diff --git a/.github/workflows/build-opensearch-image.yml b/.github/workflows/build-opensearch-image.yml index 6609175f0f8..a20871c3dbd 100644 --- a/.github/workflows/build-opensearch-image.yml +++ b/.github/workflows/build-opensearch-image.yml @@ -22,7 +22,7 @@ jobs: password: ${{ secrets.DOCKERHUB_DEV_TOKEN }} - name: opensearch/build-and-push - uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0 + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 with: provenance: false file: server/build/Dockerfile.opensearch diff --git a/.github/workflows/build-server-image.yml b/.github/workflows/build-server-image.yml index 843413b20b1..a5e9907f59d 100644 --- a/.github/workflows/build-server-image.yml +++ b/.github/workflows/build-server-image.yml @@ -27,7 +27,7 @@ jobs: password: ${{ secrets.DOCKERHUB_DEV_TOKEN }} - name: buildenv/build-and-push - uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0 + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 with: provenance: false file: server/build/Dockerfile.buildenv diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9e1ef56b85a..e48a8872bd3 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -29,14 +29,14 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.9 + uses: github/codeql-action/init@v3.28.10 with: languages: ${{ matrix.language }} debug: false config-file: ./.github/codeql/codeql-config.yml - name: Build JavaScript - uses: github/codeql-action/autobuild@v3.28.9 + uses: github/codeql-action/autobuild@v3.28.10 if: ${{ matrix.language == 'javascript' }} - name: Setup go @@ -55,4 +55,4 @@ jobs: # Perform Analysis - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.9 + uses: github/codeql-action/analyze@v3.28.10 diff --git a/.github/workflows/scorecards-analysis.yml b/.github/workflows/scorecards-analysis.yml index 0e6e97e56ec..b40d91c8b65 100644 --- a/.github/workflows/scorecards-analysis.yml +++ b/.github/workflows/scorecards-analysis.yml @@ -26,7 +26,7 @@ jobs: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 + uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 with: results_file: results.sarif results_format: sarif @@ -56,6 +56,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@0a35e8f6866a39b001e5f7ad1d0daf9836786896 # v2.27.0 + uses: github/codeql-action/upload-sarif@83923549f688e42b34d0b90ee94725f7c30532fc # v2.27.0 with: sarif_file: results.sarif diff --git a/.github/workflows/sentry.yaml b/.github/workflows/sentry.yaml index 8077372aeaf..b806a582c09 100644 --- a/.github/workflows/sentry.yaml +++ b/.github/workflows/sentry.yaml @@ -20,5 +20,5 @@ jobs: - name: cd/Checkout mattermost project uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: cd/Create Sentry release - uses: getsentry/action-release@12bba0bd9c0f65f9f80d4965db646a1aed373d36 # v1.10.3 + uses: getsentry/action-release@f56d67ba2a9debb25b217cfa562a6153174e8df6 # v3.0.0 diff --git a/.github/workflows/server-ci-artifacts.yml b/.github/workflows/server-ci-artifacts.yml index 7ee2b230a3a..60fb08459a0 100644 --- a/.github/workflows/server-ci-artifacts.yml +++ b/.github/workflows/server-ci-artifacts.yml @@ -33,7 +33,7 @@ jobs: - update-initial-status steps: - name: cd/configure-aws-credentials - uses: aws-actions/configure-aws-credentials@4fc4975a852c8cd99761e2de1f4ba73402e44dd9 # v4.0.3 + uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0 with: aws-region: us-east-1 aws-access-key-id: ${{ secrets.PR_BUILDS_BUCKET_AWS_ACCESS_KEY_ID }} @@ -83,7 +83,7 @@ jobs: password: ${{ secrets.DOCKERHUB_DEV_TOKEN }} - name: cd/setup-cosign - uses: sigstore/cosign-installer@c56c2d3e59e4281cc41dea2217323ba5694b171e # v3.8.0 + uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1 with: cosign-release: v${{ env.COSIGN_VERSION }} @@ -96,7 +96,7 @@ jobs: path: server/build/ - name: cd/setup-docker-buildx - uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3.9.0 + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - name: cd/set-docker-tag id: set_tag diff --git a/.github/workflows/server-ci-report.yml b/.github/workflows/server-ci-report.yml index 6753ac9871e..d15960ae504 100644 --- a/.github/workflows/server-ci-report.yml +++ b/.github/workflows/server-ci-report.yml @@ -57,7 +57,7 @@ jobs: run: echo "NUMBER=$(cat ${{ matrix.test.artifact }}/pr-number)" >> ${GITHUB_OUTPUT} - name: Publish test report id: report - uses: mikepenz/action-junit-report@ee6b445351cd81e2f73a16a0e52d598aeac2197f # v5.3.0 + uses: mikepenz/action-junit-report@b14027d33d3a745ccc4d6a12f649e83110b5a373 # v5.4.0 with: report_paths: ${{ matrix.test.artifact }}/report.xml check_name: ${{ matrix.test.name }} (Results) diff --git a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_account_settings_spec.js b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_account_settings_spec.js index ed166583590..50d67e3ace1 100644 --- a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_account_settings_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_account_settings_spec.js @@ -169,36 +169,43 @@ describe('Verify Accessibility Support in different sections in Settings and Pro cy.get('#displayButton').click(); cy.get('#languagesEdit').click(); - cy.get('#displayLanguage').within(() => { - cy.get('input').should('have.attr', 'aria-autocomplete', 'list').and('have.attr', 'aria-labelledby', 'changeInterfaceLanguageLabel').as('inputEl'); - }); + cy.findByRole('combobox', {name: 'Dropdown selector to change the interface language'}).should('have.attr', 'aria-autocomplete', 'list').and('have.attr', 'aria-labelledby', 'changeInterfaceLanguageLabel').as('inputEl'); cy.get('#changeInterfaceLanguageLabel').should('be.visible').and('have.text', 'Change interface language'); - // # When enter key is pressed on dropdown, it should expand and collapse - cy.get('@inputEl').typeWithForce('{enter}'); - cy.get('#displayLanguage>div').should('have.class', 'react-select__control--menu-is-open'); - cy.get('@inputEl').typeWithForce('{enter}'); - cy.get('#displayLanguage>div').should('not.have.class', 'react-select__control--menu-is-open'); + // # When space key is pressed on dropdown, it should expand and should collapse when esc key is pressed + cy.get('@inputEl').typeWithForce(' '); + cy.findByRole('listbox').should('have.class', 'react-select__menu-list').as('listBox'); + cy.get('@inputEl').typeWithForce('{esc}'); + cy.get('@listBox').should('not.exist'); // # Press down arrow twice and check aria label - cy.get('@inputEl').typeWithForce('{enter}'); + cy.get('@inputEl').typeWithForce(' '); cy.get('@inputEl').typeWithForce('{downarrow}{downarrow}'); - cy.get('#displayLanguage>span').as('ariaEl').within(($el) => { - cy.wrap($el).should('have.attr', 'aria-live', 'assertive'); - cy.get('#aria-context').should('contain', 'option English (Australia) focused').and('contain', 'Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.'); + cy.get('#displayLanguage').within(($el) => { + cy.wrap($el).findByRole('log').should('have.attr', 'aria-live', 'assertive').as('ariaEl'); + }); + cy.get('@ariaEl').within(($el) => { + cy.wrap($el).get('#aria-focused').should('contain', 'option English (Australia) focused'); + cy.wrap($el).get('#aria-guidance').should('contain', 'Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.'); }); - // # Check if language setting gets changed after user presses enter - cy.get('@inputEl').typeWithForce('{enter}'); + // # Check if language setting gets changed after user presses space + cy.get('@inputEl').typeWithForce(' '); cy.get('#displayLanguage').should('contain', 'English (Australia)'); - cy.get('@ariaEl').get('#aria-selection-event').should('contain', 'option English (Australia), selected'); + cy.get('@ariaEl').within(($el) => { + cy.wrap($el).get('#aria-selection').should('contain', 'option English (Australia) selected'); + }); - // # Press down arrow, then up arrow and press enter + // # Press down arrow, then up arrow and press space cy.get('@inputEl').typeWithForce('{downarrow}{downarrow}{downarrow}{uparrow}'); - cy.get('@ariaEl').get('#aria-context').should('contain', 'option English (US) focused'); - cy.get('@inputEl').typeWithForce('{enter}'); + cy.get('@ariaEl').within(($el) => { + cy.wrap($el).get('#aria-focused').should('contain', 'option English (US) focused'); + }); + cy.get('@inputEl').typeWithForce(' '); cy.get('#displayLanguage').should('contain', 'English (US)'); - cy.get('@ariaEl').get('#aria-selection-event').should('contain', 'option English (US), selected'); + cy.get('@ariaEl').within(($el) => { + cy.wrap($el).get('#aria-selection').should('contain', 'option English (US) selected'); + }); }); it('MM-T1488 Profile Picture should read labels', () => { diff --git a/e2e-tests/cypress/tests/integration/channels/bot_accounts/in_teams_and_channels_spec.ts b/e2e-tests/cypress/tests/integration/channels/bot_accounts/in_teams_and_channels_spec.ts index 2004d4daaa2..6baf89138dc 100644 --- a/e2e-tests/cypress/tests/integration/channels/bot_accounts/in_teams_and_channels_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/bot_accounts/in_teams_and_channels_spec.ts @@ -73,7 +73,7 @@ describe('Managing bots in Teams and Channels', () => { cy.postMessage(`/invite @${bot.username} `); // * Verify system message in-channel - cy.uiWaitUntilMessagePostedIncludes(`@${bot.username} is not a member of the team.`); + cy.uiWaitUntilMessagePostedIncludes(`You can add @${bot.username} to this channel once they are members of`); }); }); diff --git a/e2e-tests/cypress/tests/integration/channels/channel/channel_info_rhs_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel/channel_info_rhs_spec.ts index 88ae7cda0e7..cc35e4ab505 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/channel_info_rhs_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/channel/channel_info_rhs_spec.ts @@ -239,7 +239,7 @@ describe('Channel Info RHS', () => { cy.uiGetRHS().findByText('Notification Preferences').should('be.visible').click(); // * Ensures the modal is there - cy.get('.channel-notifications-settings-modal').should('be.visible'); + cy.get('.ChannelNotificationModal').should('be.visible'); }); it('should be able to view files and come back', () => { // # Go to test channel @@ -401,7 +401,7 @@ describe('Channel Info RHS', () => { cy.uiGetRHS().findByText('Notification Preferences').should('be.visible').click(); // * Ensures the modal is there - cy.get('.channel-notifications-settings-modal').should('be.visible'); + cy.get('.ChannelNotificationModal').should('be.visible'); }); }); }); diff --git a/e2e-tests/cypress/tests/integration/channels/channel/convert_group_message_to_private_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel/convert_group_message_to_private_spec.ts index 4f49e2ec71e..08f7650a072 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/convert_group_message_to_private_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/channel/convert_group_message_to_private_spec.ts @@ -67,7 +67,7 @@ describe('Group Message Conversion To Private Channel', () => { cy.get('.GenericModal__button.delete.disabled').wait(2000); // Open the team dropdown and select a team - cy.findByText('Select Team').click(); + cy.findByText('Select Team').click({force: true}); cy.findByText(testTeam2.display_name).click(); // Enter the new channel name and confirm diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_modals_dialogs_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_modals_dialogs_spec.ts index c11aad30d31..95fb568b858 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_modals_dialogs_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_modals_dialogs_spec.ts @@ -206,7 +206,7 @@ describe('Verify Accessibility Support in Modals & Dialogs', () => { cy.get('#invitePeople').should('be.visible').click(); // * Verify accessibility support in Invite People Dialog - cy.get('.InvitationModal').should('have.attr', 'aria-modal', 'true').and('have.attr', 'aria-labelledby', 'invitation_modal_title').and('have.attr', 'role', 'dialog'); + cy.findByTestId('invitationModal').should('have.attr', 'aria-modal', 'true').and('have.attr', 'aria-labelledby', 'invitation_modal_title').and('have.attr', 'role', 'dialog'); cy.get('#invitation_modal_title').should('be.visible').and('contain.text', 'Invite people to'); // # Press tab diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_ui_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_ui_spec.ts index 592143e0783..62712fd3fa2 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_ui_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_ui_spec.ts @@ -166,7 +166,7 @@ describe('Verify Guest User Identification in different screens', () => { // # Open a DM with Guest User cy.uiAddDirectMessage().click(); cy.findByRole('dialog', {name: 'Direct Messages'}).should('be.visible').wait(TIMEOUTS.ONE_SEC); - cy.findByRole('textbox', {name: 'Search for people'}). + cy.findByRole('combobox', {name: 'Search for people'}). should('have.focused'). typeWithForce(guestUser.username). wait(TIMEOUTS.ONE_SEC). @@ -182,12 +182,12 @@ describe('Verify Guest User Identification in different screens', () => { // # Open a GM with Guest User and Sysadmin cy.uiAddDirectMessage().click(); cy.findByRole('dialog', {name: 'Direct Messages'}).should('be.visible').wait(TIMEOUTS.ONE_SEC); - cy.findByRole('textbox', {name: 'Search for people'}). + cy.findByRole('combobox', {name: 'Search for people'}). should('have.focused'). typeWithForce(guestUser.username). wait(TIMEOUTS.ONE_SEC). typeWithForce('{enter}'); - cy.findByRole('textbox', {name: 'Search for people'}). + cy.findByRole('combobox', {name: 'Search for people'}). should('have.focused'). typeWithForce(admin.username). wait(TIMEOUTS.ONE_SEC). diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_guest_access_ui_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_guest_access_ui_spec.ts index 6445bb4f0e4..09d222d47d5 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_guest_access_ui_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_guest_access_ui_spec.ts @@ -82,8 +82,8 @@ describe('Guest Account - Verify Guest Access UI', () => { // * Verify the confirmation message displayed cy.get('#confirmModal').should('be.visible').within(() => { - cy.get('#confirmModalLabel').should('be.visible').and('have.text', 'Save and Disable Guest Access?'); - cy.get('.modal-body').should('be.visible').and('have.text', 'Disabling guest access will revoke all current Guest Account sessions. Guests will no longer be able to login and new guests cannot be invited into Mattermost. Guest users will be marked as inactive in user lists. Enabling this feature will not reinstate previous guest accounts. Are you sure you wish to remove these users?'); + cy.get('#genericModalLabel').should('be.visible').and('have.text', 'Save and Disable Guest Access?'); + cy.get('.ConfirmModal__body').should('be.visible').and('have.text', 'Disabling guest access will revoke all current Guest Account sessions. Guests will no longer be able to login and new guests cannot be invited into Mattermost. Guest users will be marked as inactive in user lists. Enabling this feature will not reinstate previous guest accounts. Are you sure you wish to remove these users?'); cy.get('#confirmModalButton').should('have.text', 'Save and Disable Guest Access'); }); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_not_cloud_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_not_cloud_spec.ts index 24ae4654f13..bdf1d9ddc5c 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_not_cloud_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_not_cloud_spec.ts @@ -65,8 +65,8 @@ describe('Guest Account - Verify Manage Guest Users', () => { // * Verify the confirmation message displayed cy.get('#confirmModal').should('be.visible').within(() => { - cy.get('#confirmModalLabel').should('be.visible').and('have.text', `Deactivate ${guestUser.username}`); - cy.get('.modal-body').should('be.visible').and('have.text', `This action deactivates ${guestUser.username}. They will be logged out and not have access to any teams or channels on this system.\nAre you sure you want to deactivate ${guestUser.username}?`); + cy.get('#genericModalLabel').should('be.visible').and('have.text', `Deactivate ${guestUser.username}`); + cy.get('.modal-body .ConfirmModal__body').should('be.visible').and('have.text', `This action deactivates ${guestUser.username}. They will be logged out and not have access to any teams or channels on this system.\nAre you sure you want to deactivate ${guestUser.username}?`); }); // * Verify the behavior when Cancel button in the confirmation message is clicked diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_spec.ts index 796198ab932..c70962e1f81 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_spec.ts @@ -106,8 +106,8 @@ describe('Guest Account - Verify Manage Guest Users', () => { // * Verify the confirmation message displayed cy.get('#confirmModal').should('be.visible').within(() => { - cy.get('#confirmModalLabel').should('be.visible').and('have.text', `Revoke Sessions for ${guestUser.username}`); - cy.get('.modal-body').should('be.visible').and('have.text', `This action revokes all sessions for ${guestUser.username}. They will be logged out from all devices. Are you sure you want to revoke all sessions for ${guestUser.username}?`); + cy.get('#genericModalLabel').should('be.visible').and('have.text', `Revoke Sessions for ${guestUser.username}`); + cy.get('.modal-body .ConfirmModal__body').should('be.visible').and('have.text', `This action revokes all sessions for ${guestUser.username}. They will be logged out from all devices. Are you sure you want to revoke all sessions for ${guestUser.username}?`); }); // * Verify the behavior when Cancel button in the confirmation message is clicked diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/channel_members_spec.js b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/channel_members_spec.js index 964cfc0845c..b1a62e89c4f 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/channel_members_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/channel_members_spec.js @@ -119,7 +119,7 @@ describe('Channel members test', () => { cy.get('#addChannelMembers').click(); // # Enter user1 and user2 emails - cy.findByRole('textbox', {name: 'Search for people or groups'}).typeWithForce(`${user1.email}{enter}${user2.email}{enter}`); + cy.findByRole('combobox', {name: 'Search for people or groups'}).typeWithForce(`${user1.email}{enter}${user2.email}{enter}`); // # Confirm add the users cy.get('#addUsersToChannelModal #saveItems').click(); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/channel_moderation/channel_mentions_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/channel_moderation/channel_mentions_spec.ts index 7758bb80e8e..cc2b6ba37a6 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/channel_moderation/channel_mentions_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/channel_moderation/channel_mentions_spec.ts @@ -169,14 +169,14 @@ describe('MM-23102 - Channel Moderation - Channel Mentions', () => { // * Type at all and enter that no confirmation dialogue shows up cy.postMessage('@all '); - cy.get('#confirmModalLabel').should('not.exist'); + cy.get('#genericModalLabel').should('not.exist'); // * Type at channel and enter that no confirmation dialogue shows up cy.postMessage('@channel '); - cy.get('#confirmModalLabel').should('not.exist'); + cy.get('#genericModalLabel').should('not.exist'); // * Type at here and enter that no confirmation dialogue shows up cy.postMessage('@here '); - cy.get('#confirmModalLabel').should('not.exist'); + cy.get('#genericModalLabel').should('not.exist'); }); }); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/helpers.js b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/helpers.js index 788b8fb03e1..af70532532a 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/helpers.js +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/helpers.js @@ -50,7 +50,7 @@ export function makeUserASystemRole(testUsers, role) { cy.findByRole('button', {name: 'Add People'}).click().wait(TIMEOUTS.HALF_SEC); // # Type in user name - cy.findByRole('textbox', {name: 'Search for people'}).typeWithForce(`${testUsers[role].email}`); + cy.findByRole('combobox', {name: 'Search for people'}).typeWithForce(`${testUsers[role].email}`); // # Find the user and click on him cy.get('#multiSelectList').should('be.visible').children().first().click({force: true}); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/user_management/user_management_admin_control_spec.js b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/user_management/user_management_admin_control_spec.js index 4408cb7eac0..df14a3cc794 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/user_management/user_management_admin_control_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/user_management/user_management_admin_control_spec.js @@ -103,11 +103,11 @@ describe('User Management', () => { function verifyManageUserSettingModal(user, writeAccess) { if (writeAccess) { cy.get('.manageUserSettingsBtn').should('be.visible').should('have.text', 'Manage User Settings').click(); - cy.get('#confirmModalLabel').should('be.visible').should('have.text', `Manage ${user.nickname}'s Settings`); + cy.get('#genericModalLabel').should('be.visible').should('have.text', `Manage ${user.nickname}'s Settings`); cy.get('#cancelModalButton').should('be.visible').should('have.text', 'Cancel'); cy.get('#confirmModalButton').should('be.visible').should('have.text', 'Manage User Settings').click(); - cy.get('h2#accountSettingsModalLabel').should('be.visible').should('have.text', `Manage ${user.nickname}'s Settings`); + cy.get('span#accountSettingsModalLabel').should('be.visible').should('have.text', `Manage ${user.nickname}'s Settings`); cy.get('.adminModeBadge').should('be.visible').should('have.text', 'Admin Mode'); } else { cy.get('.manageUserSettingsBtn').should('not.exist'); @@ -135,7 +135,7 @@ describe('User Management', () => { cy.findByRole('button', {name: 'Add People'}).click().wait(TIMEOUTS.HALF_SEC); // # Type in user name - cy.findByRole('textbox', {name: 'Search for people'}).typeWithForce(`${userEmail}`); + cy.findByRole('combobox', {name: 'Search for people'}).typeWithForce(`${userEmail}`); // # Find the user and click on him cy.get('#multiSelectList').should('be.visible').children().first().click({force: true}); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/dm_list_of_users_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/dm_list_of_users_spec.js index e7b114f9635..478279e31ee 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/dm_list_of_users_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/dm_list_of_users_spec.js @@ -44,7 +44,7 @@ describe('Messaging', () => { // # Search for the deactivated user cy.findByRole('dialog', {name: 'Direct Messages'}).should('be.visible').wait(TIMEOUTS.ONE_SEC); - cy.findByRole('textbox', {name: 'Search for people'}).typeWithForce(deactivatedUser.email); + cy.findByRole('combobox', {name: 'Search for people'}).typeWithForce(deactivatedUser.email); // * Verify that the inactive user is not found cy.get('.no-channel-message').should('be.visible').and('contain', 'No results found matching'); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/header_not_cloud_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/header_not_cloud_spec.js index 677d008cbfc..d737f1243bf 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/header_not_cloud_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/header_not_cloud_spec.js @@ -51,7 +51,7 @@ describe('Header', () => { // # Open a DM with the bot cy.uiAddDirectMessage().click().wait(TIMEOUTS.ONE_SEC); cy.findByRole('dialog', {name: 'Direct Messages'}).should('be.visible').wait(TIMEOUTS.ONE_SEC); - cy.findByRole('textbox', {name: 'Search for people'}). + cy.findByRole('combobox', {name: 'Search for people'}). typeWithForce('matterpoll').wait(TIMEOUTS.ONE_SEC). typeWithForce('{enter}'); cy.get('#selectItems').contains('matterpoll'); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/permalink_click_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/permalink_click_spec.js index bed65aa3eed..67a842c41b6 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/permalink_click_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/permalink_click_spec.js @@ -127,6 +127,6 @@ function joinPrivateChannel(channel) { function verifyPrivateChannelJoinPromptIsVisible(channel) { // * Verify modal is shown before joining the private channel cy.get('#confirmModal').should('be.visible'); - cy.get('#confirmModalLabel').should('be.visible').and('have.text', 'Join private channel'); + cy.get('#genericModalLabel').should('be.visible').and('have.text', 'Join private channel'); cy.get('#confirmModalBody').should('be.visible').and('have.text', `You are about to join ${channel.name} without explicitly being added by the channel admin. Are you sure you wish to join this private channel?`); } diff --git a/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/dm_more_searching_from_page_spec.js b/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/dm_more_searching_from_page_spec.js index aebedbddfbb..a0457085272 100644 --- a/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/dm_more_searching_from_page_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/dm_more_searching_from_page_spec.js @@ -47,7 +47,7 @@ describe('Multi Team and DM', () => { cy.findByText('Previous').should('exist'); // # Enter a search term - cy.findByRole('textbox', {name: 'Search for people'}).typeWithForce(searchTerm); + cy.findByRole('combobox', {name: 'Search for people'}).typeWithForce(searchTerm); // * Assert that the previous / next links do not appear since there should only be 1 record displayed cy.findByText('Next').should('not.exist'); diff --git a/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/dm_more_show_user_count_spec.js b/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/dm_more_show_user_count_spec.js index c87f06476cd..35ccbbdde28 100644 --- a/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/dm_more_show_user_count_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/dm_more_show_user_count_spec.js @@ -55,7 +55,7 @@ describe('Multi Team and DM', () => { const totalUsers = number.text().split(' ').slice(2, 3); // * Assert that 2 unique users are displayed - cy.findByRole('textbox', {name: 'Search for people'}).typeWithForce(unique).then(() => { + cy.findByRole('combobox', {name: 'Search for people'}).typeWithForce(unique).then(() => { cy.get('#multiSelectList').within(() => { cy.get('.more-modal__details').should('have.length', 2); }); diff --git a/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/gm_add_user_spec.js b/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/gm_add_user_spec.js index 8cded03fa16..d2055a276d3 100644 --- a/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/gm_add_user_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/gm_add_user_spec.js @@ -57,7 +57,7 @@ describe('Multi-user group messages', () => { cy.findByRole('dialog', {name: 'Direct Messages'}).should('be.visible').wait(TIMEOUTS.ONE_SEC); // # Start typing part of a username that matches previously created users - cy.findByRole('textbox', {name: 'Search for people'}). + cy.findByRole('combobox', {name: 'Search for people'}). typeWithForce(searchTerm). wait(TIMEOUTS.ONE_SEC); diff --git a/e2e-tests/cypress/tests/integration/channels/notifications/notification_preferences_do_not_save_spec.js b/e2e-tests/cypress/tests/integration/channels/notifications/notification_preferences_do_not_save_spec.js index 165030ea7d3..ddf73c68f6c 100644 --- a/e2e-tests/cypress/tests/integration/channels/notifications/notification_preferences_do_not_save_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/notifications/notification_preferences_do_not_save_spec.js @@ -55,7 +55,7 @@ describe('Notifications', () => { cy.wait(TIMEOUTS.HALF_SEC); // # Close the modal - cy.get('#accountSettingsHeader').find('button').should('be.visible').click(); + cy.uiClose(); } else { // * Ensure that 'Send email notifications' is set to 'Immediately' cy.get('#emailNotificationImmediately').should('be.visible').and('be.checked'); diff --git a/e2e-tests/cypress/tests/integration/channels/settings/display/channel_display_mode_spec.js b/e2e-tests/cypress/tests/integration/channels/settings/display/channel_display_mode_spec.js index 16ae3968102..f7750da925c 100644 --- a/e2e-tests/cypress/tests/integration/channels/settings/display/channel_display_mode_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/settings/display/channel_display_mode_spec.js @@ -43,7 +43,6 @@ describe('Settings > Display > Channel Display Mode', () => { cy.get('#channel_display_modeTitle').should('contain', 'Channel Display'); cy.get('#channel_display_modeDesc').should('contain', 'Full width'); cy.get('#channel_display_modeEdit').should('contain', 'Edit'); - cy.get('#accountSettingsHeader > .close').should('be.visible'); }); it('should render in max setting view', () => { @@ -59,7 +58,6 @@ describe('Settings > Display > Channel Display Mode', () => { cy.get('#channel_display_modeFormatB').should('be.visible'); cy.get('#saveSetting').should('contain', 'Save'); cy.get('#cancelSetting').should('contain', 'Cancel'); - cy.get('#accountSettingsHeader > .close').should('be.visible'); }); it('MM-T296 change channel display mode setting to "Full width"', () => { diff --git a/e2e-tests/cypress/tests/integration/channels/settings/display/theme/code_theme_colors_spec.js b/e2e-tests/cypress/tests/integration/channels/settings/display/theme/code_theme_colors_spec.js index 45f09a99679..795b811fe8f 100644 --- a/e2e-tests/cypress/tests/integration/channels/settings/display/theme/code_theme_colors_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/settings/display/theme/code_theme_colors_spec.js @@ -35,10 +35,13 @@ describe('Settings > Display > Theme > Custom Theme Colors', () => { cy.get('#customThemes').check().should('be.checked'); // # Open Center Channel Styles section - cy.get('#centerChannelStyles').click({force: true}).wait(TIMEOUTS.ONE_HUNDRED_MILLIS); + cy.get('#centerChannelStylesAccordion').click({force: true}).wait(TIMEOUTS.ONE_HUNDRED_MILLIS); // # Select custom code theme - cy.get('#codeThemeSelect').should('be.visible').scrollIntoView().select(theme.name); + cy.get('#codeThemeSelect'). + scrollIntoView({offset: {top: 20, left: 0}}). + should('exist'). + select(theme.name, {force: true}); // * Verify that the setting changes in the background? verifyLastPostStyle(theme); diff --git a/e2e-tests/cypress/tests/integration/channels/settings/display/timezone_display_mode_spec.js b/e2e-tests/cypress/tests/integration/channels/settings/display/timezone_display_mode_spec.js index f65d741b009..8fb29bda4a2 100644 --- a/e2e-tests/cypress/tests/integration/channels/settings/display/timezone_display_mode_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/settings/display/timezone_display_mode_spec.js @@ -196,7 +196,7 @@ function setTimezoneDisplayTo(isAutomatic, value) { }); // # Close Settings modal - cy.get('#accountSettingsHeader > .close').should('be.visible').click(); + cy.uiClose(); } function setTimezoneDisplayToAutomatic(value) { diff --git a/e2e-tests/cypress/tests/integration/channels/system_console/user_management/users_deactivation_spec.js b/e2e-tests/cypress/tests/integration/channels/system_console/user_management/users_deactivation_spec.js index 7f24904ba74..11a9bab3e24 100644 --- a/e2e-tests/cypress/tests/integration/channels/system_console/user_management/users_deactivation_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/system_console/user_management/users_deactivation_spec.js @@ -95,7 +95,7 @@ describe('System Console > User Management > Deactivation', () => { cy.findByRole('dialog', {name: 'Direct Messages'}).should('be.visible').wait(TIMEOUTS.ONE_SEC); // # Start typing part of a username that matches previously created users - cy.findByRole('textbox', {name: 'Search for people'}). + cy.findByRole('combobox', {name: 'Search for people'}). typeWithForce(other.username). wait(TIMEOUTS.ONE_SEC); @@ -127,7 +127,7 @@ describe('System Console > User Management > Deactivation', () => { cy.uiAddDirectMessage().click().wait(TIMEOUTS.HALF_SEC); // # Type the user name of user1 on Channel switcher input - cy.findByRole('textbox', {name: 'Search for people'}). + cy.findByRole('combobox', {name: 'Search for people'}). typeWithForce(user1.username). wait(TIMEOUTS.ONE_SEC); diff --git a/e2e-tests/cypress/tests/integration/channels/team_settings/closed_team_invite_by_email_spec.js b/e2e-tests/cypress/tests/integration/channels/team_settings/closed_team_invite_by_email_spec.js index dc2fd0d0d04..e1303354496 100644 --- a/e2e-tests/cypress/tests/integration/channels/team_settings/closed_team_invite_by_email_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/team_settings/closed_team_invite_by_email_spec.js @@ -85,7 +85,7 @@ describe('Team Settings', () => { cy.get('.InviteAs').findByTestId('inviteMembersLink').click(); } - cy.findByRole('textbox', {name: 'Add or Invite People'}).type(email, {force: true}).wait(TIMEOUTS.HALF_SEC).type('{enter}', {force: true}); + cy.findByRole('combobox', {name: 'Add or Invite People'}).type(email, {force: true}).wait(TIMEOUTS.HALF_SEC).type('{enter}', {force: true}); cy.findByTestId('inviteButton').click(); // # Wait for a while to ensure that email notification is sent and logout from sysadmin account diff --git a/e2e-tests/cypress/tests/integration/channels/team_settings/invite_user_to_closed_team_spec.js b/e2e-tests/cypress/tests/integration/channels/team_settings/invite_user_to_closed_team_spec.js index d9577a58d85..9b629e9a9fc 100644 --- a/e2e-tests/cypress/tests/integration/channels/team_settings/invite_user_to_closed_team_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/team_settings/invite_user_to_closed_team_spec.js @@ -82,7 +82,7 @@ describe('Team Settings', () => { function inviteNewMemberToTeam(email) { cy.wait(TIMEOUTS.HALF_SEC); - cy.findByRole('textbox', {name: 'Add or Invite People'}). + cy.findByRole('combobox', {name: 'Add or Invite People'}). typeWithForce(email). wait(TIMEOUTS.HALF_SEC). typeWithForce('{enter}'); diff --git a/e2e-tests/cypress/tests/integration/playbooks/channels/rhs/about_spec.js b/e2e-tests/cypress/tests/integration/playbooks/channels/rhs/about_spec.js index 64bf07efd76..7e6465ee173 100644 --- a/e2e-tests/cypress/tests/integration/playbooks/channels/rhs/about_spec.js +++ b/e2e-tests/cypress/tests/integration/playbooks/channels/rhs/about_spec.js @@ -90,7 +90,7 @@ describe('channels > rhs > header', {testIsolation: true}, () => { cy.get('#rhsContainer').findByTestId('rendered-run-name').should('be.visible').contains('new run name'); // * make sure the channel name remains unchanged - cy.get('#channelHeaderInfo').findByRole('heading').contains(playbookRunName); + cy.get('#channelHeaderInfo').contains(playbookRunName); }); }); diff --git a/e2e-tests/cypress/tests/support/ui/channel.js b/e2e-tests/cypress/tests/support/ui/channel.js index 635c0acfe66..9b4de2238fb 100644 --- a/e2e-tests/cypress/tests/support/ui/channel.js +++ b/e2e-tests/cypress/tests/support/ui/channel.js @@ -96,7 +96,7 @@ Cypress.Commands.add('goToDm', (username) => { // # Start typing part of a username that matches previously created users cy.get('#selectItems input').typeWithForce(username); cy.findByRole('dialog', {name: 'Direct Messages'}).should('be.visible').wait(TIMEOUTS.ONE_SEC); - cy.findByRole('textbox', {name: 'Search for people'}). + cy.findByRole('combobox', {name: 'Search for people'}). typeWithForce(username). wait(TIMEOUTS.ONE_SEC). typeWithForce('{enter}'); diff --git a/e2e-tests/cypress/tests/support/ui/channel_sidebar.ts b/e2e-tests/cypress/tests/support/ui/channel_sidebar.ts index bdbed421649..569a2fec07c 100644 --- a/e2e-tests/cypress/tests/support/ui/channel_sidebar.ts +++ b/e2e-tests/cypress/tests/support/ui/channel_sidebar.ts @@ -16,7 +16,7 @@ function uiCreateSidebarCategory(categoryName: string = `category-${getRandomId( // # Click on the sidebar menu dropdown and select Create Category cy.uiBrowseOrCreateChannel('Create new category'); - cy.findByRole('dialog', {name: 'Rename Category'}).should('be.visible').within(() => { + cy.findByRole('dialog', {name: 'Create New Category'}).should('be.visible').within(() => { // # Fill in the category name and click 'Create' cy.findByRole('textbox').should('be.visible').typeWithForce(categoryName). invoke('val').should('equal', categoryName); @@ -59,7 +59,7 @@ function uiMoveChannelToCategory(channelName: string, categoryName: string, newC }); if (newCategory) { - cy.findByRole('dialog', {name: 'Rename Category'}).should('be.visible').within(() => { + cy.findByRole('dialog', {name: 'Create New Category'}).should('be.visible').within(() => { // # Fill in the category name and click 'Create' cy.findByRole('textbox').should('be.visible').typeWithForce(categoryName). invoke('val').should('equal', categoryName); diff --git a/e2e-tests/cypress/tests/support/ui/data_retention.js b/e2e-tests/cypress/tests/support/ui/data_retention.js index 0d8f23c87f2..fdc8e20bd15 100644 --- a/e2e-tests/cypress/tests/support/ui/data_retention.js +++ b/e2e-tests/cypress/tests/support/ui/data_retention.js @@ -28,7 +28,7 @@ Cypress.Commands.add('uiFillOutCustomPolicyFields', (name, durationDropdown, dur Cypress.Commands.add('uiAddTeamsToCustomPolicy', (teamNames) => { cy.uiGetButton('Add teams').click(); teamNames.forEach((teamName) => { - cy.findByRole('textbox', {name: 'Search and add teams'}).typeWithForce(teamName); + cy.findByRole('combobox', {name: 'Search and add teams'}).typeWithForce(teamName); cy.get('.team-info-block').then((el) => { el.click(); }); @@ -39,7 +39,7 @@ Cypress.Commands.add('uiAddTeamsToCustomPolicy', (teamNames) => { Cypress.Commands.add('uiAddChannelsToCustomPolicy', (channelNames) => { cy.uiGetButton('Add channels').click(); channelNames.forEach((channelName) => { - cy.findByRole('textbox', {name: 'Search and add channels'}).typeWithForce(channelName); + cy.findByRole('combobox', {name: 'Search and add channels'}).typeWithForce(channelName); cy.wait(TIMEOUTS.ONE_SEC); cy.get('.channel-info-block').then((el) => { el.click(); diff --git a/e2e-tests/cypress/tests/support/ui_commands.ts b/e2e-tests/cypress/tests/support/ui_commands.ts index d6cf35744ae..4cf093365f2 100644 --- a/e2e-tests/cypress/tests/support/ui_commands.ts +++ b/e2e-tests/cypress/tests/support/ui_commands.ts @@ -237,7 +237,7 @@ function uiGotoDirectMessageWithUser(user: User) { cy.findByRole('dialog', {name: 'Direct Messages'}).should('be.visible').wait(TIMEOUTS.ONE_SEC); // # Type username - cy.findByRole('textbox', {name: 'Search for people'}).click({force: true}). + cy.findByRole('combobox', {name: 'Search for people'}).click({force: true}). type(user.username, {force: true}).wait(TIMEOUTS.ONE_SEC); // * Expect user count in the list to be 1 diff --git a/server/Makefile b/server/Makefile index 07bc7efee9f..7ffe02170d0 100644 --- a/server/Makefile +++ b/server/Makefile @@ -112,6 +112,7 @@ GO_MINOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'. MINIMUM_SUPPORTED_GO_MAJOR_VERSION = 1 MINIMUM_SUPPORTED_GO_MINOR_VERSION = 15 GO_VERSION_VALIDATION_ERR_MSG = Golang version is not supported, please update to at least $(MINIMUM_SUPPORTED_GO_MAJOR_VERSION).$(MINIMUM_SUPPORTED_GO_MINOR_VERSION) +GO_COMPATIBILITY_TEST_VERSIONS := 1.22.7 1.23.6 # GOOS/GOARCH of the build host, used to determine whether we're cross-compiling or not BUILDER_GOOS_GOARCH="$(shell $(GO) env GOOS)_$(shell $(GO) env GOARCH)" @@ -135,10 +136,10 @@ TEMPLATES_DIR=templates # Plugins Packages PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:) -PLUGIN_PACKAGES += mattermost-plugin-calls-v1.5.1 +PLUGIN_PACKAGES += mattermost-plugin-calls-v1.5.2 PLUGIN_PACKAGES += mattermost-plugin-github-v2.3.0 PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.9.1 -PLUGIN_PACKAGES += mattermost-plugin-jira-v4.2.0 +PLUGIN_PACKAGES += mattermost-plugin-jira-v4.2.1 # We need to prepackage both versions of playbooks and install the correct one based on the server license. See MM-60025. PLUGIN_PACKAGES += mattermost-plugin-playbooks-v1.40.0 PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.1.1 @@ -147,7 +148,7 @@ PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.3.4 PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.8.0 PLUGIN_PACKAGES += mattermost-plugin-ai-v1.1.1 PLUGIN_PACKAGES += mattermost-plugin-boards-v9.1.1 -PLUGIN_PACKAGES += mattermost-plugin-msteams-v2.1.0 +PLUGIN_PACKAGES += mattermost-plugin-msteams-v2.1.1 PLUGIN_PACKAGES += mattermost-plugin-user-survey-v1.1.1 PLUGIN_PACKAGES += mattermost-plugin-mscalendar-v1.3.4 PLUGIN_PACKAGES += mattermost-plugin-msteams-meetings-v2.2.0 @@ -283,6 +284,13 @@ else $(MAKE) mmctl-build endif +golang-versions: ## Install Golang versions used for compatibility testing (e.g. plugins) + @for version in $(GO_COMPATIBILITY_TEST_VERSIONS); do \ + $(GO) install golang.org/dl/go$$version@latest && \ + $(GOBIN)/go$$version download; \ + done + export GO_COMPATIBILITY_TEST_VERSIONS="${GO_COMPATIBILITY_TEST_VERSIONS}" + golangci-lint: ## Run golangci-lint on codebase $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.57.1 @@ -412,7 +420,7 @@ modules-tidy: ## Tidy Go modules -cd public && $(GO) mod tidy mv enterprise/external_imports.go.orig enterprise/external_imports.go -test-server-pre: check-prereqs-enterprise start-docker gotestsum ## Runs tests. +test-server-pre: check-prereqs-enterprise start-docker gotestsum golang-versions ## Runs tests. ifeq ($(BUILD_ENTERPRISE_READY),true) @echo Running all tests else diff --git a/server/channels/api4/config_test.go b/server/channels/api4/config_test.go index 7be9eef6a5e..55e482d2c49 100644 --- a/server/channels/api4/config_test.go +++ b/server/channels/api4/config_test.go @@ -122,6 +122,7 @@ func TestGetConfigAnyFlagsAccess(t *testing.T) { require.NoError(t, err) t.Run("Can read value with permission", func(t *testing.T) { assert.NotNil(t, cfg.FeatureFlags) + assert.NotNil(t, cfg.ExperimentalSettings.RestrictSystemAdmin) }) } diff --git a/server/channels/api4/emoji_test.go b/server/channels/api4/emoji_test.go index 67b2f804151..c0cad849b08 100644 --- a/server/channels/api4/emoji_test.go +++ b/server/channels/api4/emoji_test.go @@ -222,6 +222,14 @@ func TestGetEmojiList(t *testing.T) { }() th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true }) + t.Run("should return an empty array when there are no custom emoijs", func(t *testing.T) { + listEmoji, _, err := client.GetEmojiList(context.Background(), 0, 100) + require.NoError(t, err) + + require.NotEqual(t, nil, listEmoji) + require.Equal(t, []*model.Emoji{}, listEmoji) + }) + emojis := []*model.Emoji{ { CreatorId: th.BasicUser.Id, @@ -237,47 +245,63 @@ func TestGetEmojiList(t *testing.T) { }, } - for idx, emoji := range emojis { - newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif") - require.NoError(t, err) - emojis[idx] = newEmoji - } + t.Run("should return an array of emojis", func(t *testing.T) { + for idx, emoji := range emojis { + newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif") + require.NoError(t, err) + emojis[idx] = newEmoji + } - listEmoji, _, err := client.GetEmojiList(context.Background(), 0, 100) - require.NoError(t, err) - for _, emoji := range emojis { + listEmoji, _, err := client.GetEmojiList(context.Background(), 0, 100) + require.NoError(t, err) + for _, emoji := range emojis { + found := false + for _, savedEmoji := range listEmoji { + if emoji.Id == savedEmoji.Id { + found = true + break + } + } + require.Truef(t, found, "failed to get emoji with id %v, %v", emoji.Id, len(listEmoji)) + } + }) + + t.Run("should return an empty array when past the maximum page count", func(t *testing.T) { + listEmoji, _, err := client.GetEmojiList(context.Background(), 1, 100) + require.NoError(t, err) + + require.NotEqual(t, nil, listEmoji) + require.Equal(t, []*model.Emoji{}, listEmoji) + }) + + t.Run("should not return a deleted emoji", func(t *testing.T) { + _, err := client.DeleteEmoji(context.Background(), emojis[0].Id) + require.NoError(t, err) + listEmoji, _, err := client.GetEmojiList(context.Background(), 0, 100) + require.NoError(t, err) found := false for _, savedEmoji := range listEmoji { - if emoji.Id == savedEmoji.Id { + if savedEmoji.Id == emojis[0].Id { found = true break } } - require.Truef(t, found, "failed to get emoji with id %v, %v", emoji.Id, len(listEmoji)) - } + require.Falsef(t, found, "should not get a deleted emoji %v", emojis[0].Id) + }) - _, err = client.DeleteEmoji(context.Background(), emojis[0].Id) - require.NoError(t, err) - listEmoji, _, err = client.GetEmojiList(context.Background(), 0, 100) - require.NoError(t, err) - found := false - for _, savedEmoji := range listEmoji { - if savedEmoji.Id == emojis[0].Id { - found = true - break - } - } - require.Falsef(t, found, "should not get a deleted emoji %v", emojis[0].Id) + t.Run("should return fewer results based on the provided page size", func(t *testing.T) { + listEmoji, _, err := client.GetEmojiList(context.Background(), 0, 1) + require.NoError(t, err) - listEmoji, _, err = client.GetEmojiList(context.Background(), 0, 1) - require.NoError(t, err) + require.Len(t, listEmoji, 1, "should only return 1") + }) - require.Len(t, listEmoji, 1, "should only return 1") + t.Run("should return a sorted emoji list", func(t *testing.T) { + listEmoji, _, err := client.GetSortedEmojiList(context.Background(), 0, 100, model.EmojiSortByName) + require.NoError(t, err) - listEmoji, _, err = client.GetSortedEmojiList(context.Background(), 0, 100, model.EmojiSortByName) - require.NoError(t, err) - - require.Greater(t, len(listEmoji), 0, "should return more than 0") + require.Greater(t, len(listEmoji), 0, "should return more than 0") + }) } func TestGetEmojisByNames(t *testing.T) { @@ -340,6 +364,14 @@ func TestGetEmojisByNames(t *testing.T) { assert.Contains(t, emojiIds, emoji2.Id) }) + t.Run("should return an empty array when no emojis are found", func(t *testing.T) { + emojis, _, err := client.GetEmojisByNames(context.Background(), []string{model.NewId(), model.NewId()}) + + require.NoError(t, err) + assert.NotNil(t, emojis) + assert.Equal(t, []*model.Emoji{}, emojis) + }) + t.Run("should return an error when too many emojis are requested", func(t *testing.T) { names := make([]string, GetEmojisByNamesMax+1) for i := 0; i < len(names); i++ { @@ -702,59 +734,79 @@ func TestSearchEmoji(t *testing.T) { emojis[idx] = newEmoji } - search := &model.EmojiSearch{Term: searchTerm1} - remojis, resp, err := client.SearchEmoji(context.Background(), search) - require.NoError(t, err) - CheckOKStatus(t, resp) + t.Run("should return emojis based on the query", func(t *testing.T) { + search := &model.EmojiSearch{Term: searchTerm1} + remojis, resp, err := client.SearchEmoji(context.Background(), search) + require.NoError(t, err) + CheckOKStatus(t, resp) - found := false - for _, e := range remojis { - if e.Name == emojis[0].Name { - found = true + found := false + for _, e := range remojis { + if e.Name == emojis[0].Name { + found = true + } } - } - assert.True(t, found) + assert.True(t, found) - search.Term = searchTerm2 - search.PrefixOnly = true - remojis, resp, err = client.SearchEmoji(context.Background(), search) - require.NoError(t, err) - CheckOKStatus(t, resp) + search.Term = searchTerm2 + search.PrefixOnly = true + remojis, resp, err = client.SearchEmoji(context.Background(), search) + require.NoError(t, err) + CheckOKStatus(t, resp) - found = false - for _, e := range remojis { - if e.Name == emojis[1].Name { - found = true + found = false + for _, e := range remojis { + if e.Name == emojis[1].Name { + found = true + } } - } - assert.False(t, found) + assert.False(t, found) - search.PrefixOnly = false - remojis, resp, err = client.SearchEmoji(context.Background(), search) - require.NoError(t, err) - CheckOKStatus(t, resp) + search.PrefixOnly = false + remojis, resp, err = client.SearchEmoji(context.Background(), search) + require.NoError(t, err) + CheckOKStatus(t, resp) - found = false - for _, e := range remojis { - if e.Name == emojis[1].Name { - found = true + found = false + for _, e := range remojis { + if e.Name == emojis[1].Name { + found = true + } } - } - assert.True(t, found) + assert.True(t, found) + }) - search.Term = "" - _, resp, err = client.SearchEmoji(context.Background(), search) - require.Error(t, err) - CheckBadRequestStatus(t, resp) + t.Run("should return an empty array when no emojis match the query", func(t *testing.T) { + search := &model.EmojiSearch{Term: model.NewId()} - _, err = client.Logout(context.Background()) - require.NoError(t, err) - _, resp, err = client.SearchEmoji(context.Background(), search) - require.Error(t, err) - CheckUnauthorizedStatus(t, resp) + remojis, _, err := client.SearchEmoji(context.Background(), search) + require.NoError(t, err) + + require.NotEqual(t, nil, remojis) + require.Equal(t, []*model.Emoji{}, remojis) + }) + + t.Run("should return a 400 error when an empty term is passed", func(t *testing.T) { + search := &model.EmojiSearch{Term: ""} + + _, resp, err := client.SearchEmoji(context.Background(), search) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + + t.Run("should return a 401 error when logged out", func(t *testing.T) { + search := &model.EmojiSearch{Term: searchTerm1} + + _, err := client.Logout(context.Background()) + require.NoError(t, err) + + _, resp, err := client.SearchEmoji(context.Background(), search) + require.Error(t, err) + CheckUnauthorizedStatus(t, resp) + }) } func TestAutocompleteEmoji(t *testing.T) { @@ -783,32 +835,48 @@ func TestAutocompleteEmoji(t *testing.T) { emojis[idx] = newEmoji } - remojis, resp, err := client.AutocompleteEmoji(context.Background(), searchTerm1, "") - require.NoErrorf(t, err, "AutocompleteEmoji failed with search term: %s", searchTerm1) - CheckOKStatus(t, resp) + t.Run("should return autocompleted emojis based on the search term", func(t *testing.T) { + remojis, resp, err := client.AutocompleteEmoji(context.Background(), searchTerm1, "") + require.NoErrorf(t, err, "AutocompleteEmoji failed with search term: %s", searchTerm1) + CheckOKStatus(t, resp) - found1 := false - found2 := false - for _, e := range remojis { - if e.Name == emojis[0].Name { - found1 = true + found1 := false + found2 := false + for _, e := range remojis { + if e.Name == emojis[0].Name { + found1 = true + } + + if e.Name == emojis[1].Name { + found2 = true + } } - if e.Name == emojis[1].Name { - found2 = true - } - } + assert.True(t, found1) + assert.False(t, found2) + }) - assert.True(t, found1) - assert.False(t, found2) + t.Run("should return an empty array when no emojis match the search term", func(t *testing.T) { + remojis, resp, err := client.AutocompleteEmoji(context.Background(), model.NewId(), "") + require.NoErrorf(t, err, "AutocompleteEmoji failed with search term: %s", searchTerm1) + CheckOKStatus(t, resp) - _, resp, err = client.AutocompleteEmoji(context.Background(), "", "") - require.Error(t, err) - CheckBadRequestStatus(t, resp) + require.NotEqual(t, nil, remojis) + require.Equal(t, []*model.Emoji{}, remojis) + }) - _, err = client.Logout(context.Background()) - require.NoError(t, err) - _, resp, err = client.AutocompleteEmoji(context.Background(), searchTerm1, "") - require.Error(t, err) - CheckUnauthorizedStatus(t, resp) + t.Run("should return a 400 error when an empty term is passed", func(t *testing.T) { + _, resp, err := client.AutocompleteEmoji(context.Background(), "", "") + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + + t.Run("should return a 401 error when logged out", func(t *testing.T) { + _, err := client.Logout(context.Background()) + require.NoError(t, err) + + _, resp, err := client.AutocompleteEmoji(context.Background(), searchTerm1, "") + require.Error(t, err) + CheckUnauthorizedStatus(t, resp) + }) } diff --git a/server/channels/api4/job.go b/server/channels/api4/job.go index 815e063f3b6..03afdb2071f 100644 --- a/server/channels/api4/job.go +++ b/server/channels/api4/job.go @@ -122,7 +122,7 @@ func downloadJob(c *Context, w http.ResponseWriter, r *http.Request) { if !filepath.IsLocal(cleanedExportDir) { c.Err = model.NewAppError("unableToDownloadJob", "api.job.unable_to_download_job", nil, "job.Data did not include export_dir, export_dir was malformed, or jobId.zip wasn't found", - http.StatusNotFound).Wrap(err) + http.StatusNotFound) return } diff --git a/server/channels/api4/job_test.go b/server/channels/api4/job_test.go index 8142ff48cb4..394f3260808 100644 --- a/server/channels/api4/job_test.go +++ b/server/channels/api4/job_test.go @@ -345,6 +345,30 @@ func TestDownloadJob(t *testing.T) { _, resp, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id) require.Error(t, err) CheckBadRequestStatus(t, resp) + + // Test the case where export_dir is not valid + jobName = model.NewId() + job = &model.Job{ + Id: jobName, + Type: model.JobTypeMessageExport, + Data: map[string]string{ + "export_type": "csv", + "is_downloadable": "true", + "export_dir": "/bad/absolute/path", + }, + Status: model.JobStatusSuccess, + } + _, err = th.App.Srv().Store().Job().Save(job) + require.NoError(t, err) + defer func() { + _, delErr := th.App.Srv().Store().Job().Delete(job.Id) + require.NoError(t, delErr, "Failed to delete job %s", job.Id) + }() + + _, resp, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id) + require.Error(t, err) + require.EqualError(t, err, "Unable to download this job") + CheckNotFoundStatus(t, resp) } func TestCancelJob(t *testing.T) { diff --git a/server/channels/api4/user_test.go b/server/channels/api4/user_test.go index ece4cf00399..4a4d4589316 100644 --- a/server/channels/api4/user_test.go +++ b/server/channels/api4/user_test.go @@ -4237,6 +4237,14 @@ func TestSetDefaultProfileImage(t *testing.T) { _, err = th.SystemAdminClient.SetDefaultProfileImage(context.Background(), user.Id) require.NoError(t, err) + // Check that a system admin can set the default profile image for another system admin + anotherAdmin := th.CreateUser() + _, appErr := th.App.UpdateUserRoles(th.Context, anotherAdmin.Id, model.SystemAdminRoleId+" "+model.SystemUserRoleId, false) + require.Nil(t, appErr) + + _, err = th.SystemAdminClient.SetDefaultProfileImage(context.Background(), anotherAdmin.Id) + require.NoError(t, err) + ruser, appErr := th.App.GetUser(user.Id) require.Nil(t, appErr) assert.Less(t, ruser.LastPictureUpdate, iuser.LastPictureUpdate, "LastPictureUpdate should be updated to a lower negative number") diff --git a/server/channels/app/authorization.go b/server/channels/app/authorization.go index 62c29b7eca5..6e5d4eddee4 100644 --- a/server/channels/app/authorization.go +++ b/server/channels/app/authorization.go @@ -82,6 +82,19 @@ func (a *App) SessionHasPermissionToChannel(c request.CTX, session model.Session return false } + channel, appErr := a.GetChannel(c, channelID) + if appErr != nil && appErr.StatusCode == http.StatusNotFound { + return false + } + + if session.IsUnrestricted() || a.RolesGrantPermission(session.GetUserRoles(), model.PermissionManageSystem.Id) { + return true + } + + if a.isChannelArchivedAndHidden(channel) { + return false + } + ids, err := a.Srv().Store().Channel().GetAllChannelMembersForUser(c, session.UserId, true, true) var channelRoles []string if err == nil { @@ -93,15 +106,6 @@ func (a *App) SessionHasPermissionToChannel(c request.CTX, session model.Session } } - channel, appErr := a.GetChannel(c, channelID) - if appErr != nil && appErr.StatusCode == http.StatusNotFound { - return false - } - - if session.IsUnrestricted() { - return true - } - if appErr == nil && channel.TeamId != "" { return a.SessionHasPermissionToTeam(session, channel.TeamId, permission) } @@ -115,22 +119,32 @@ func (a *App) SessionHasPermissionToChannels(c request.CTX, session model.Sessio return true } + if session.IsUnrestricted() || a.RolesGrantPermission(session.GetUserRoles(), model.PermissionManageSystem.Id) { + return true + } + for _, channelID := range channelIDs { if channelID == "" { return false } - } - // if System Roles (ie. Admin, TeamAdmin) allow permissions - // if so, no reason to check team - if a.SessionHasPermissionTo(session, permission) { // make sure all channels exist, otherwise return false. for _, channelID := range channelIDs { - _, appErr := a.GetChannel(c, channelID) - if appErr != nil && appErr.StatusCode == http.StatusNotFound { + channel, appErr := a.GetChannel(c, channelID) + if appErr != nil { + return false + } + + // if any channel is archived and the user doesn't have permission to view archived channels, return false + if a.isChannelArchivedAndHidden(channel) { return false } } + } + + // if System Roles (i.e. Admin, TeamAdmin) allow permissions + // if so, no reason to check team + if a.SessionHasPermissionTo(session, permission) { return true } @@ -148,6 +162,7 @@ func (a *App) SessionHasPermissionToChannels(c request.CTX, session model.Sessio } return false } + return true } @@ -202,7 +217,7 @@ func (a *App) SessionHasPermissionToUser(session model.Session, userID string) b if userID == "" { return false } - if session.IsUnrestricted() { + if session.IsUnrestricted() || a.SessionHasPermissionTo(session, model.PermissionManageSystem) { return true } @@ -210,11 +225,20 @@ func (a *App) SessionHasPermissionToUser(session model.Session, userID string) b return true } - if a.SessionHasPermissionTo(session, model.PermissionEditOtherUsers) { - return true + if !a.SessionHasPermissionTo(session, model.PermissionEditOtherUsers) { + return false } - return false + user, err := a.GetUser(userID) + if err != nil { + return false + } + + if user.IsSystemAdmin() { + return false + } + + return true } func (a *App) SessionHasPermissionToUserOrBot(rctx request.CTX, session model.Session, userID string) bool { @@ -380,7 +404,7 @@ func (a *App) SessionHasPermissionToReadChannel(c request.CTX, session model.Ses } func (a *App) HasPermissionToReadChannel(c request.CTX, userID string, channel *model.Channel) bool { - if !*a.Config().TeamSettings.ExperimentalViewArchivedChannels && channel.DeleteAt != 0 { + if a.isChannelArchivedAndHidden(channel) { return false } if a.HasPermissionToChannel(c, userID, channel.Id, model.PermissionReadChannelContent) { @@ -395,7 +419,7 @@ func (a *App) HasPermissionToReadChannel(c request.CTX, userID string, channel * } func (a *App) HasPermissionToChannelMemberCount(c request.CTX, userID string, channel *model.Channel) bool { - if !*a.Config().TeamSettings.ExperimentalViewArchivedChannels && channel.DeleteAt != 0 { + if a.isChannelArchivedAndHidden(channel) { return false } if a.HasPermissionToChannel(c, userID, channel.Id, model.PermissionReadChannelContent) { @@ -408,3 +432,7 @@ func (a *App) HasPermissionToChannelMemberCount(c request.CTX, userID string, ch return false } + +func (a *App) isChannelArchivedAndHidden(channel *model.Channel) bool { + return !*a.Config().TeamSettings.ExperimentalViewArchivedChannels && channel.DeleteAt != 0 +} diff --git a/server/channels/app/authorization_test.go b/server/channels/app/authorization_test.go index 6ec559aab8a..9736e25c038 100644 --- a/server/channels/app/authorization_test.go +++ b/server/channels/app/authorization_test.go @@ -140,6 +140,24 @@ func TestSessionHasPermissionToChannel(t *testing.T) { assert.True(t, th.App.SessionHasPermissionToChannel(th.Context, session, th.BasicChannel.Id, model.PermissionAddReaction)) }) + t.Run("basic user cannot access archived channel if setting is off", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.TeamSettings.ExperimentalViewArchivedChannels = false + }) + err := th.App.DeleteChannel(th.Context, th.BasicChannel, th.SystemAdminUser.Id) + require.Nil(t, err) + assert.False(t, th.App.SessionHasPermissionToChannel(th.Context, session, th.BasicChannel.Id, model.PermissionReadChannel)) + }) + + t.Run("basic user can access archived channel if setting is on", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.TeamSettings.ExperimentalViewArchivedChannels = true + }) + err := th.App.DeleteChannel(th.Context, th.BasicChannel, th.SystemAdminUser.Id) + require.Nil(t, err) + assert.True(t, th.App.SessionHasPermissionToChannel(th.Context, session, th.BasicChannel.Id, model.PermissionReadChannel)) + }) + t.Run("does not panic if fetching channel causes an error", func(t *testing.T) { // Regression test for MM-29812 // Mock the channel store so getting the channel returns with an error, as per the bug report. @@ -203,6 +221,78 @@ func TestSessionHasPermissionToChannels(t *testing.T) { assert.False(t, th.App.SessionHasPermissionToChannels(th.Context, session, allChannels, model.PermissionReadChannel)) }) + t.Run("basic user can access archived channel if setting is on", func(t *testing.T) { + session := model.Session{ + UserId: th.BasicUser.Id, + } + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.TeamSettings.ExperimentalViewArchivedChannels = true + }) + + newChannel := th.CreateChannel(th.Context, th.BasicTeam) + _, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser, newChannel, false) + assert.Nil(t, appErr) + + err := th.App.DeleteChannel(th.Context, newChannel, th.SystemAdminUser.Id) + require.Nil(t, err) + assert.True(t, th.App.SessionHasPermissionToChannels(th.Context, session, []string{newChannel.Id}, model.PermissionReadChannel)) + }) + + t.Run("basic user cannot access archived channel if setting is off", func(t *testing.T) { + session := model.Session{ + UserId: th.BasicUser.Id, + } + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.TeamSettings.ExperimentalViewArchivedChannels = false + }) + + newChannel := th.CreateChannel(th.Context, th.BasicTeam) + _, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser, newChannel, false) + assert.Nil(t, appErr) + + err := th.App.DeleteChannel(th.Context, newChannel, th.SystemAdminUser.Id) + require.Nil(t, err) + assert.False(t, th.App.SessionHasPermissionToChannels(th.Context, session, []string{newChannel.Id}, model.PermissionReadChannel)) + }) + + t.Run("basic user cannot access mixed archived and non-archived channels if setting is off", func(t *testing.T) { + session := model.Session{ + UserId: th.BasicUser.Id, + } + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.TeamSettings.ExperimentalViewArchivedChannels = false + }) + + archivedChannel := th.CreateChannel(th.Context, th.BasicTeam) + _, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser, archivedChannel, false) + assert.Nil(t, appErr) + + err := th.App.DeleteChannel(th.Context, archivedChannel, th.SystemAdminUser.Id) + require.Nil(t, err) + + mixedChannels := []string{th.BasicChannel.Id, archivedChannel.Id} + assert.False(t, th.App.SessionHasPermissionToChannels(th.Context, session, mixedChannels, model.PermissionReadChannel)) + }) + + t.Run("basic user can access mixed archived and non-archived channels if setting is on", func(t *testing.T) { + session := model.Session{ + UserId: th.BasicUser.Id, + } + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.TeamSettings.ExperimentalViewArchivedChannels = true + }) + + archivedChannel := th.CreateChannel(th.Context, th.BasicTeam) + _, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser, archivedChannel, false) + assert.Nil(t, appErr) + + err := th.App.DeleteChannel(th.Context, archivedChannel, th.SystemAdminUser.Id) + require.Nil(t, err) + + mixedChannels := []string{th.BasicChannel.Id, archivedChannel.Id} + assert.True(t, th.App.SessionHasPermissionToChannels(th.Context, session, mixedChannels, model.PermissionReadChannel)) + }) + t.Run("System Admins can access basic channels", func(t *testing.T) { session := model.Session{ UserId: th.SystemAdminUser.Id, @@ -382,6 +472,7 @@ func TestSessionHasPermissionToUser(t *testing.T) { th.AddPermissionToRole(model.PermissionEditOtherUsers.Id, model.SystemUserManagerRoleId) assert.True(t, th.App.SessionHasPermissionToUser(session, th.BasicUser2.Id)) + assert.False(t, th.App.SessionHasPermissionToUser(session, th.SystemAdminUser.Id)) th.RemovePermissionFromRole(model.PermissionEditOtherUsers.Id, model.SystemUserManagerRoleId) bot, err := th.App.CreateBot(th.Context, &model.Bot{ diff --git a/server/channels/app/busy_test.go b/server/channels/app/busy_test.go index 71c7d2a4d25..44187513ede 100644 --- a/server/channels/app/busy_test.go +++ b/server/channels/app/busy_test.go @@ -16,6 +16,7 @@ import ( ) func TestBusySet(t *testing.T) { + t.Skip("https://mattermost.atlassian.net/browse/MM-63300") cluster := &ClusterMock{Busy: &Busy{}, t: t} busy := NewBusy(cluster) diff --git a/server/channels/app/custom_profile_attributes.go b/server/channels/app/custom_profile_attributes.go index 6649148362b..00e1809ef95 100644 --- a/server/channels/app/custom_profile_attributes.go +++ b/server/channels/app/custom_profile_attributes.go @@ -4,6 +4,7 @@ package app import ( + "database/sql" "encoding/json" "net/http" "sort" @@ -41,13 +42,14 @@ func (a *App) GetCPAField(fieldID string) (*model.PropertyField, *model.AppError return nil, model.NewAppError("GetCPAField", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - field, err := a.Srv().propertyService.GetPropertyField(fieldID) + field, err := a.Srv().propertyService.GetPropertyField(groupID, fieldID) if err != nil { - return nil, model.NewAppError("GetCPAField", "app.custom_profile_attributes.get_property_field.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - - if field.GroupID != groupID { - return nil, model.NewAppError("GetCPAField", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound) + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, model.NewAppError("GetCPAField", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + default: + return nil, model.NewAppError("GetCPAField", "app.custom_profile_attributes.get_property_field.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } } return field, nil @@ -64,7 +66,7 @@ func (a *App) ListCPAFields() ([]*model.PropertyField, *model.AppError) { PerPage: CustomProfileAttributesFieldLimit, } - fields, err := a.Srv().propertyService.SearchPropertyFields(opts) + fields, err := a.Srv().propertyService.SearchPropertyFields(groupID, "", opts) if err != nil { return nil, model.NewAppError("GetCPAFields", "app.custom_profile_attributes.search_property_fields.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -159,13 +161,13 @@ func (a *App) DeleteCPAField(id string) *model.AppError { return model.NewAppError("DeleteCPAField", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - existingField, err := a.Srv().propertyService.GetPropertyField(id) - if err != nil { - return model.NewAppError("DeleteCPAField", "app.custom_profile_attributes.get_property_field.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - - if existingField.GroupID != groupID { - return model.NewAppError("DeleteCPAField", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound) + if _, err := a.Srv().propertyService.GetPropertyField(groupID, id); err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return model.NewAppError("DeleteCPAField", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound) + default: + return model.NewAppError("DeleteCPAField", "app.custom_profile_attributes.get_property_field.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } } if err := a.Srv().propertyService.DeletePropertyField(id); err != nil { @@ -191,10 +193,8 @@ func (a *App) ListCPAValues(userID string) ([]*model.PropertyValue, *model.AppEr return nil, model.NewAppError("GetCPAFields", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - values, err := a.Srv().propertyService.SearchPropertyValues(model.PropertyValueSearchOpts{ - GroupID: groupID, - TargetID: userID, - PerPage: CustomProfileAttributesFieldLimit, + values, err := a.Srv().propertyService.SearchPropertyValues(groupID, userID, model.PropertyValueSearchOpts{ + PerPage: CustomProfileAttributesFieldLimit, }) if err != nil { return nil, model.NewAppError("ListCPAValues", "app.custom_profile_attributes.list_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(err) @@ -209,15 +209,11 @@ func (a *App) GetCPAValue(valueID string) (*model.PropertyValue, *model.AppError return nil, model.NewAppError("GetCPAValue", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - value, err := a.Srv().propertyService.GetPropertyValue(valueID) + value, err := a.Srv().propertyService.GetPropertyValue(groupID, valueID) if err != nil { return nil, model.NewAppError("GetCPAValue", "app.custom_profile_attributes.get_property_field.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - if value.GroupID != groupID { - return nil, model.NewAppError("GetCPAValue", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound) - } - return value, nil } diff --git a/server/channels/app/custom_profile_attributes_test.go b/server/channels/app/custom_profile_attributes_test.go index 038389434a4..e8fc6474afa 100644 --- a/server/channels/app/custom_profile_attributes_test.go +++ b/server/channels/app/custom_profile_attributes_test.go @@ -25,9 +25,9 @@ func TestGetCPAField(t *testing.T) { require.NoError(t, cErr) t.Run("should fail when getting a non-existent field", func(t *testing.T) { - field, err := th.App.GetCPAField(model.NewId()) - require.NotNil(t, err) - require.Equal(t, "app.custom_profile_attributes.get_property_field.app_error", err.Id) + field, appErr := th.App.GetCPAField(model.NewId()) + require.NotNil(t, appErr) + require.Equal(t, "app.custom_profile_attributes.property_field_not_found.app_error", appErr.Id) require.Empty(t, field) }) @@ -157,7 +157,7 @@ func TestCreateCPAField(t *testing.T) { require.Equal(t, cpaGroupID, createdField.GroupID) require.Equal(t, model.StringInterface{model.CustomProfileAttributesPropertyAttrsVisibility: model.CustomProfileAttributesVisibilityHidden}, createdField.Attrs) - fetchedField, gErr := th.App.Srv().propertyService.GetPropertyField(createdField.ID) + fetchedField, gErr := th.App.Srv().propertyService.GetPropertyField("", createdField.ID) require.NoError(t, gErr) require.Equal(t, field.Name, fetchedField.Name) require.NotZero(t, fetchedField.CreateAt) @@ -392,7 +392,7 @@ func TestDeleteCPAField(t *testing.T) { t.Run("should fail if the field doesn't exist", func(t *testing.T) { err := th.App.DeleteCPAField(model.NewId()) require.NotNil(t, err) - require.Equal(t, "app.custom_profile_attributes.get_property_field.app_error", err.Id) + require.Equal(t, "app.custom_profile_attributes.property_field_not_found.app_error", err.Id) }) t.Run("should not allow to delete a field outside of CPA", func(t *testing.T) { @@ -412,7 +412,7 @@ func TestDeleteCPAField(t *testing.T) { t.Run("should correctly delete the field", func(t *testing.T) { // check that we have the associated values to the field prior deletion opts := model.PropertyValueSearchOpts{PerPage: 10, FieldID: createdField.ID} - values, err := th.App.Srv().propertyService.SearchPropertyValues(opts) + values, err := th.App.Srv().propertyService.SearchPropertyValues(cpaGroupID, "", opts) require.NoError(t, err) require.Len(t, values, 3) @@ -420,17 +420,17 @@ func TestDeleteCPAField(t *testing.T) { require.Nil(t, th.App.DeleteCPAField(createdField.ID)) // check that it is marked as deleted - fetchedField, err := th.App.Srv().propertyService.GetPropertyField(createdField.ID) + fetchedField, err := th.App.Srv().propertyService.GetPropertyField("", createdField.ID) require.NoError(t, err) require.NotZero(t, fetchedField.DeleteAt) // ensure that the associated fields have been marked as deleted too - values, err = th.App.Srv().propertyService.SearchPropertyValues(opts) + values, err = th.App.Srv().propertyService.SearchPropertyValues(cpaGroupID, "", opts) require.NoError(t, err) require.Len(t, values, 0) opts.IncludeDeleted = true - values, err = th.App.Srv().propertyService.SearchPropertyValues(opts) + values, err = th.App.Srv().propertyService.SearchPropertyValues(cpaGroupID, "", opts) require.NoError(t, err) require.Len(t, values, 3) for _, value := range values { diff --git a/server/channels/app/platform/status.go b/server/channels/app/platform/status.go index 5aee2f3abba..d8bf6609e39 100644 --- a/server/channels/app/platform/status.go +++ b/server/channels/app/platform/status.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "net/http" + "time" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" @@ -417,7 +418,7 @@ func (ps *PlatformService) SetStatusDoNotDisturbTimed(userID string, endtime int status.Status = model.StatusDnd status.Manual = true - status.DNDEndTime = endtime + status.DNDEndTime = truncateDNDEndTime(endtime) ps.SaveAndBroadcastStatus(status) if ps.sharedChannelService != nil { @@ -425,6 +426,19 @@ func (ps *PlatformService) SetStatusDoNotDisturbTimed(userID string, endtime int } } +// truncateDNDEndTime takes a user-provided timestamp (in seconds) for when their DND expiry should end and truncates +// it to line up with the DND expiry job so that the user's DND time doesn't expire late by an interval. The job to +// expire statuses runs every minute currently, so this trims the seconds and milliseconds off the given timestamp. +// +// This will result in statuses expiring slightly earlier than specified in the UI, but the status will expire at +// the correct time on the wall clock. For example, if the time is currently 13:04:29 and the user sets the expiry to +// 5 minutes, truncating will make the status will expire at 13:09:00 instead of at 13:10:00. +// +// Note that the timestamps used by this are in seconds, not milliseconds. This matches UserStatus.DNDEndTime. +func truncateDNDEndTime(endtime int64) int64 { + return time.Unix(endtime, 0).Truncate(model.DNDExpiryInterval).Unix() +} + func (ps *PlatformService) SetStatusDoNotDisturb(userID string) { if !*ps.Config().ServiceSettings.EnableUserStatuses { return diff --git a/server/channels/app/platform/status_test.go b/server/channels/app/platform/status_test.go index bbb4d6d57a8..7f791d555a5 100644 --- a/server/channels/app/platform/status_test.go +++ b/server/channels/app/platform/status_test.go @@ -6,6 +6,7 @@ package platform import ( "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" @@ -37,3 +38,17 @@ func TestSaveStatus(t *testing.T) { }) } } + +func TestTruncateDNDEndTime(t *testing.T) { + // 2025-Jan-20 at 17:13:32 GMT becomes 17:13:00 + assert.Equal(t, int64(1737393180), truncateDNDEndTime(1737393212)) + + // 2025-Jan-20 at 17:13:00 GMT remains unchanged + assert.Equal(t, int64(1737393180), truncateDNDEndTime(1737393180)) + + // 2025-Jan-20 at 00:00:10 GMT becomes 00:00:00 + assert.Equal(t, int64(1737331200), truncateDNDEndTime(1737331210)) + + // 2025-Jan-20 at 00:00:10 GMT remains unchanged + assert.Equal(t, int64(1737331200), truncateDNDEndTime(1737331200)) +} diff --git a/server/channels/app/platform/web_hub.go b/server/channels/app/platform/web_hub.go index 948272f42f4..5eab747a1ce 100644 --- a/server/channels/app/platform/web_hub.go +++ b/server/channels/app/platform/web_hub.go @@ -311,7 +311,7 @@ func (ps *PlatformService) CheckWebConn(userID, connectionID string, seqNum int6 connRes.ActiveQueue = aq connRes.ReuseCount = queues.ReuseCount - // parse the dq, wc.addToDeadQ() + // parse the deadq if queues.DeadQ != nil { dq, dqPtr, err := ps.UnmarshalDQ(queues.DeadQ) if err != nil { @@ -322,7 +322,9 @@ func (ps *PlatformService) CheckWebConn(userID, connectionID string, seqNum int6 return nil } - if dqPtr > 0 { + // We check if atleast one item has been written. + // Length of dq is always guaranteed to be deadQueueSize. + if dq[0] != nil { connRes.DeadQueue = dq connRes.DeadQueuePointer = dqPtr } diff --git a/server/channels/app/platform/websocket_reliable.go b/server/channels/app/platform/websocket_reliable.go index 09a269d85ca..0fe86531378 100644 --- a/server/channels/app/platform/websocket_reliable.go +++ b/server/channels/app/platform/websocket_reliable.go @@ -156,7 +156,7 @@ func (ps *PlatformService) UnmarshalDQ(buf []json.RawMessage) ([]*model.WebSocke // Same as active queue, this can never be out of bounds because all dead queues // are of deadQueueSize. dq[dqPtr] = item - dqPtr++ + dqPtr = (dqPtr + 1) % deadQueueSize } return dq, dqPtr, nil } diff --git a/server/channels/app/platform/websocket_reliable_test.go b/server/channels/app/platform/websocket_reliable_test.go index c9cbe333b9c..e9ced119e5d 100644 --- a/server/channels/app/platform/websocket_reliable_test.go +++ b/server/channels/app/platform/websocket_reliable_test.go @@ -65,3 +65,54 @@ func TestMarshalDQ(t *testing.T) { assert.Equal(t, 3, dqPtr) assert.Equal(t, events[:3], gotEvents[:3]) } + +func TestUnmarshalDQFullBuffer(t *testing.T) { + ps := PlatformService{} + + t.Run("dq full", func(t *testing.T) { + // Create exactly deadQueueSize events + events := make([]*model.WebSocketEvent, deadQueueSize) + for i := 0; i < deadQueueSize; i++ { + events[i] = model.NewWebSocketEvent(model.WebsocketEventPosted, "t1", "c1", "u1", nil, "").SetSequence(int64(i)) + } + + // Set up a scenario where the buffer is already filled and has wrapped around + // Use index 0 and simulate that the dqPtr has wrapped around to 0 again + got, err := ps.marshalDQ(events, 0, 0) + require.NoError(t, err) + require.Len(t, got, deadQueueSize) + + // Unmarshal the full buffer back + gotEvents, dqPtr, err := ps.UnmarshalDQ(got) + require.NoError(t, err) + + // Check that dqPtr wraps around to 0, not deadQueueSize + assert.Equal(t, 0, dqPtr, "dqPtr should be 0 for a full buffer (deadQueueSize % deadQueueSize = 0)") + + // Verify all events were unmarshaled correctly + assert.Equal(t, events, gotEvents) + }) + + t.Run("dq rollover", func(t *testing.T) { + // Alternative test: Create a simulation of the circular buffer behavior + // This test fills up to the max and ensures wraparound works correctly + events := make([]*model.WebSocketEvent, deadQueueSize) + for i := 0; i < deadQueueSize; i++ { + // Create events with sequence numbers that show wraparound + // Last event will have highest sequence to demonstrate the break condition + // Seq nos: 100 - 228 + events[i] = model.NewWebSocketEvent(model.WebsocketEventPosted, "t1", "c1", "u1", nil, "").SetSequence(int64(i + 100)) + } + + // Marshal only the last entry wrapping to the first to test wraparound detection + got2, err := ps.marshalDQ(events, deadQueueSize-1, 0) + require.NoError(t, err) + require.Len(t, got2, 1) // Just the last element + + // Unmarshal this single element + gotEvents2, dqPtr2, err := ps.UnmarshalDQ(got2) + require.NoError(t, err) + assert.Equal(t, 1, dqPtr2, "dqPtr should be 1 for a 1-element buffer (1 % deadQueueSize = 1)") + assert.Equal(t, events[deadQueueSize-1], gotEvents2[0]) + }) +} diff --git a/server/channels/app/plugin_api_test.go b/server/channels/app/plugin_api_test.go index 8ed8034bf28..e259ff50251 100644 --- a/server/channels/app/plugin_api_test.go +++ b/server/channels/app/plugin_api_test.go @@ -1614,7 +1614,8 @@ func TestInterpluginPluginHTTP(t *testing.T) { defer th.TearDown() setupMultiPluginAPITest(t, - []string{` + []string{ + ` package main import ( @@ -1759,8 +1760,7 @@ func TestAPIMetrics(t *testing.T) { pluginID := model.NewId() backend := filepath.Join(pluginDir, pluginID, "backend.exe") - code := - ` + code := ` package main import ( @@ -1896,6 +1896,23 @@ func TestPluginHTTPConnHijack(t *testing.T) { require.Equal(t, "OK", string(body)) } +func makePluginHTTPRequest(t *testing.T, pluginID string, port int, token string) string { + t.Helper() + client := &http.Client{} + reqURL := fmt.Sprintf("http://localhost:%d/plugins/%s", port, pluginID) + req, err := http.NewRequest("GET", reqURL, nil) + require.NoError(t, err) + req.Header.Set(model.HeaderAuth, model.HeaderToken+" "+token) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return string(body) +} + func TestPluginMFAEnforcement(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() @@ -1942,22 +1959,6 @@ func TestPluginMFAEnforcement(t *testing.T) { }) require.Nil(t, appErr) - client := &http.Client{} - makeRequest := func() string { - reqURL := fmt.Sprintf("http://localhost:%d/plugins/%s", th.Server.ListenAddr.Port, pluginID) - req, err := http.NewRequest("GET", reqURL, nil) - require.NoError(t, err) - req.Header.Set(model.HeaderAuth, model.HeaderToken+" "+session.Token) - - resp, err := client.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - return string(body) - } - t.Run("MFA not enforced", func(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableMultifactorAuthentication = true @@ -1965,7 +1966,7 @@ func TestPluginMFAEnforcement(t *testing.T) { }) // Should return user ID since MFA is not enforced - userID := makeRequest() + userID := makePluginHTTPRequest(t, pluginID, th.Server.ListenAddr.Port, session.Token) assert.Equal(t, user.Id, userID) }) @@ -1976,7 +1977,7 @@ func TestPluginMFAEnforcement(t *testing.T) { }) // Should return empty string since MFA is enforced but not active - userID := makeRequest() + userID := makePluginHTTPRequest(t, pluginID, th.Server.ListenAddr.Port, session.Token) assert.Empty(t, userID) }) } @@ -2806,3 +2807,42 @@ func TestPluginPatchChannelMembersNotifications(t *testing.T) { assert.Equal(t, "", updated.NotifyProps["test_field"]) }) } + +func TestPluginServeHTTPCompatibility(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + pluginCode := ` + package main + + import ( + "net/http" + "github.com/mattermost/mattermost/server/public/plugin" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func (p *MyPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { + w.Write([]byte("plugin response")) + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + ` + + for _, goVersion := range strings.Fields(os.Getenv("GO_COMPATIBILITY_TEST_VERSIONS")) { + t.Run(goVersion, func(t *testing.T) { + tearDown, ids, errs := SetAppEnvironmentWithPluginsGoVersion(t, []string{pluginCode}, th.App, th.NewPluginAPI, goVersion) + defer tearDown() + require.NoError(t, errs[0]) + require.Len(t, ids, 1) + pluginID := ids[0] + + res := makePluginHTTPRequest(t, pluginID, th.Server.ListenAddr.Port, "") + assert.Equal(t, "plugin response", res) + }) + } +} diff --git a/server/channels/app/plugin_hooks_test.go b/server/channels/app/plugin_hooks_test.go index 4d4e92b65f6..65d4874f9e6 100644 --- a/server/channels/app/plugin_hooks_test.go +++ b/server/channels/app/plugin_hooks_test.go @@ -31,6 +31,14 @@ import ( ) func SetAppEnvironmentWithPlugins(t *testing.T, pluginCode []string, app *App, apiFunc func(*model.Manifest) plugin.API) (func(), []string, []error) { + return setAppEnvironmentWithPlugins(t, pluginCode, app, apiFunc, "") +} + +func SetAppEnvironmentWithPluginsGoVersion(t *testing.T, pluginCode []string, app *App, apiFunc func(*model.Manifest) plugin.API, goVersion string) (func(), []string, []error) { + return setAppEnvironmentWithPlugins(t, pluginCode, app, apiFunc, goVersion) +} + +func setAppEnvironmentWithPlugins(t *testing.T, pluginCode []string, app *App, apiFunc func(*model.Manifest) plugin.API, goVersion string) (func(), []string, []error) { pluginDir, err := os.MkdirTemp("", "") require.NoError(t, err) webappPluginDir, err := os.MkdirTemp("", "") @@ -45,7 +53,7 @@ func SetAppEnvironmentWithPlugins(t *testing.T, pluginCode []string, app *App, a for _, code := range pluginCode { pluginID := model.NewId() backend := filepath.Join(pluginDir, pluginID, "backend.exe") - utils.CompileGo(t, code, backend) + utils.CompileGoVersion(t, goVersion, code, backend) err = os.WriteFile(filepath.Join(pluginDir, pluginID, "plugin.json"), []byte(`{"id": "`+pluginID+`", "server": {"executable": "backend.exe"}}`), 0600) require.NoError(t, err) @@ -327,7 +335,8 @@ func TestHookMessageHasBeenPosted(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) + `, + }, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) defer tearDown() post := &model.Post{ @@ -366,7 +375,8 @@ func TestHookMessageWillBeUpdated(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, th.NewPluginAPI) + `, + }, th.App, th.NewPluginAPI) defer tearDown() post := &model.Post{ @@ -414,7 +424,8 @@ func TestHookMessageHasBeenUpdated(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) + `, + }, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) defer tearDown() post := &model.Post{ @@ -460,7 +471,8 @@ func TestHookMessageHasBeenDeleted(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) + `, + }, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) defer tearDown() post := &model.Post{ @@ -726,7 +738,8 @@ func TestUserWillLogIn_Blocked(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, th.NewPluginAPI) + `, + }, th.App, th.NewPluginAPI) defer tearDown() r := &http.Request{} @@ -766,7 +779,8 @@ func TestUserWillLogInIn_Passed(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, th.NewPluginAPI) + `, + }, th.App, th.NewPluginAPI) defer tearDown() r := &http.Request{} @@ -808,7 +822,8 @@ func TestUserHasLoggedIn(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, th.NewPluginAPI) + `, + }, th.App, th.NewPluginAPI) defer tearDown() r := &http.Request{} @@ -850,7 +865,8 @@ func TestUserHasBeenDeactivated(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, th.NewPluginAPI) + `, + }, th.App, th.NewPluginAPI) defer tearDown() user := &model.User{ @@ -898,7 +914,8 @@ func TestUserHasBeenCreated(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, th.NewPluginAPI) + `, + }, th.App, th.NewPluginAPI) defer tearDown() user := &model.User{ @@ -943,7 +960,8 @@ func TestErrorString(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, th.NewPluginAPI) + `, + }, th.App, th.NewPluginAPI) defer tearDown() require.Len(t, activationErrors, 1) @@ -973,7 +991,8 @@ func TestErrorString(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, th.NewPluginAPI) + `, + }, th.App, th.NewPluginAPI) defer tearDown() require.Len(t, activationErrors, 1) @@ -1029,7 +1048,8 @@ func TestHookContext(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) + `, + }, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) defer tearDown() post := &model.Post{ @@ -1077,7 +1097,8 @@ func TestActiveHooks(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, th.NewPluginAPI) + `, + }, th.App, th.NewPluginAPI) defer tearDown() require.Len(t, pluginIDs, 1) @@ -1133,8 +1154,7 @@ func TestHookMetrics(t *testing.T) { pluginID := model.NewId() backend := filepath.Join(pluginDir, pluginID, "backend.exe") - code := - ` + code := ` package main import ( @@ -1241,7 +1261,8 @@ func TestHookReactionHasBeenAdded(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) + `, + }, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) defer tearDown() reaction := &model.Reaction{ @@ -1283,7 +1304,8 @@ func TestHookReactionHasBeenRemoved(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) + `, + }, th.App, func(*model.Manifest) plugin.API { return &mockAPI }) defer tearDown() reaction := &model.Reaction{ @@ -1326,7 +1348,8 @@ func TestHookRunDataRetention(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, th.NewPluginAPI) + `, + }, th.App, th.NewPluginAPI) defer tearDown() require.Len(t, pluginIDs, 1) @@ -1370,7 +1393,8 @@ func TestHookOnSendDailyTelemetry(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, th.NewPluginAPI) + `, + }, th.App, th.NewPluginAPI) defer tearDown() require.Len(t, pluginIDs, 1) @@ -1414,7 +1438,8 @@ func TestHookOnCloudLimitsUpdated(t *testing.T) { func main() { plugin.ClientMain(&MyPlugin{}) } - `}, th.App, th.NewPluginAPI) + `, + }, th.App, th.NewPluginAPI) defer tearDown() require.Len(t, pluginIDs, 1) diff --git a/server/channels/app/properties/property_field.go b/server/channels/app/properties/property_field.go index d3b59681903..322f53877b9 100644 --- a/server/channels/app/properties/property_field.go +++ b/server/channels/app/properties/property_field.go @@ -11,19 +11,24 @@ func (ps *PropertyService) CreatePropertyField(field *model.PropertyField) (*mod return ps.fieldStore.Create(field) } -func (ps *PropertyService) GetPropertyField(id string) (*model.PropertyField, error) { - return ps.fieldStore.Get(id) +func (ps *PropertyService) GetPropertyField(groupID, id string) (*model.PropertyField, error) { + return ps.fieldStore.Get(groupID, id) } -func (ps *PropertyService) GetPropertyFields(ids []string) ([]*model.PropertyField, error) { - return ps.fieldStore.GetMany(ids) +func (ps *PropertyService) GetPropertyFields(groupID string, ids []string) ([]*model.PropertyField, error) { + return ps.fieldStore.GetMany(groupID, ids) } func (ps *PropertyService) CountActivePropertyFieldsForGroup(groupID string) (int64, error) { return ps.fieldStore.CountForGroup(groupID, false) } -func (ps *PropertyService) SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) { +func (ps *PropertyService) SearchPropertyFields(groupID, targetID string, opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) { + // groupID and targetID are part of the search method signature to + // incentivize the use of the database indexes in searches + opts.GroupID = groupID + opts.TargetID = targetID + return ps.fieldStore.SearchPropertyFields(opts) } diff --git a/server/channels/app/properties/property_value.go b/server/channels/app/properties/property_value.go index dbb79afed9a..11b65743d0a 100644 --- a/server/channels/app/properties/property_value.go +++ b/server/channels/app/properties/property_value.go @@ -11,15 +11,19 @@ func (ps *PropertyService) CreatePropertyValue(value *model.PropertyValue) (*mod return ps.valueStore.Create(value) } -func (ps *PropertyService) GetPropertyValue(id string) (*model.PropertyValue, error) { - return ps.valueStore.Get(id) +func (ps *PropertyService) GetPropertyValue(groupID, id string) (*model.PropertyValue, error) { + return ps.valueStore.Get(groupID, id) } -func (ps *PropertyService) GetPropertyValues(ids []string) ([]*model.PropertyValue, error) { - return ps.valueStore.GetMany(ids) +func (ps *PropertyService) GetPropertyValues(groupID string, ids []string) ([]*model.PropertyValue, error) { + return ps.valueStore.GetMany(groupID, ids) } -func (ps *PropertyService) SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) { +func (ps *PropertyService) SearchPropertyValues(groupID, targetID string, opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) { + // groupID and targetID are part of the search method signature to + // incentivize the use of the database indexes in searches + opts.GroupID = groupID + opts.TargetID = targetID return ps.valueStore.SearchPropertyValues(opts) } diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 5a8b815279e..65184880726 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -1782,14 +1782,14 @@ func cancelTask(mut *sync.Mutex, taskPointer **model.ScheduledTask) { func runDNDStatusExpireJob(a *App) { if a.IsLeader() { withMut(&a.ch.dndTaskMut, func() { - a.ch.dndTask = model.CreateRecurringTaskFromNextIntervalTime("Unset DND Statuses", a.UpdateDNDStatusOfUsers, 5*time.Minute) + a.ch.dndTask = model.CreateRecurringTaskFromNextIntervalTime("Unset DND Statuses", a.UpdateDNDStatusOfUsers, model.DNDExpiryInterval) }) } a.ch.srv.AddClusterLeaderChangedListener(func() { mlog.Info("Cluster leader changed. Determining if unset DNS status task should be running", mlog.Bool("isLeader", a.IsLeader())) if a.IsLeader() { withMut(&a.ch.dndTaskMut, func() { - a.ch.dndTask = model.CreateRecurringTaskFromNextIntervalTime("Unset DND Statuses", a.UpdateDNDStatusOfUsers, 5*time.Minute) + a.ch.dndTask = model.CreateRecurringTaskFromNextIntervalTime("Unset DND Statuses", a.UpdateDNDStatusOfUsers, model.DNDExpiryInterval) }) } else { cancelTask(&a.ch.dndTaskMut, &a.ch.dndTask) diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 0fe7c3e269e..111509144ab 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -9012,11 +9012,11 @@ func (s *RetryLayerPropertyFieldStore) Delete(id string) error { } -func (s *RetryLayerPropertyFieldStore) Get(id string) (*model.PropertyField, error) { +func (s *RetryLayerPropertyFieldStore) Get(groupID string, id string) (*model.PropertyField, error) { tries := 0 for { - result, err := s.PropertyFieldStore.Get(id) + result, err := s.PropertyFieldStore.Get(groupID, id) if err == nil { return result, nil } @@ -9033,11 +9033,11 @@ func (s *RetryLayerPropertyFieldStore) Get(id string) (*model.PropertyField, err } -func (s *RetryLayerPropertyFieldStore) GetMany(ids []string) ([]*model.PropertyField, error) { +func (s *RetryLayerPropertyFieldStore) GetMany(groupID string, ids []string) ([]*model.PropertyField, error) { tries := 0 for { - result, err := s.PropertyFieldStore.GetMany(ids) + result, err := s.PropertyFieldStore.GetMany(groupID, ids) if err == nil { return result, nil } @@ -9201,11 +9201,11 @@ func (s *RetryLayerPropertyValueStore) DeleteForField(id string) error { } -func (s *RetryLayerPropertyValueStore) Get(id string) (*model.PropertyValue, error) { +func (s *RetryLayerPropertyValueStore) Get(groupID string, id string) (*model.PropertyValue, error) { tries := 0 for { - result, err := s.PropertyValueStore.Get(id) + result, err := s.PropertyValueStore.Get(groupID, id) if err == nil { return result, nil } @@ -9222,11 +9222,11 @@ func (s *RetryLayerPropertyValueStore) Get(id string) (*model.PropertyValue, err } -func (s *RetryLayerPropertyValueStore) GetMany(ids []string) ([]*model.PropertyValue, error) { +func (s *RetryLayerPropertyValueStore) GetMany(groupID string, ids []string) ([]*model.PropertyValue, error) { tries := 0 for { - result, err := s.PropertyValueStore.GetMany(ids) + result, err := s.PropertyValueStore.GetMany(groupID, ids) if err == nil { return result, nil } diff --git a/server/channels/store/sqlstore/emoji_store.go b/server/channels/store/sqlstore/emoji_store.go index 8c834291e86..5f9c59cf135 100644 --- a/server/channels/store/sqlstore/emoji_store.go +++ b/server/channels/store/sqlstore/emoji_store.go @@ -63,7 +63,7 @@ func (es SqlEmojiStore) GetByName(c request.CTX, name string, allowFromCache boo func (es SqlEmojiStore) GetMultipleByName(c request.CTX, names []string) ([]*model.Emoji, error) { query := es.emojiSelectQuery.Where(sq.Eq{"Name": names}) - var emojis []*model.Emoji + emojis := []*model.Emoji{} if err := es.DBXFromContext(c.Context()).SelectBuilder(&emojis, query); err != nil { return nil, errors.Wrapf(err, "error getting emojis by names %v", names) } @@ -72,7 +72,7 @@ func (es SqlEmojiStore) GetMultipleByName(c request.CTX, names []string) ([]*mod } func (es SqlEmojiStore) GetList(offset, limit int, sort string) ([]*model.Emoji, error) { - var emojis []*model.Emoji + emojis := []*model.Emoji{} query := es.emojiSelectQuery if sort == model.EmojiSortByName { @@ -106,7 +106,7 @@ func (es SqlEmojiStore) Delete(emoji *model.Emoji, time int64) error { } func (es SqlEmojiStore) Search(name string, prefixOnly bool, limit int) ([]*model.Emoji, error) { - var emojis []*model.Emoji + emojis := []*model.Emoji{} name = sanitizeSearchTerm(name, "\\") diff --git a/server/channels/store/sqlstore/property_field_store.go b/server/channels/store/sqlstore/property_field_store.go index d19737b1cfe..85f07c3ce64 100644 --- a/server/channels/store/sqlstore/property_field_store.go +++ b/server/channels/store/sqlstore/property_field_store.go @@ -52,9 +52,13 @@ func (s *SqlPropertyFieldStore) Create(field *model.PropertyField) (*model.Prope return field, nil } -func (s *SqlPropertyFieldStore) Get(id string) (*model.PropertyField, error) { +func (s *SqlPropertyFieldStore) Get(groupID, id string) (*model.PropertyField, error) { builder := s.tableSelectQuery.Where(sq.Eq{"id": id}) + if groupID != "" { + builder = builder.Where(sq.Eq{"GroupID": groupID}) + } + var field model.PropertyField if err := s.GetReplica().GetBuilder(&field, builder); err != nil { return nil, errors.Wrap(err, "property_field_get_select") @@ -63,9 +67,13 @@ func (s *SqlPropertyFieldStore) Get(id string) (*model.PropertyField, error) { return &field, nil } -func (s *SqlPropertyFieldStore) GetMany(ids []string) ([]*model.PropertyField, error) { +func (s *SqlPropertyFieldStore) GetMany(groupID string, ids []string) ([]*model.PropertyField, error) { builder := s.tableSelectQuery.Where(sq.Eq{"id": ids}) + if groupID != "" { + builder = builder.Where(sq.Eq{"GroupID": groupID}) + } + fields := []*model.PropertyField{} if err := s.GetReplica().SelectBuilder(&fields, builder); err != nil { return nil, errors.Wrap(err, "property_field_get_many_query") diff --git a/server/channels/store/sqlstore/property_value_store.go b/server/channels/store/sqlstore/property_value_store.go index d0c37ce501c..a06b2d3547e 100644 --- a/server/channels/store/sqlstore/property_value_store.go +++ b/server/channels/store/sqlstore/property_value_store.go @@ -56,9 +56,13 @@ func (s *SqlPropertyValueStore) Create(value *model.PropertyValue) (*model.Prope return value, nil } -func (s *SqlPropertyValueStore) Get(id string) (*model.PropertyValue, error) { +func (s *SqlPropertyValueStore) Get(groupID, id string) (*model.PropertyValue, error) { builder := s.tableSelectQuery.Where(sq.Eq{"id": id}) + if groupID != "" { + builder = builder.Where(sq.Eq{"GroupID": groupID}) + } + var value model.PropertyValue if err := s.GetReplica().GetBuilder(&value, builder); err != nil { return nil, errors.Wrap(err, "property_value_get_select") @@ -67,9 +71,13 @@ func (s *SqlPropertyValueStore) Get(id string) (*model.PropertyValue, error) { return &value, nil } -func (s *SqlPropertyValueStore) GetMany(ids []string) ([]*model.PropertyValue, error) { +func (s *SqlPropertyValueStore) GetMany(groupID string, ids []string) ([]*model.PropertyValue, error) { builder := s.tableSelectQuery.Where(sq.Eq{"id": ids}) + if groupID != "" { + builder = builder.Where(sq.Eq{"GroupID": groupID}) + } + var values []*model.PropertyValue if err := s.GetReplica().SelectBuilder(&values, builder); err != nil { return nil, errors.Wrap(err, "property_value_get_many_query") diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 8121c641382..2d929b65a05 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -1087,8 +1087,8 @@ type PropertyGroupStore interface { type PropertyFieldStore interface { Create(field *model.PropertyField) (*model.PropertyField, error) - Get(id string) (*model.PropertyField, error) - GetMany(ids []string) ([]*model.PropertyField, error) + Get(groupID, id string) (*model.PropertyField, error) + GetMany(groupID string, ids []string) ([]*model.PropertyField, error) CountForGroup(groupID string, includeDeleted bool) (int64, error) SearchPropertyFields(opts model.PropertyFieldSearchOpts) ([]*model.PropertyField, error) Update(fields []*model.PropertyField) ([]*model.PropertyField, error) @@ -1097,8 +1097,8 @@ type PropertyFieldStore interface { type PropertyValueStore interface { Create(value *model.PropertyValue) (*model.PropertyValue, error) - Get(id string) (*model.PropertyValue, error) - GetMany(ids []string) ([]*model.PropertyValue, error) + Get(groupID, id string) (*model.PropertyValue, error) + GetMany(groupID string, ids []string) ([]*model.PropertyValue, error) SearchPropertyValues(opts model.PropertyValueSearchOpts) ([]*model.PropertyValue, error) Update(values []*model.PropertyValue) ([]*model.PropertyValue, error) Upsert(values []*model.PropertyValue) ([]*model.PropertyValue, error) diff --git a/server/channels/store/storetest/mocks/PropertyFieldStore.go b/server/channels/store/storetest/mocks/PropertyFieldStore.go index a1baac5217e..d4139251ad6 100644 --- a/server/channels/store/storetest/mocks/PropertyFieldStore.go +++ b/server/channels/store/storetest/mocks/PropertyFieldStore.go @@ -90,9 +90,9 @@ func (_m *PropertyFieldStore) Delete(id string) error { return r0 } -// Get provides a mock function with given fields: id -func (_m *PropertyFieldStore) Get(id string) (*model.PropertyField, error) { - ret := _m.Called(id) +// Get provides a mock function with given fields: groupID, id +func (_m *PropertyFieldStore) Get(groupID string, id string) (*model.PropertyField, error) { + ret := _m.Called(groupID, id) if len(ret) == 0 { panic("no return value specified for Get") @@ -100,19 +100,19 @@ func (_m *PropertyFieldStore) Get(id string) (*model.PropertyField, error) { var r0 *model.PropertyField var r1 error - if rf, ok := ret.Get(0).(func(string) (*model.PropertyField, error)); ok { - return rf(id) + if rf, ok := ret.Get(0).(func(string, string) (*model.PropertyField, error)); ok { + return rf(groupID, id) } - if rf, ok := ret.Get(0).(func(string) *model.PropertyField); ok { - r0 = rf(id) + if rf, ok := ret.Get(0).(func(string, string) *model.PropertyField); ok { + r0 = rf(groupID, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.PropertyField) } } - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(id) + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(groupID, id) } else { r1 = ret.Error(1) } @@ -120,9 +120,9 @@ func (_m *PropertyFieldStore) Get(id string) (*model.PropertyField, error) { return r0, r1 } -// GetMany provides a mock function with given fields: ids -func (_m *PropertyFieldStore) GetMany(ids []string) ([]*model.PropertyField, error) { - ret := _m.Called(ids) +// GetMany provides a mock function with given fields: groupID, ids +func (_m *PropertyFieldStore) GetMany(groupID string, ids []string) ([]*model.PropertyField, error) { + ret := _m.Called(groupID, ids) if len(ret) == 0 { panic("no return value specified for GetMany") @@ -130,19 +130,19 @@ func (_m *PropertyFieldStore) GetMany(ids []string) ([]*model.PropertyField, err var r0 []*model.PropertyField var r1 error - if rf, ok := ret.Get(0).(func([]string) ([]*model.PropertyField, error)); ok { - return rf(ids) + if rf, ok := ret.Get(0).(func(string, []string) ([]*model.PropertyField, error)); ok { + return rf(groupID, ids) } - if rf, ok := ret.Get(0).(func([]string) []*model.PropertyField); ok { - r0 = rf(ids) + if rf, ok := ret.Get(0).(func(string, []string) []*model.PropertyField); ok { + r0 = rf(groupID, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.PropertyField) } } - if rf, ok := ret.Get(1).(func([]string) error); ok { - r1 = rf(ids) + if rf, ok := ret.Get(1).(func(string, []string) error); ok { + r1 = rf(groupID, ids) } else { r1 = ret.Error(1) } diff --git a/server/channels/store/storetest/mocks/PropertyValueStore.go b/server/channels/store/storetest/mocks/PropertyValueStore.go index 0218927b435..c2296d71838 100644 --- a/server/channels/store/storetest/mocks/PropertyValueStore.go +++ b/server/channels/store/storetest/mocks/PropertyValueStore.go @@ -80,9 +80,9 @@ func (_m *PropertyValueStore) DeleteForField(id string) error { return r0 } -// Get provides a mock function with given fields: id -func (_m *PropertyValueStore) Get(id string) (*model.PropertyValue, error) { - ret := _m.Called(id) +// Get provides a mock function with given fields: groupID, id +func (_m *PropertyValueStore) Get(groupID string, id string) (*model.PropertyValue, error) { + ret := _m.Called(groupID, id) if len(ret) == 0 { panic("no return value specified for Get") @@ -90,19 +90,19 @@ func (_m *PropertyValueStore) Get(id string) (*model.PropertyValue, error) { var r0 *model.PropertyValue var r1 error - if rf, ok := ret.Get(0).(func(string) (*model.PropertyValue, error)); ok { - return rf(id) + if rf, ok := ret.Get(0).(func(string, string) (*model.PropertyValue, error)); ok { + return rf(groupID, id) } - if rf, ok := ret.Get(0).(func(string) *model.PropertyValue); ok { - r0 = rf(id) + if rf, ok := ret.Get(0).(func(string, string) *model.PropertyValue); ok { + r0 = rf(groupID, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.PropertyValue) } } - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(id) + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(groupID, id) } else { r1 = ret.Error(1) } @@ -110,9 +110,9 @@ func (_m *PropertyValueStore) Get(id string) (*model.PropertyValue, error) { return r0, r1 } -// GetMany provides a mock function with given fields: ids -func (_m *PropertyValueStore) GetMany(ids []string) ([]*model.PropertyValue, error) { - ret := _m.Called(ids) +// GetMany provides a mock function with given fields: groupID, ids +func (_m *PropertyValueStore) GetMany(groupID string, ids []string) ([]*model.PropertyValue, error) { + ret := _m.Called(groupID, ids) if len(ret) == 0 { panic("no return value specified for GetMany") @@ -120,19 +120,19 @@ func (_m *PropertyValueStore) GetMany(ids []string) ([]*model.PropertyValue, err var r0 []*model.PropertyValue var r1 error - if rf, ok := ret.Get(0).(func([]string) ([]*model.PropertyValue, error)); ok { - return rf(ids) + if rf, ok := ret.Get(0).(func(string, []string) ([]*model.PropertyValue, error)); ok { + return rf(groupID, ids) } - if rf, ok := ret.Get(0).(func([]string) []*model.PropertyValue); ok { - r0 = rf(ids) + if rf, ok := ret.Get(0).(func(string, []string) []*model.PropertyValue); ok { + r0 = rf(groupID, ids) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.PropertyValue) } } - if rf, ok := ret.Get(1).(func([]string) error); ok { - r1 = rf(ids) + if rf, ok := ret.Get(1).(func(string, []string) error); ok { + r1 = rf(groupID, ids) } else { r1 = ret.Error(1) } diff --git a/server/channels/store/storetest/property_field_store.go b/server/channels/store/storetest/property_field_store.go index d6f8ce6a409..a37214c8d15 100644 --- a/server/channels/store/storetest/property_field_store.go +++ b/server/channels/store/storetest/property_field_store.go @@ -75,44 +75,59 @@ func testCreatePropertyField(t *testing.T, _ request.CTX, ss store.Store) { func testGetPropertyField(t *testing.T, _ request.CTX, ss store.Store) { t.Run("should fail on nonexisting field", func(t *testing.T) { - field, err := ss.PropertyField().Get(model.NewId()) + field, err := ss.PropertyField().Get("", model.NewId()) require.Zero(t, field) require.ErrorIs(t, err, sql.ErrNoRows) }) - t.Run("should be able to retrieve an existing property field", func(t *testing.T) { - newField := &model.PropertyField{ - GroupID: model.NewId(), - Name: "My new property field", - Type: model.PropertyFieldTypeText, - Attrs: map[string]any{ - "locked": true, - "special": "value", - }, - } - _, err := ss.PropertyField().Create(newField) - require.NoError(t, err) - require.NotZero(t, newField.ID) + groupID := model.NewId() + newField := &model.PropertyField{ + GroupID: groupID, + Name: "My new property field", + Type: model.PropertyFieldTypeText, + Attrs: map[string]any{ + "locked": true, + "special": "value", + }, + } + _, err := ss.PropertyField().Create(newField) + require.NoError(t, err) + require.NotZero(t, newField.ID) - field, err := ss.PropertyField().Get(newField.ID) + t.Run("should be able to retrieve an existing property field", func(t *testing.T) { + field, err := ss.PropertyField().Get(groupID, newField.ID) + require.NoError(t, err) + require.Equal(t, newField.ID, field.ID) + require.True(t, field.Attrs["locked"].(bool)) + require.Equal(t, "value", field.Attrs["special"]) + + // should work without specifying the group ID as well + field, err = ss.PropertyField().Get("", newField.ID) require.NoError(t, err) require.Equal(t, newField.ID, field.ID) require.True(t, field.Attrs["locked"].(bool)) require.Equal(t, "value", field.Attrs["special"]) }) + + t.Run("should not be able to retrieve an existing field when specifying a different group ID", func(t *testing.T) { + field, err := ss.PropertyField().Get(model.NewId(), newField.ID) + require.Zero(t, field) + require.ErrorIs(t, err, sql.ErrNoRows) + }) } func testGetManyPropertyFields(t *testing.T, _ request.CTX, ss store.Store) { t.Run("should fail on nonexisting fields", func(t *testing.T) { - fields, err := ss.PropertyField().GetMany([]string{model.NewId(), model.NewId()}) + fields, err := ss.PropertyField().GetMany("", []string{model.NewId(), model.NewId()}) require.Empty(t, fields) require.ErrorContains(t, err, "missmatch results") }) + groupID := model.NewId() newFields := []*model.PropertyField{} for _, fieldName := range []string{"field1", "field2", "field3"} { newField := &model.PropertyField{ - GroupID: model.NewId(), + GroupID: groupID, Name: fieldName, Type: model.PropertyFieldTypeText, } @@ -123,18 +138,39 @@ func testGetManyPropertyFields(t *testing.T, _ request.CTX, ss store.Store) { newFields = append(newFields, newField) } + newFieldOutsideGroup := &model.PropertyField{ + GroupID: model.NewId(), + Name: "field outside the groupID", + Type: model.PropertyFieldTypeText, + } + _, err := ss.PropertyField().Create(newFieldOutsideGroup) + require.NoError(t, err) + require.NotZero(t, newFieldOutsideGroup.ID) + t.Run("should fail if at least one of the ids is nonexistent", func(t *testing.T) { - fields, err := ss.PropertyField().GetMany([]string{newFields[0].ID, newFields[1].ID, model.NewId()}) + fields, err := ss.PropertyField().GetMany(groupID, []string{newFields[0].ID, newFields[1].ID, model.NewId()}) require.Empty(t, fields) require.ErrorContains(t, err, "missmatch results") }) t.Run("should be able to retrieve existing property fields", func(t *testing.T) { - fields, err := ss.PropertyField().GetMany([]string{newFields[0].ID, newFields[1].ID, newFields[2].ID}) + fields, err := ss.PropertyField().GetMany(groupID, []string{newFields[0].ID, newFields[1].ID, newFields[2].ID}) require.NoError(t, err) require.Len(t, fields, 3) require.ElementsMatch(t, newFields, fields) }) + + t.Run("should fail if asked for valid IDs but outside the group", func(t *testing.T) { + fields, err := ss.PropertyField().GetMany(groupID, []string{newFields[0].ID, newFieldOutsideGroup.ID}) + require.Empty(t, fields) + require.ErrorContains(t, err, "missmatch results") + }) + + t.Run("should be able to retrieve existing property fields from multiple groups", func(t *testing.T) { + fields, err := ss.PropertyField().GetMany("", []string{newFields[0].ID, newFieldOutsideGroup.ID}) + require.NoError(t, err) + require.Len(t, fields, 2) + }) } func testUpdatePropertyField(t *testing.T, _ request.CTX, ss store.Store) { @@ -216,7 +252,7 @@ func testUpdatePropertyField(t *testing.T, _ request.CTX, ss store.Store) { require.NoError(t, err) // Verify first field - updated1, err := ss.PropertyField().Get(field1.ID) + updated1, err := ss.PropertyField().Get("", field1.ID) require.NoError(t, err) require.Equal(t, "Updated first", updated1.Name) require.Equal(t, model.PropertyFieldTypeSelect, updated1.Type) @@ -226,7 +262,7 @@ func testUpdatePropertyField(t *testing.T, _ request.CTX, ss store.Store) { require.Greater(t, updated1.UpdateAt, updated1.CreateAt) // Verify second field - updated2, err := ss.PropertyField().Get(field2.ID) + updated2, err := ss.PropertyField().Get("", field2.ID) require.NoError(t, err) require.Equal(t, "Updated second", updated2.Name) require.Equal(t, model.PropertyFieldTypeSelect, updated2.Type) @@ -271,12 +307,12 @@ func testUpdatePropertyField(t *testing.T, _ request.CTX, ss store.Store) { require.ErrorContains(t, err, "model.property_field.is_valid.app_error") // Check that fields were not updated - updated1, err := ss.PropertyField().Get(field1.ID) + updated1, err := ss.PropertyField().Get(groupID, field1.ID) require.NoError(t, err) require.Equal(t, "Field 1", updated1.Name) require.Equal(t, originalUpdateAt1, updated1.UpdateAt) - updated2, err := ss.PropertyField().Get(field2.ID) + updated2, err := ss.PropertyField().Get(groupID, field2.ID) require.NoError(t, err) require.Equal(t, groupID, updated2.GroupID) require.Equal(t, originalUpdateAt2, updated2.UpdateAt) @@ -316,7 +352,7 @@ func testUpdatePropertyField(t *testing.T, _ request.CTX, ss store.Store) { require.ErrorContains(t, err, "failed to update, some property fields were not found") // Check that the valid field was not updated - updated1, err := ss.PropertyField().Get(field1.ID) + updated1, err := ss.PropertyField().Get("", field1.ID) require.NoError(t, err) require.Equal(t, "First field", updated1.Name) require.Equal(t, originalUpdateAt, updated1.UpdateAt) @@ -345,7 +381,7 @@ func testDeletePropertyField(t *testing.T, _ request.CTX, ss store.Store) { require.NoError(t, err) // Verify the field was soft-deleted - deletedField, err := ss.PropertyField().Get(field.ID) + deletedField, err := ss.PropertyField().Get("", field.ID) require.NoError(t, err) require.NotZero(t, deletedField.DeleteAt) }) diff --git a/server/channels/store/storetest/property_value_store.go b/server/channels/store/storetest/property_value_store.go index cad73212ac6..7d458f639dd 100644 --- a/server/channels/store/storetest/property_value_store.go +++ b/server/channels/store/storetest/property_value_store.go @@ -76,43 +76,57 @@ func testCreatePropertyValue(t *testing.T, _ request.CTX, ss store.Store) { func testGetPropertyValue(t *testing.T, _ request.CTX, ss store.Store) { t.Run("should fail on nonexisting value", func(t *testing.T) { - value, err := ss.PropertyValue().Get(model.NewId()) + value, err := ss.PropertyValue().Get("", model.NewId()) require.Zero(t, value) require.ErrorIs(t, err, sql.ErrNoRows) }) - t.Run("should be able to retrieve an existing property value", func(t *testing.T) { - newValue := &model.PropertyValue{ - TargetID: model.NewId(), - TargetType: "test_type", - GroupID: model.NewId(), - FieldID: model.NewId(), - Value: json.RawMessage(`"test value"`), - } - _, err := ss.PropertyValue().Create(newValue) - require.NoError(t, err) - require.NotZero(t, newValue.ID) + groupID := model.NewId() + newValue := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: groupID, + FieldID: model.NewId(), + Value: json.RawMessage(`"test value"`), + } + _, err := ss.PropertyValue().Create(newValue) + require.NoError(t, err) + require.NotZero(t, newValue.ID) - value, err := ss.PropertyValue().Get(newValue.ID) + t.Run("should be able to retrieve an existing property value", func(t *testing.T) { + value, err := ss.PropertyValue().Get(groupID, newValue.ID) require.NoError(t, err) require.Equal(t, newValue.ID, value.ID) require.Equal(t, newValue.Value, value.Value) + + // should work without specifying the group ID as well + value, err = ss.PropertyValue().Get("", newValue.ID) + require.NoError(t, err) + require.Equal(t, newValue.ID, value.ID) + require.Equal(t, newValue.Value, value.Value) + }) + + t.Run("should not be able to retrieve an existing value when specifying a different group ID", func(t *testing.T) { + value, err := ss.PropertyValue().Get(model.NewId(), newValue.ID) + require.Zero(t, value) + require.ErrorIs(t, err, sql.ErrNoRows) }) } func testGetManyPropertyValues(t *testing.T, _ request.CTX, ss store.Store) { t.Run("should fail on nonexisting values", func(t *testing.T) { - values, err := ss.PropertyValue().GetMany([]string{model.NewId(), model.NewId()}) + values, err := ss.PropertyValue().GetMany("", []string{model.NewId(), model.NewId()}) require.Empty(t, values) require.ErrorContains(t, err, "missmatch results") }) + groupID := model.NewId() newValues := []*model.PropertyValue{} for i := 0; i < 3; i++ { newValue := &model.PropertyValue{ TargetID: model.NewId(), TargetType: "test_type", - GroupID: model.NewId(), + GroupID: groupID, FieldID: model.NewId(), Value: json.RawMessage(fmt.Sprintf(`"test value %d"`, i)), } @@ -123,18 +137,41 @@ func testGetManyPropertyValues(t *testing.T, _ request.CTX, ss store.Store) { newValues = append(newValues, newValue) } + newValueOutsideGroup := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "test_type", + GroupID: model.NewId(), + FieldID: model.NewId(), + Value: json.RawMessage(`"value outside the groupID"`), + } + _, err := ss.PropertyValue().Create(newValueOutsideGroup) + require.NoError(t, err) + require.NotZero(t, newValueOutsideGroup.ID) + t.Run("should fail if at least one of the ids is nonexistent", func(t *testing.T) { - values, err := ss.PropertyValue().GetMany([]string{newValues[0].ID, newValues[1].ID, model.NewId()}) + values, err := ss.PropertyValue().GetMany(groupID, []string{newValues[0].ID, newValues[1].ID, model.NewId()}) require.Empty(t, values) require.ErrorContains(t, err, "missmatch results") }) t.Run("should be able to retrieve existing property values", func(t *testing.T) { - values, err := ss.PropertyValue().GetMany([]string{newValues[0].ID, newValues[1].ID, newValues[2].ID}) + values, err := ss.PropertyValue().GetMany(groupID, []string{newValues[0].ID, newValues[1].ID, newValues[2].ID}) require.NoError(t, err) require.Len(t, values, 3) require.ElementsMatch(t, newValues, values) }) + + t.Run("should fail if asked for valid IDs but outside the group", func(t *testing.T) { + values, err := ss.PropertyValue().GetMany(groupID, []string{newValues[0].ID, newValueOutsideGroup.ID}) + require.Empty(t, values) + require.ErrorContains(t, err, "missmatch results") + }) + + t.Run("should be able to retrieve existing property values from multiple groups", func(t *testing.T) { + fields, err := ss.PropertyValue().GetMany("", []string{newValues[0].ID, newValueOutsideGroup.ID}) + require.NoError(t, err) + require.Len(t, fields, 2) + }) } func testUpdatePropertyValue(t *testing.T, _ request.CTX, ss store.Store) { @@ -208,13 +245,13 @@ func testUpdatePropertyValue(t *testing.T, _ request.CTX, ss store.Store) { require.NoError(t, err) // Verify first value - updated1, err := ss.PropertyValue().Get(value1.ID) + updated1, err := ss.PropertyValue().Get("", value1.ID) require.NoError(t, err) require.Equal(t, json.RawMessage(`"updated value 1"`), updated1.Value) require.Greater(t, updated1.UpdateAt, updated1.CreateAt) // Verify second value - updated2, err := ss.PropertyValue().Get(value2.ID) + updated2, err := ss.PropertyValue().Get("", value2.ID) require.NoError(t, err) require.Equal(t, json.RawMessage(`"updated value 2"`), updated2.Value) require.Greater(t, updated2.UpdateAt, updated2.CreateAt) @@ -256,12 +293,12 @@ func testUpdatePropertyValue(t *testing.T, _ request.CTX, ss store.Store) { require.Contains(t, err.Error(), "model.property_value.is_valid.app_error") // Check that values were not updated - updated1, err := ss.PropertyValue().Get(value1.ID) + updated1, err := ss.PropertyValue().Get("", value1.ID) require.NoError(t, err) require.Equal(t, json.RawMessage(`"Value 1"`), updated1.Value) require.Equal(t, originalUpdateAt1, updated1.UpdateAt) - updated2, err := ss.PropertyValue().Get(value2.ID) + updated2, err := ss.PropertyValue().Get("", value2.ID) require.NoError(t, err) require.Equal(t, groupID, updated2.GroupID) require.Equal(t, originalUpdateAt2, updated2.UpdateAt) @@ -300,7 +337,7 @@ func testUpdatePropertyValue(t *testing.T, _ request.CTX, ss store.Store) { require.ErrorContains(t, err, "failed to update, some property values were not found") // Check that the valid value was not updated - updated1, err := ss.PropertyValue().Get(value1.ID) + updated1, err := ss.PropertyValue().Get("", value1.ID) require.NoError(t, err) require.Equal(t, json.RawMessage(`"Value 1"`), updated1.Value) require.Equal(t, originalUpdateAt, updated1.UpdateAt) @@ -352,7 +389,7 @@ func testUpsertPropertyValue(t *testing.T, _ request.CTX, ss store.Store) { require.NotZero(t, values[0].CreateAt) require.NotZero(t, values[1].CreateAt) - valuesFromStore, err := ss.PropertyValue().GetMany([]string{values[0].ID, values[1].ID}) + valuesFromStore, err := ss.PropertyValue().GetMany("", []string{values[0].ID, values[1].ID}) require.NoError(t, err) require.Len(t, valuesFromStore, 2) }) @@ -383,7 +420,7 @@ func testUpsertPropertyValue(t *testing.T, _ request.CTX, ss store.Store) { require.Greater(t, values[0].UpdateAt, values[0].CreateAt) // Verify in database - updated, err := ss.PropertyValue().Get(valueID) + updated, err := ss.PropertyValue().Get("", valueID) require.NoError(t, err) require.Equal(t, json.RawMessage(`"updated value"`), updated.Value) require.Greater(t, updated.UpdateAt, updated.CreateAt) @@ -417,10 +454,10 @@ func testUpsertPropertyValue(t *testing.T, _ request.CTX, ss store.Store) { require.Len(t, values, 2) // Verify both values - newValueUpserted, err := ss.PropertyValue().Get(newValue.ID) + newValueUpserted, err := ss.PropertyValue().Get("", newValue.ID) require.NoError(t, err) require.Equal(t, json.RawMessage(`"new value"`), newValueUpserted.Value) - existingValueUpserted, err := ss.PropertyValue().Get(existingValue.ID) + existingValueUpserted, err := ss.PropertyValue().Get("", existingValue.ID) require.NoError(t, err) require.Equal(t, json.RawMessage(`"updated existing"`), existingValueUpserted.Value) }) @@ -455,7 +492,7 @@ func testUpsertPropertyValue(t *testing.T, _ request.CTX, ss store.Store) { require.Contains(t, err.Error(), "model.property_value.is_valid.app_error") // Verify the existing value was not changed - retrieved, err := ss.PropertyValue().Get(existingValue.ID) + retrieved, err := ss.PropertyValue().Get("", existingValue.ID) require.NoError(t, err) require.Equal(t, originalValue.Value, retrieved.Value) require.Equal(t, originalValue.UpdateAt, retrieved.UpdateAt) @@ -493,7 +530,7 @@ func testDeletePropertyValue(t *testing.T, _ request.CTX, ss store.Store) { require.NoError(t, err) // Verify the value was soft-deleted - deletedValue, err := ss.PropertyValue().Get(value.ID) + deletedValue, err := ss.PropertyValue().Get("", value.ID) require.NoError(t, err) require.NotZero(t, deletedValue.DeleteAt) }) @@ -716,7 +753,7 @@ func testCreatePropertyValueWithArray(t *testing.T, _ request.CTX, ss store.Stor require.NotZero(t, updated) // Verify updated array values - retrieved, err := ss.PropertyValue().Get(created.ID) + retrieved, err := ss.PropertyValue().Get("", created.ID) require.NoError(t, err) var arrayValues []string require.NoError(t, json.Unmarshal(retrieved.Value, &arrayValues)) @@ -726,12 +763,13 @@ func testCreatePropertyValueWithArray(t *testing.T, _ request.CTX, ss store.Stor func testDeleteForField(t *testing.T, _ request.CTX, ss store.Store) { fieldID := model.NewId() + groupID := model.NewId() // Create test values value1 := &model.PropertyValue{ TargetID: model.NewId(), TargetType: "test_type", - GroupID: model.NewId(), + GroupID: groupID, FieldID: fieldID, Value: json.RawMessage(`"value 1"`), } @@ -739,7 +777,7 @@ func testDeleteForField(t *testing.T, _ request.CTX, ss store.Store) { value2 := &model.PropertyValue{ TargetID: model.NewId(), TargetType: "test_type", - GroupID: model.NewId(), + GroupID: groupID, FieldID: fieldID, Value: json.RawMessage(`"value 2"`), } @@ -747,7 +785,7 @@ func testDeleteForField(t *testing.T, _ request.CTX, ss store.Store) { value3 := &model.PropertyValue{ TargetID: model.NewId(), TargetType: "test_type", - GroupID: model.NewId(), + GroupID: groupID, FieldID: model.NewId(), // Different field ID Value: json.RawMessage(`"value 3"`), } @@ -762,14 +800,14 @@ func testDeleteForField(t *testing.T, _ request.CTX, ss store.Store) { require.NoError(t, err) // Verify values were soft-deleted - deletedValues, err := ss.PropertyValue().GetMany([]string{value1.ID, value2.ID}) + deletedValues, err := ss.PropertyValue().GetMany(groupID, []string{value1.ID, value2.ID}) require.NoError(t, err) require.Len(t, deletedValues, 2) require.NotZero(t, deletedValues[0].DeleteAt) require.NotZero(t, deletedValues[1].DeleteAt) // Verify value with different field ID was not deleted - nonDeletedValue, err := ss.PropertyValue().Get(value3.ID) + nonDeletedValue, err := ss.PropertyValue().Get(groupID, value3.ID) require.NoError(t, err) require.Zero(t, nonDeletedValue.DeleteAt) } diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index c803ec4fb6c..bdd09444621 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -7153,10 +7153,10 @@ func (s *TimerLayerPropertyFieldStore) Delete(id string) error { return err } -func (s *TimerLayerPropertyFieldStore) Get(id string) (*model.PropertyField, error) { +func (s *TimerLayerPropertyFieldStore) Get(groupID string, id string) (*model.PropertyField, error) { start := time.Now() - result, err := s.PropertyFieldStore.Get(id) + result, err := s.PropertyFieldStore.Get(groupID, id) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -7169,10 +7169,10 @@ func (s *TimerLayerPropertyFieldStore) Get(id string) (*model.PropertyField, err return result, err } -func (s *TimerLayerPropertyFieldStore) GetMany(ids []string) ([]*model.PropertyField, error) { +func (s *TimerLayerPropertyFieldStore) GetMany(groupID string, ids []string) ([]*model.PropertyField, error) { start := time.Now() - result, err := s.PropertyFieldStore.GetMany(ids) + result, err := s.PropertyFieldStore.GetMany(groupID, ids) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -7297,10 +7297,10 @@ func (s *TimerLayerPropertyValueStore) DeleteForField(id string) error { return err } -func (s *TimerLayerPropertyValueStore) Get(id string) (*model.PropertyValue, error) { +func (s *TimerLayerPropertyValueStore) Get(groupID string, id string) (*model.PropertyValue, error) { start := time.Now() - result, err := s.PropertyValueStore.Get(id) + result, err := s.PropertyValueStore.Get(groupID, id) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { @@ -7313,10 +7313,10 @@ func (s *TimerLayerPropertyValueStore) Get(id string) (*model.PropertyValue, err return result, err } -func (s *TimerLayerPropertyValueStore) GetMany(ids []string) ([]*model.PropertyValue, error) { +func (s *TimerLayerPropertyValueStore) GetMany(groupID string, ids []string) ([]*model.PropertyValue, error) { start := time.Now() - result, err := s.PropertyValueStore.GetMany(ids) + result, err := s.PropertyValueStore.GetMany(groupID, ids) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { diff --git a/server/channels/web/handlers.go b/server/channels/web/handlers.go index 9d3531f1e05..6314ce629f5 100644 --- a/server/channels/web/handlers.go +++ b/server/channels/web/handlers.go @@ -25,10 +25,6 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/utils" ) -const ( - frameAncestors = "'self' teams.microsoft.com" -) - func GetHandlerName(h func(*Context, http.ResponseWriter, *http.Request)) string { handlerName := runtime.FuncForPC(reflect.ValueOf(h).Pointer()).Name() pos := strings.LastIndex(handlerName, ".") @@ -242,8 +238,8 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Set content security policy. This is also specified in the root.html of the webapp in a meta tag. w.Header().Set("Content-Security-Policy", fmt.Sprintf( - "frame-ancestors %s; script-src 'self' cdn.rudderlabs.com%s%s", - frameAncestors, + "frame-ancestors 'self' %s; script-src 'self' cdn.rudderlabs.com%s%s", + *c.App.Config().ServiceSettings.FrameAncestors, h.cspShaDirective, devCSP, )) diff --git a/server/channels/web/handlers_test.go b/server/channels/web/handlers_test.go index 37fe2b21b38..97a8722b853 100644 --- a/server/channels/web/handlers_test.go +++ b/server/channels/web/handlers_test.go @@ -343,10 +343,10 @@ func TestHandlerServeCSPHeader(t *testing.T) { response := httptest.NewRecorder() handler.ServeHTTP(response, request) assert.Equal(t, 200, response.Code) - assert.Equal(t, []string{"frame-ancestors " + frameAncestors + "; script-src 'self' cdn.rudderlabs.com"}, response.Header()["Content-Security-Policy"]) + assert.Equal(t, []string{"frame-ancestors 'self' " + *th.App.Config().ServiceSettings.FrameAncestors + "; script-src 'self' cdn.rudderlabs.com"}, response.Header()["Content-Security-Policy"]) }) - t.Run("static, with subpath", func(t *testing.T) { + t.Run("static, with subpath and frame ancestors", func(t *testing.T) { th := SetupWithStoreMock(t) defer th.TearDown() @@ -367,6 +367,7 @@ func TestHandlerServeCSPHeader(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = *cfg.ServiceSettings.SiteURL + "/subpath" + *cfg.ServiceSettings.FrameAncestors = "teams.microsoft.com *.cloud.microsoft" }) web := New(th.Server) @@ -384,7 +385,7 @@ func TestHandlerServeCSPHeader(t *testing.T) { response := httptest.NewRecorder() handler.ServeHTTP(response, request) assert.Equal(t, 200, response.Code) - assert.Equal(t, []string{"frame-ancestors " + frameAncestors + "; script-src 'self' cdn.rudderlabs.com"}, response.Header()["Content-Security-Policy"]) + assert.Equal(t, []string{"frame-ancestors 'self' " + *th.App.Config().ServiceSettings.FrameAncestors + "; script-src 'self' cdn.rudderlabs.com"}, response.Header()["Content-Security-Policy"]) // TODO: It's hard to unit test this now that the CSP directive is effectively // decided in Setup(). Circle back to this in master once the memory store is @@ -399,7 +400,7 @@ func TestHandlerServeCSPHeader(t *testing.T) { response = httptest.NewRecorder() handler.ServeHTTP(response, request) assert.Equal(t, 200, response.Code) - assert.Equal(t, []string{"frame-ancestors " + frameAncestors + "; script-src 'self' cdn.rudderlabs.com"}, response.Header()["Content-Security-Policy"]) + assert.Equal(t, []string{"frame-ancestors 'self' " + *th.App.Config().ServiceSettings.FrameAncestors + "; script-src 'self' cdn.rudderlabs.com"}, response.Header()["Content-Security-Policy"]) // TODO: See above. // assert.Contains(t, response.Header()["Content-Security-Policy"], "frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com 'sha256-tPOjw+tkVs9axL78ZwGtYl975dtyPHB6LYKAO2R3gR4='", "csp header incorrectly changed after subpath changed") }) @@ -429,7 +430,7 @@ func TestHandlerServeCSPHeader(t *testing.T) { response := httptest.NewRecorder() handler.ServeHTTP(response, request) assert.Equal(t, 200, response.Code) - assert.Equal(t, []string{"frame-ancestors " + frameAncestors + "; script-src 'self' cdn.rudderlabs.com 'unsafe-eval' 'unsafe-inline'"}, response.Header()["Content-Security-Policy"]) + assert.Equal(t, []string{"frame-ancestors 'self' " + *th.App.Config().ServiceSettings.FrameAncestors + "; script-src 'self' cdn.rudderlabs.com 'unsafe-eval' 'unsafe-inline'"}, response.Header()["Content-Security-Policy"]) }) } diff --git a/server/channels/web/oauth.go b/server/channels/web/oauth.go index 077baf0c5a7..b0a22413286 100644 --- a/server/channels/web/oauth.go +++ b/server/channels/web/oauth.go @@ -195,7 +195,7 @@ func authorizeOAuthPage(c *Context, w http.ResponseWriter, r *http.Request) { c.LogAudit("success") w.Header().Set("X-Frame-Options", "SAMEORIGIN") - w.Header().Set("Content-Security-Policy", fmt.Sprintf("frame-ancestors %s", frameAncestors)) + w.Header().Set("Content-Security-Policy", fmt.Sprintf("frame-ancestors 'self' %s", *c.App.Config().ServiceSettings.FrameAncestors)) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache, max-age=31556926") diff --git a/server/i18n/cs.json b/server/i18n/cs.json index e6b6b3fe1bf..6261e50997c 100644 --- a/server/i18n/cs.json +++ b/server/i18n/cs.json @@ -517,7 +517,7 @@ }, { "id": "model.incoming_hook.id.app_error", - "translation": "Nevalidní Id." + "translation": "Neplatné Id: {{.Id}}." }, { "id": "model.incoming_hook.icon_url.app_error", @@ -5293,11 +5293,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Verze 130+" + "translation": "Verze 132+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Verze 130+" + "translation": "Verze 132+" }, { "id": "web.error.unsupported_browser.install_guide.mac", @@ -10422,5 +10422,25 @@ { "id": "app.file_info.get_storage_usage.app_error", "translation": "Nepodařilo se získat využití úložiště pro všechny soubory." + }, + { + "id": "api.context.get_session.app_error", + "translation": "Relace nebyla nalezena." + }, + { + "id": "app.custom_profile_attributes.count_property_fields.app_error", + "translation": "Nelze spočítat počet polí pro skupinu vlastního atributu profilu" + }, + { + "id": "app.custom_profile_attributes.property_value_upsert.app_error", + "translation": "Nelze provést vložení nebo aktualizaci polí vlastního atributu profilu" + }, + { + "id": "model.config.is_valid.metrics_client_side_user_id.app_error", + "translation": "Neplatné ID uživatele na straně klienta: {{.Id}}" + }, + { + "id": "model.config.is_valid.metrics_client_side_user_ids.app_error", + "translation": "Počet prvků v ClientSideUserIds {{.CurrentLength}} je vyšší než maximální limit {{.MaxLength}}." } ] diff --git a/server/i18n/de.json b/server/i18n/de.json index 38a5c03deb1..c30a2581c2a 100644 --- a/server/i18n/de.json +++ b/server/i18n/de.json @@ -4648,11 +4648,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Version 130+" + "translation": "Version 132+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Version 130+" + "translation": "Version 132+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -10438,5 +10438,25 @@ { "id": "model.config.is_valid.metrics_client_side_user_ids.app_error", "translation": "Die Anzahl der Elemente in ClientSideUserIds {{.CurrentLength}} ist höher als die maximale Grenze von {{.MaxLength}}." + }, + { + "id": "api.channel.update_channel.banner_info.channel_type.not_allowed", + "translation": "Kanalbanner können nur für öffentliche und private Kanäle konfiguriert werden." + }, + { + "id": "model.channel.is_valid.banner_info.background_color.empty.app_error", + "translation": "Die Farbe des Kanalbanners darf nicht leer sein, wenn das Kanalbanner aktiviert ist" + }, + { + "id": "model.channel.is_valid.banner_info.channel_type.app_error", + "translation": "Kanalbanner können nur für öffentliche und private Kanäle konfiguriert werden" + }, + { + "id": "model.channel.is_valid.banner_info.text.empty.app_error", + "translation": "Kanalbanner-Infotext kann nicht leer sein, wenn Kanalbanner aktiviert ist" + }, + { + "id": "model.channel.is_valid.banner_info.text.invalid_length.app_error", + "translation": "Der Text im Kanalbanner ist zu lang. Die maximal zulässige Länge beträgt {{.maxLength}} Zeichen." } ] diff --git a/server/i18n/es.json b/server/i18n/es.json index 41b4bb856b6..ece20272882 100644 --- a/server/i18n/es.json +++ b/server/i18n/es.json @@ -598,7 +598,7 @@ { "id": "api.command_groupmsg.invalid_user.app_error", "translation": { - "many": "No se puede encontrar los usuarios: {{.Users}}", + "many": "No se pueden encontrar los usuarios: {{.Users}}", "one": "No se puede encontrar el usuario: {{.Users}}", "other": "" } @@ -649,7 +649,7 @@ }, { "id": "api.command_invite.hint", - "translation": "@[usuario] ~[canal]" + "translation": "@[username] ~[channel]..." }, { "id": "api.command_invite.missing_message.app_error", @@ -657,7 +657,7 @@ }, { "id": "api.command_invite.missing_user.app_error", - "translation": "No se pudo encontrar el usuario. Puede que haya sido desactivado por el Administrador del Sistema." + "translation": "No se pudo encontrar al usuario {{.User}}. Puede que haya sido desactivado por el Administrador del Sistema." }, { "id": "api.command_invite.name", @@ -677,7 +677,11 @@ }, { "id": "api.command_invite.user_already_in_channel.app_error", - "translation": "{{.User}} ya está en el canal." + "translation": { + "many": "{{.User}} ya están en el canal.", + "one": "{{.User}} ya está en el canal.", + "other": "" + } }, { "id": "api.command_invite_people.permission.app_error", @@ -982,8 +986,8 @@ { "id": "api.email_batching.send_batched_email_notification.subject", "translation": { - "many": "[{{.SiteName}}] Nuevas Notificaciones {{.Day}} {{.Month}}, {{.Year}}", - "one": "[{{.SiteName}}] Nueva Notificación {{.Day}} {{.Month}}, {{.Year}}", + "many": "[{{.SiteName}}] Nuevas Notificaciones del {{.Day}} {{.Month}}, {{.Year}}", + "one": "[{{.SiteName}}] Nueva Notificación del {{.Day}} {{.Month}}, {{.Year}}", "other": "" } }, @@ -1729,7 +1733,7 @@ }, { "id": "api.team.update_restricted_domains.mismatch.app_error", - "translation": "La restricción del equipo a {{.Domain}} no está permitido por la configuración del sistema. Por favor contacta a un administrador del sistema." + "translation": "La configuración del sistema no permite limitar el equipo a {{ .Domain }}. Por favor, contacta con el administrador." }, { "id": "api.team.update_team_scheme.license.error", @@ -1885,7 +1889,7 @@ }, { "id": "api.templates.user_access_token_body.info", - "translation": "Un token de acceso personal ha sido agregado a tu cuenta en {{ .SiteURL}}. El mismo puede ser utilizado para acceder a {{.SiteName}} con tu cuenta." + "translation": "Un token de acceso personal ha sido agregado a tu cuenta en {{ .SiteURL }}. El mismo puede ser utilizado para acceder a {{.SiteName}} con tu cuenta." }, { "id": "api.templates.user_access_token_body.title", @@ -2105,7 +2109,7 @@ }, { "id": "api.user.login_by_oauth.not_available.app_error", - "translation": "{{.Servicio}} SSO a través de OAuth 2.0 no está disponible en este servidor." + "translation": "{{.Service}} SSO a través de OAuth 2.0 no está disponible en este servidor." }, { "id": "api.user.login_by_oauth.parse.app_error", @@ -2261,7 +2265,7 @@ }, { "id": "api.web_socket.connect.upgrade.app_error", - "translation": "Falla al actualizar la conexión del websocket." + "translation": "URL bloqueada por restricción de CORS. URL: {{.BlockedOrigin}}" }, { "id": "api.web_socket_router.bad_action.app_error", @@ -2849,11 +2853,11 @@ }, { "id": "app.notification.subject.group_message.full", - "translation": "[{{ .SiteName }}] Nuevo Mensaje de Grupo en {{ .TeamName}} el {{.Day}} {{.Month}}, {{.Year}}" + "translation": "[{{ .SiteName }}] Nuevo Mensaje de Grupo en {{ .ChannelName}} el {{.Day}} {{.Month}}, {{.Year}}" }, { "id": "app.notification.subject.group_message.generic", - "translation": "[{{.SiteName}}] Nuevo Mensaje de Grupo el {{.Day}} {{.Month}}, {{.Year}}" + "translation": "[{{ .SiteName }}] Nuevo Mensaje de Grupo el {{.Day}} {{.Month}}, {{.Year}}" }, { "id": "app.notification.subject.notification.full", @@ -3009,31 +3013,31 @@ }, { "id": "ent.elasticsearch.aggregator_worker.create_index_job.error", - "translation": "El agregador trabajos de Elasticsearch no pudo crear el trabajo de indexación" + "translation": "El agregador trabajos de {{.Backend}} no pudo crear el trabajo de indexación" }, { "id": "ent.elasticsearch.aggregator_worker.delete_indexes.error", - "translation": "El agregador trabajos de Elasticsearch no pudo eliminar los índices" + "translation": "El agregador trabajos de {{.Backend}} no pudo eliminar los índices" }, { "id": "ent.elasticsearch.aggregator_worker.get_indexes.error", - "translation": "El agregador trabajos de Elasticsearch no pudo obtener los índices" + "translation": "El agregador trabajos de {{.Backend}} no pudo obtener los índices" }, { "id": "ent.elasticsearch.aggregator_worker.index_job_failed.error", - "translation": "El agregador trabajos de Elasticsearch falló debido a que hay un trabajo indexación fallando" + "translation": "El agregador trabajos de {{.Backend}} falló debido a que hay un trabajo indexación fallando" }, { "id": "ent.elasticsearch.create_client.connect_failed", - "translation": "La configuración del cliente de Elasticsearch falló" + "translation": "La configuración del cliente de {{.Backend}} falló" }, { "id": "ent.elasticsearch.data_retention_delete_indexes.delete_index.error", - "translation": "No se pudo borrar el índice de ElasticSearch" + "translation": "No se pudo borrar el índice de {{.Backend}}" }, { "id": "ent.elasticsearch.data_retention_delete_indexes.get_indexes.error", - "translation": "No se pudo obtener los índices de Elasticsearch" + "translation": "No se pudo obtener los índices de {{.Backend}}" }, { "id": "ent.elasticsearch.delete_post.error", @@ -3041,7 +3045,7 @@ }, { "id": "ent.elasticsearch.generic.disabled", - "translation": "La búsqueda con Elasticsearch está deshabilitada en este servidor" + "translation": "La búsqueda con {{.Backend}} está deshabilitada en este servidor" }, { "id": "ent.elasticsearch.index_post.error", @@ -3049,19 +3053,19 @@ }, { "id": "ent.elasticsearch.indexer.do_job.parse_end_time.error", - "translation": "El trabajo de Elasticsearch falló al analizar la hora de finalización" + "translation": "El trabajo de indexación en {{.Backend}} falló al analizar la hora de finalización" }, { "id": "ent.elasticsearch.indexer.do_job.parse_start_time.error", - "translation": "El trabajo de Elasticsearch falló al analizar la hora de inicio" + "translation": "El trabajo de indexación en {{.Backend}} falló al analizar la hora de inicio" }, { "id": "ent.elasticsearch.not_started.error", - "translation": "Elasticsearch no se ha iniciado" + "translation": "{{.Backend}} no se ha iniciado" }, { "id": "ent.elasticsearch.search_posts.disabled", - "translation": "La búsqueda con Elasticsearch está deshabilitada en este servidor" + "translation": "La búsqueda con {{.Backend}} está deshabilitada en este servidor" }, { "id": "ent.elasticsearch.search_posts.parse_matches_failed", @@ -3077,11 +3081,11 @@ }, { "id": "ent.elasticsearch.stop.already_stopped.app_error", - "translation": "Elasticsearch ya fue detenido." + "translation": "{{.Backend}} ya fue detenido." }, { "id": "ent.elasticsearch.test_config.indexing_disabled.error", - "translation": "Elasticsearch está deshabilitado." + "translation": "{{.Backend}} está deshabilitado." }, { "id": "ent.elasticsearch.test_config.license.error", @@ -3241,7 +3245,7 @@ }, { "id": "interactive_message.decode_trigger_id.expired", - "translation": "El trigger ID para el diálogo interactivo ha expirado. Los trigger IDs tienen una duración máxima de {{.Seconds}} segundos." + "translation": "El trigger ID para el diálogo interactivo ha expirado. Los trigger IDs tienen una duración máxima de {{.Duration}}." }, { "id": "interactive_message.decode_trigger_id.missing_data", @@ -3605,7 +3609,7 @@ }, { "id": "model.config.is_valid.elastic_search.enable_searching.app_error", - "translation": "El valor de Elasticsearch IndexingEnabled debe ser verdadero cuando el valor de Elasticsearch SearchEnabled es verdadero" + "translation": "La configuración {{.EnableIndexing}} debe estar establecida en verdadero cuando {{.Searching}} está configurado como verdadero" }, { "id": "model.config.is_valid.elastic_search.live_indexing_batch_size.app_error", @@ -3993,7 +3997,7 @@ }, { "id": "model.incoming_hook.id.app_error", - "translation": "Id inválido." + "translation": "Id inválido: {{.Id}}." }, { "id": "model.incoming_hook.parse_data.app_error", @@ -4513,7 +4517,7 @@ }, { "id": "web.incoming_webhook.channel_locked.app_error", - "translation": "Este webhook no tiene autorización para publicar en el canal solicitado." + "translation": "Este webhook no tiene autorización para publicar en el canal {{.channel_id}} solicitado" }, { "id": "web.incoming_webhook.disabled.app_error", @@ -4529,7 +4533,7 @@ }, { "id": "web.incoming_webhook.permissions.app_error", - "translation": "Permisos del canal inapropiados." + "translation": "El usuario {{.user}} no tiene los permisos apropiados para el canal {{.channel}}" }, { "id": "web.incoming_webhook.split_props_length.app_error", @@ -4541,7 +4545,7 @@ }, { "id": "web.incoming_webhook.user.app_error", - "translation": "No se encontró el usuario." + "translation": "No se encontró el usuario {{.user}}" }, { "id": "api.license.request-trial.bad-request", @@ -4781,7 +4785,7 @@ }, { "id": "model.config.is_valid.elastic_search.enable_autocomplete.app_error", - "translation": "El valor de Elasticsearch EnabledIndexing debe ser verdadero cuando el valor de Elasticsearch EnabledAutocomplete es verdadero" + "translation": "La configuración {{.EnableIndexing}} debe estar establecida en verdadero cuando {{.Autocomplete}} está configurado como verdadero" }, { "id": "model.config.is_valid.bleve_search.filename.app_error", @@ -4837,15 +4841,15 @@ }, { "id": "ent.elasticsearch.create_template_users_if_not_exists.template_create_failed", - "translation": "No se pudo crear la plantilla Elasticsearch para los usuarios" + "translation": "No se pudo crear la plantilla {{.Backend}} para los usuarios" }, { "id": "ent.elasticsearch.create_template_posts_if_not_exists.template_create_failed", - "translation": "No se pudo crear la plantilla Elasticsearch para los mensajes" + "translation": "No se pudo crear la plantilla {{.Backend}} para los mensajes" }, { "id": "ent.elasticsearch.create_template_channels_if_not_exists.template_create_failed", - "translation": "No se pudo crear la plantilla Elasticsearch para los canales" + "translation": "No se pudo crear la plantilla {{.Backend}} para los canales" }, { "id": "bleveengine.stop_user_index.error", @@ -5441,7 +5445,7 @@ }, { "id": "api.post.check_for_out_of_channel_groups_mentions.message.multiple", - "translation": "@{{.Usernames}} y @{.LastUsername}} no fueron notificados por esta mención porque no se encuentran en este canal. Ellos no pueden ser agregados a este canal porque no son miembros de los grupos asociados. Para agregarlos a este canal, deben ser agregados a los grupos asociados." + "translation": "@{{.Usernames}} y @{{.LastUsername}} no fueron notificados por esta mención porque no se encuentran en este canal. Ellos no pueden ser agregados a este canal porque no son miembros de los grupos asociados. Para agregarlos a este canal, deben ser agregados a los grupos asociados." }, { "id": "api.post.check_for_out_of_channel_group_users.message.none", @@ -5493,7 +5497,7 @@ }, { "id": "api.command_invite.user_not_in_team.app_error", - "translation": "@{{.Username}} no es un miembro de este canal." + "translation": "Solo se puede agregar {{.Users}} a este canal una vez que sean miembros del equipo **{{.Team}}**." }, { "id": "api.command.execute_command.format.app_error", @@ -5625,11 +5629,11 @@ }, { "id": "ent.elasticsearch.start.parse_server_version.app_error", - "translation": "Fallo al interpretar la versión del servidor de Elasticsearch." + "translation": "Fallo al interpretar la versión del servidor de {{.Backend}}." }, { "id": "ent.elasticsearch.start.get_server_version.app_error", - "translation": "Fallo al intentar recuperar la versión del servidor de Elasticsearch." + "translation": "Fallo al intentar recuperar la versión del servidor de {{.Backend}}." }, { "id": "ent.elasticsearch.search_users.unmarshall_user_failed", @@ -5649,7 +5653,7 @@ }, { "id": "ent.elasticsearch.search_channels.disabled", - "translation": "La búsqueda con Elasticsearch está deshabilitada en este servidor" + "translation": "La búsqueda con {{.Backend}} está deshabilitada en este servidor" }, { "id": "ent.elasticsearch.refresh_indexes.refresh_failed", @@ -7801,7 +7805,7 @@ }, { "id": "api.command_share.check_channel_exist.error", - "translation": "Error al comprobar si el canal compartido existe: {{.Error}}" + "translation": "Error al comprobar si el canal compartido {{.ChannelID}} existe: {{.Error}}" }, { "id": "api.command_share.channel_remote_id_not_exists", @@ -7841,7 +7845,7 @@ }, { "id": "api.command_remote.remote_table_header", - "translation": "| Conexión segura | Nombre a mostrar | ID de la conexión | URL del Sitio | Invitación aceptada | En Linea | Último ping |" + "translation": "| Conexión segura | Nombre a mostrar | ID de la conexión | URL del Sitio | Equipo predeterminado | Invitación aceptada | En Linea | Último ping | Eliminado |" }, { "id": "api.command_remote.name.hint", @@ -8029,7 +8033,7 @@ }, { "id": "ent.elasticsearch.search_files.disabled", - "translation": "La búsqueda de archivos en Elasticsearch está desactivada en este servidor" + "translation": "La búsqueda de archivos en {{.Backend}} está desactivada en este servidor" }, { "id": "ent.elasticsearch.post.get_files_batch_for_indexing.error", @@ -8053,7 +8057,7 @@ }, { "id": "ent.elasticsearch.create_template_file_info_if_not_exists.template_create_failed", - "translation": "Fallo al crear la plantilla de Elasticsearch para los archivos" + "translation": "Fallo al crear la plantilla de {{.Backend}} para los archivos" }, { "id": "ent.data_retention.run_failed.error", @@ -8393,7 +8397,7 @@ }, { "id": "common.parse_error_int64", - "translation": " " + "translation": "Error al convertir el valor: {{.Value}} a int64" }, { "id": "app.post.cloud.get.app_error", @@ -8489,7 +8493,7 @@ }, { "id": "api.templates.invite_team_and_channels_body.title", - "translation": " " + "translation": "{{ .SenderName }} te invitó a unirte a {{ .ChannelsLen }} canales del Equipo {{ .TeamDisplayName }}" }, { "id": "model.channel.is_valid.1_or_more.app_error", @@ -8541,11 +8545,11 @@ }, { "id": "api.templates.invite_team_and_channels_subject", - "translation": " " + "translation": "[{{ .SiteName }}] {{ .SenderName }} te ha invitado a participar en {{ .ChannelsLen }} canales del equipo {{ .TeamDisplayName }}" }, { "id": "api.templates.invite_team_and_channel_body.title", - "translation": "{{ .SenderName }} te invitó a unirte al Canal {{ .ChannelName }} en el Equipo {{ .TeamDisplayName}}" + "translation": "{{ .SenderName }} te invitó a unirte al Canal {{ .ChannelName }} en el Equipo {{ .TeamDisplayName }}" }, { "id": "app.user.update_thread_read_for_user_by_post.app_error", @@ -8585,7 +8589,7 @@ }, { "id": "api.command_help.success", - "translation": "Mattermost es una plataforma de código abierto para comunicación segura, colaboración, y orquestación de trabajo entre herramientas y equipos.\nMattermost contiene tres herramientas clave:\n\n**Channels** - Mantente conectado con tu equipo vía 1:1 y en por mensajes grupales.\n**[Playbooks](/playbooks)** - Construye y configura procesos repetibles para lograr resultados específicos y predecibles.\n**[Tableros](/boards)** - Administra proyectos y tareas en una estructura de tablero Kanban para ayudar a que tú equipo logre los hitos clave.\n\n[Ver documentación y guías]({{.HelpLink}})" + "translation": "Mattermost es una plataforma de código abierto para comunicación segura, colaboración, y orquestación de trabajo entre herramientas y equipos.\nMattermost contiene tres herramientas clave:\n\n**Canales** - Mantente conectado con tu equipo vía 1:1 y en por mensajes grupales.\n**[Playbooks](/playbooks)** - Construye y configura procesos repetibles para lograr resultados específicos y predecibles.\n\n[Ver documentación y guías]({{.HelpLink}})" }, { "id": "app.cloud.trial_plan_bot_message", @@ -8737,7 +8741,7 @@ }, { "id": "api.acknowledgement.save.archived_channel.app_error", - "translation": "No puedes reconocer en un canal archivado." + "translation": "No puedes guardar un acuse de recibo en un canal archivado." }, { "id": "api.command_invite.user_not_in_team.messageOverflow", @@ -8757,7 +8761,7 @@ }, { "id": "api.config.update_config.translations.app_error", - "translation": "Falla al actualizar traducciones del servidor." + "translation": "No se pudo actualizar traducciones del servidor." }, { "id": "api.context.ip_filtering.apply_ip_filters.app_error", @@ -8765,7 +8769,7 @@ }, { "id": "api.context.ip_filtering.get_ip_filters.app_error", - "translation": "Ha ocurrido un error mientras se buscaron los Filtros IP" + "translation": "Se ha producido un error al obtener los Filtros de IP" }, { "id": "api.context.ip_filtering.get_my_ip.failed", @@ -8858,5 +8862,313 @@ { "id": "api.channel.bookmark.delete_channel_bookmark.direct_or_group_channels.forbidden.app_error", "translation": "Error al eliminar el marcador de canal." + }, + { + "id": "api.channel.bookmark.create_channel_bookmark.deleted_channel.forbidden.app_error", + "translation": "No se pudo crear el marcador del canal." + }, + { + "id": "api.channel.bookmark.delete_channel_bookmark.deleted_channel.forbidden.app_error", + "translation": "No se pudo eliminar el marcador del canal." + }, + { + "id": "api.channel.bookmark.delete_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", + "translation": "No se pudo eliminar el marcador del canal." + }, + { + "id": "api.channel.bookmark.delete_channel_bookmark.forbidden.app_error", + "translation": "No se pudo eliminar el marcador del canal." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark.deleted_channel.forbidden.app_error", + "translation": "No se pudo actualizar el marcador del canal." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark.direct_or_group_channels.forbidden.app_error", + "translation": "No se pudo actualizar el marcador del canal." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", + "translation": "No se pudo actualizar el marcador del canal." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark.forbidden.app_error", + "translation": "No se pudo actualizar el marcador del canal." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark_sort_order.deleted_channel.forbidden.app_error", + "translation": "No se pudo actualizar el orden de clasificación del marcador del canal." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark_sort_order.direct_or_group_channels.forbidden.app_error", + "translation": "No se pudo actualizar el orden de clasificación del marcador del canal." + }, + { + "id": "api.context.outgoing_oauth_connection.update_connection.app_error", + "translation": "Ocurrió un error al actualizar la conexión OAuth saliente." + }, + { + "id": "api.custom_profile_attributes.field_not_found", + "translation": "intentando modificar un campo que no existe" + }, + { + "id": "api.channel.bookmark.update_channel_bookmark_sort_order.forbidden.app_error", + "translation": "No se pudo actualizar el orden de clasificación del marcador del canal." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark_sort_order.direct_or_group_channels_by_guests.forbidden.app_error", + "translation": "No se pudo actualizar el orden de clasificación del marcador del canal." + }, + { + "id": "api.oauth.get_access_token.bad_request.app_error", + "translation": "invalid_request: Solicitud errónea." + }, + { + "id": "api.context.get_session.app_error", + "translation": "Sesiones no encontradas." + }, + { + "id": "api.filter_config_error", + "translation": "No se pudo filtrar la configuración." + }, + { + "id": "api.license.request-trial.bad-request.business-email", + "translation": "Dirección de correo electrónico empresarial inválida para la prueba" + }, + { + "id": "api.context.outgoing_oauth_connection.create_connection.app_error", + "translation": "Se produjo un error al crear la conexión OAuth saliente." + }, + { + "id": "api.context.outgoing_oauth_connection.create_connection.input_error", + "translation": "Parámetros de entrada inválidos." + }, + { + "id": "api.context.outgoing_oauth_connection.not_available.configuration_disabled", + "translation": "Las conexiones OAuth salientes no están disponibles en este servidor." + }, + { + "id": "api.context.outgoing_oauth_connection.list_connections.app_error", + "translation": "Ocurrió un error al listar las conexiones OAuth salientes." + }, + { + "id": "api.context.outgoing_oauth_connection.list_connections.input_error", + "translation": "Parámetros de entrada inválidos." + }, + { + "id": "api.context.outgoing_oauth_connection.validate_connection_credentials.app_error", + "translation": "Ocurrió un error al validar las credenciales de conexión OAuth saliente." + }, + { + "id": "api.context.outgoing_oauth_connection.update_connection.input_error", + "translation": "Parámetros de entrada inválidos." + }, + { + "id": "api.custom_status.set_custom_statuses.emoji_not_found", + "translation": "No se pudo actualizar el estado personalizado. El emoji con el nombre especificado no existe." + }, + { + "id": "api.emoji.upload.seek.app_error", + "translation": "No fue posible ubicar el principio del archivo." + }, + { + "id": "api.channel.create_channel.missing_team_id.error", + "translation": "Falta team_id en el cuerpo de la solicitud" + }, + { + "id": "api.channel.create_channel.missing_display_name.error", + "translation": "Falta display_name en el cuerpo de la solicitud" + }, + { + "id": "api.context.request_body_too_large.app_error", + "translation": "No se puede procesar la solicitud. El cuerpo de la solicitud es demasiado grande." + }, + { + "id": "api.file.zip_file_reader.app_error", + "translation": "No se pudo obtener un lector de archivos zip." + }, + { + "id": "api.custom_profile_attributes.license_error", + "translation": "La licencia actual no permite Atributos de Perfil Personalizados." + }, + { + "id": "api.error_no_organization_name_provided_for_self_hosted_onboarding", + "translation": "Error: no se ha indicado el nombre de la organización para la puesta en marcha en servidor propio." + }, + { + "id": "api.get_site_url_error", + "translation": "No se pudo obtener la URL del sitio de la instancia" + }, + { + "id": "api.channel.create_channel.direct_channel.remote_restricted.app_error", + "translation": "No se puede crear un canal directo con un usuario remoto" + }, + { + "id": "api.channel.create_group.remote_restricted.app_error", + "translation": "No se puede crear una canal de grupo con usuarios remotos" + }, + { + "id": "api.channel.update_channel_member_roles.guest.app_error", + "translation": "Actualización de miembro de canal inválida: Un invitado no puede ser promovido a miembro del equipo o administrador del equipo, primero debe ser promovido a usuario." + }, + { + "id": "api.channel.update_channel_member_roles.user_and_guest.app_error", + "translation": "Actualización de miembro de canal no válida: Un invitado no puede ser establecido para un solo canal, un Administrador del Sistema debe promover o degradar usuarios a/desde invitados." + }, + { + "id": "api.config.update.elasticsearch.autocomplete_cannot_be_enabled_error", + "translation": "El autocompletado de canales no se puede habilitar porque el esquema del índice de canales está desactualizado. Se recomienda regenerar el índice de canales. Consulta el registro de cambios de Mattermost para más información" + }, + { + "id": "api.context.outgoing_oauth_connection.delete_connection.app_error", + "translation": "Se produjo un error al eliminar la conexión OAuth saliente." + }, + { + "id": "api.license.add_license.copy.app_error", + "translation": "No se pudo copiar el contenido del archivo de licencia al búfer" + }, + { + "id": "api.job.status.invalid", + "translation": "Estado establecido no válido" + }, + { + "id": "api.job.unable_to_manage_job.incorrect_job_type", + "translation": "No tienes permiso para gestionar este tipo de tarea" + }, + { + "id": "api.plugin.upload.file_too_large.app_error", + "translation": "El tamaño del plugin cargado excede el límite establecido. Este límite puede cambiarse en la Consola del Sistema en Almacenamiento de Archivos > Tamaño Máximo de Archivo" + }, + { + "id": "api.command.execute_command.deleted.error", + "translation": "No es posible ejecutar comandos en un canal eliminado." + }, + { + "id": "api.remote_cluster.create_invite_error", + "translation": "No se pudo crear la invitación al clúster remoto" + }, + { + "id": "api.roles.get_multiple_by_name_too_many.request_error", + "translation": "No es posible obtener tantos roles por nombre. Solo se pueden solicitar {{.MaxNames}} roles a la vez." + }, + { + "id": "api.system.logs.download_bytes_buffer.app_error", + "translation": "No se pudieron escribir los registros en el búfer" + }, + { + "id": "api.remote_cluster.accept_invitation_error", + "translation": "No se pudo aceptar la invitación del clúster remoto" + }, + { + "id": "api.scheduled_posts.feature_disabled", + "translation": "la función de publicaciones programadas está desactivada" + }, + { + "id": "api.scheduled_posts.license_error", + "translation": "La función de publicaciones programadas requiere una licencia" + }, + { + "id": "api.remote_cluster.generate_invite_cluster_is_confirmed", + "translation": "No se puede generar el código de invitación para un clúster confirmado" + }, + { + "id": "api.remote_cluster.base64_decode_error", + "translation": "No se pudo decodificar la cadena base64" + }, + { + "id": "api.remote_cluster.cluster_not_deleted", + "translation": "El clúster remoto no ha sido eliminado" + }, + { + "id": "api.remote_cluster.encrypt_invite_error", + "translation": "No se pudo encriptar la invitación al clúster remoto utilizando la contraseña proporcionada" + }, + { + "id": "api.remote_cluster.get.not_found", + "translation": "Clúster remoto no encontrado" + }, + { + "id": "api.remote_cluster.invite_decrypt_error", + "translation": "No fue posible desencriptar la invitación al clúster remoto con la contraseña suministrada" + }, + { + "id": "api.post.move_thread.disabled.app_error", + "translation": "El movimiento de hilos de conversación está deshabilitado" + }, + { + "id": "api.post.post_priority.persistent_notification_validation_error.request_error", + "translation": "La validación de la notificación persistente ha fallado." + }, + { + "id": "api.post.check_for_out_of_team_mentions.message.multiple", + "translation": "@{{.Usernames}} y @{{.LastUsername}} no fueron notificados por esta mención debido a que no pertenecen a este equipo." + }, + { + "id": "api.post.delete_post.not_enabled.app_error", + "translation": "No es posible borrar la publicación, ya que ServiceSettings.EnableAPIPostDeletion no está activado." + }, + { + "id": "api.post.move_thread.no_permission", + "translation": "No tienes permiso para mover este hilo de conversación." + }, + { + "id": "api.post.post_priority.max_recipients_persistent_notification_post.request_error", + "translation": "La publicación de notificación persistente permite un máximo de {{.MaxRecipients}} destinatarios." + }, + { + "id": "api.post.post_priority.min_recipients_persistent_notification_post.request_error", + "translation": "La publicación de notificación persistente debe tener al menos 1 destinatario." + }, + { + "id": "api.server.hosted_signup_unavailable.error", + "translation": "Portal no disponible para registro de autoalojamiento." + }, + { + "id": "api.system.logs.invalidFilter", + "translation": "Filtro de registros inválido" + }, + { + "id": "api.team.update_team_member_roles.guest.app_error", + "translation": "Actualización de miembro de equipo inválida: Un usuario invitado no puede ser designado como miembro o administrador de equipo, es necesario promoverlo a usuario primero." + }, + { + "id": "api.templates.ip_filters_changed.subTitle", + "translation": "@{{ .InitiatingUsername }} modificó la configuración de filtrado de IP para tu espacio de trabajo en la URL: {{ .SiteURL }}" + }, + { + "id": "api.templates.ip_filters_changed.button", + "translation": "Revisar cambios" + }, + { + "id": "api.post.deduplicate_create_post.cache_error", + "translation": "Error al guardar en caché la publicación después de eliminar duplicados de una solicitud de cliente repetida." + }, + { + "id": "api.team.user.missing_account", + "translation": "No es posible encontrar el usuario." + }, + { + "id": "api.team.update_team_member_roles.user_and_guest.app_error", + "translation": "Actualización de miembro de equipo no válida: Un invitado no puede ser asignado a un único equipo, un Administrador del Sistema debe promover o degradar usuarios a/desde invitados." + }, + { + "id": "api.post.check_for_out_of_team_mentions.message.one", + "translation": "@{{.Username}} no fue notificado por esta mención porque no es miembro de este equipo." + }, + { + "id": "api.shared_channel.get_shared_channel_remotes_error", + "translation": "No se pudieron obtener los remotos del canal compartido" + }, + { + "id": "api.shared_channel.has_remote_error", + "translation": "No se pudo determinar si el canal está compartido con el remoto" + }, + { + "id": "api.shared_channel.invite_remote_to_channel_error", + "translation": "No se pudo invitar remoto al canal" + }, + { + "id": "api.shared_channel.uninvite_remote_to_channel_error", + "translation": "No se pudo anular la invitación de remoto al canal" } ] diff --git a/server/i18n/ja.json b/server/i18n/ja.json index 9224ee25156..3f097626b59 100644 --- a/server/i18n/ja.json +++ b/server/i18n/ja.json @@ -4603,7 +4603,7 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Version 130+" + "translation": "Version 132+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -8567,7 +8567,7 @@ }, { "id": "app.post_reminder_dm", - "translation": "@{{.Username}}からのこのメッセージについてのリマインダーです: {{.SiteURL}}/{{.TeamName}}/pl/{{.PostId}}" + "translation": "このメッセージ(@{{.Username}}により投稿された)についてのリマインダーです: {{.SiteURL}}/{{.TeamName}}/pl/{{.PostId}}" }, { "id": "api.command_marketplace.unsupported.app_error", diff --git a/server/i18n/nb-NO.json b/server/i18n/nb-NO.json index c16fa861f98..378d6698a15 100644 --- a/server/i18n/nb-NO.json +++ b/server/i18n/nb-NO.json @@ -5,19 +5,19 @@ }, { "id": "api.command_remove.permission.app_error", - "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" + "translation": "Du har ikke de nødvendige tillatelsene til å fjerne medlemmet." }, { "id": "api.command_msg.permission.app_error", - "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" + "translation": "Du har ikke de nødvendige tillatelsene til å sende direktemeldinger til denne brukeren." }, { "id": "api.command_channel_rename.permission.app_error", - "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" + "translation": "Du har ikke de nødvendige tillatelsene til å gi kanalen nytt navn." }, { "id": "api.command_channel_purpose.permission.app_error", - "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" + "translation": "Du har ikke de nødvendige tillatelsene til å redigere kanalformålet." }, { "id": "api.command_channel_purpose.desc", @@ -25,7 +25,7 @@ }, { "id": "api.command_channel_header.permission.app_error", - "translation": "Уучлаарай, танд группын товч тайлбар/уриаг өөрчлөх эрх байхгүй байна" + "translation": "Du har ikke de nødvendige tillatelsene til å redigere kanaloverskriften." }, { "id": "api.command_channel_header.desc", @@ -262,5 +262,25 @@ { "id": "api.command_away.success", "translation": "Du er nå borte" + }, + { + "id": "app.post.move_thread.from_another_channel", + "translation": "Denne tråden ble flyttet fra en annen kanal" + }, + { + "id": "api.cloud.cws_webhook_event_missing_error", + "translation": "Webhook-hendelsen ble ikke håndtert. Enten mangler den, eller så er den ikke gyldig." + }, + { + "id": "api.cloud.app_error", + "translation": "Intern feil oppsto under cloud api-forespørsel." + }, + { + "id": "api.cloud.license_error", + "translation": "Lisensen din støtter ikke skyforespørsler." + }, + { + "id": "api.cloud.notify_admin_to_upgrade_error.already_notified", + "translation": "Har allerede varslet admin" } ] diff --git a/server/i18n/nl.json b/server/i18n/nl.json index a1bd65abb82..056f6570c07 100644 --- a/server/i18n/nl.json +++ b/server/i18n/nl.json @@ -4628,11 +4628,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Versie 130+" + "translation": "Versie 132+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Versie 130+" + "translation": "Versie 132+" }, { "id": "web.error.unsupported_browser.learn_more", diff --git a/server/i18n/pl.json b/server/i18n/pl.json index 6500353522a..9aae47dfa6a 100644 --- a/server/i18n/pl.json +++ b/server/i18n/pl.json @@ -4609,11 +4609,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Wersja 130+" + "translation": "Wersja 132+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Wersja 130+" + "translation": "Wersja 132+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -10442,5 +10442,25 @@ { "id": "model.config.is_valid.metrics_client_side_user_ids.app_error", "translation": "Liczba elementów w ClientSideUserIds {{.CurrentLength}} jest wyższa niż maksymalny limit {{.MaxLength}}." + }, + { + "id": "model.channel.is_valid.banner_info.background_color.empty.app_error", + "translation": "Kolor banera kanału nie może być pusty, gdy baner kanału jest włączony" + }, + { + "id": "model.channel.is_valid.banner_info.channel_type.app_error", + "translation": "Baner kanału można skonfigurować tylko na kanałach publicznych i prywatnych" + }, + { + "id": "model.channel.is_valid.banner_info.text.empty.app_error", + "translation": "Tekst informacji o banerze kanału nie może być pusty, gdy baner kanału jest włączony" + }, + { + "id": "model.channel.is_valid.banner_info.text.invalid_length.app_error", + "translation": "Tekst informacji o banerze kanału jest za długi. Maksymalna dozwolona długość to {{.maxLength}} znaków." + }, + { + "id": "api.channel.update_channel.banner_info.channel_type.not_allowed", + "translation": "Baner kanału można skonfigurować tylko na kanałach publicznych i prywatnych." } ] diff --git a/server/i18n/ru.json b/server/i18n/ru.json index d49dc9fec41..3e190da0799 100644 --- a/server/i18n/ru.json +++ b/server/i18n/ru.json @@ -5665,11 +5665,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Версия 130+" + "translation": "Версия 132+" }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Версия 130+" + "translation": "Версия 132+" }, { "id": "web.error.unsupported_browser.min_browser_version.firefox", @@ -10082,5 +10082,21 @@ { "id": "api.command.execute_command.deleted.error", "translation": "Невозможно выполнить команду в удаленном канале." + }, + { + "id": "api.custom_profile_attributes.field_not_found", + "translation": "пытаюсь исправить поле, которого не существует" + }, + { + "id": "api.context.get_session.app_error", + "translation": "Сеанс не найден." + }, + { + "id": "app.custom_profile_attributes.count_property_fields.app_error", + "translation": "Невозможно подсчитать количество полей для группы атрибутов пользовательского профиля" + }, + { + "id": "api.channel.update_channel.banner_info.channel_type.not_allowed", + "translation": "Баннер канала можно настроить только на публичных и приватных каналах." } ] diff --git a/server/i18n/sv.json b/server/i18n/sv.json index 7a39891d0bf..31a799d8ce4 100644 --- a/server/i18n/sv.json +++ b/server/i18n/sv.json @@ -77,11 +77,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Version 130+" + "translation": "Version 132+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Version 130+" + "translation": "Version 132+" }, { "id": "web.error.unsupported_browser.learn_more", diff --git a/server/i18n/uk.json b/server/i18n/uk.json index a051903d0f7..6cc06fc3242 100644 --- a/server/i18n/uk.json +++ b/server/i18n/uk.json @@ -4601,19 +4601,19 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.safari", - "translation": "Version 17.4+" + "translation": "Версія 17.4+" }, { "id": "web.error.unsupported_browser.min_browser_version.firefox", - "translation": "Version 119+" + "translation": "Версія 119+" }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Версія 130+" + "translation": "Версія 132+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Версія 130+" + "translation": "Версія 132+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -10414,5 +10414,53 @@ { "id": "app.post.restore_post_version.not_allowed.app_error", "translation": "Ви не маєте відповідних дозволів." + }, + { + "id": "app.file_info.get_storage_usage.app_error", + "translation": "Не вдалося отримати використання сховища для всіх файлів." + }, + { + "id": "app.file_info.get_by_ids.app_error", + "translation": "Не вдається отримати інформацію про файли за ідентифікаторами для історії редагувань." + }, + { + "id": "app.file_info.undelete_for_post_ids.app_error", + "translation": "Не вдалося відновити вкладені файли." + }, + { + "id": "app.post.restore_post_version.get_single.app_error", + "translation": "Не вдалося отримати стару версію допису." + }, + { + "id": "app.post.restore_post_version.not_valid_post_history_item.app_error", + "translation": "Наданий ідентифікатор історії повідомлень не відповідає елементу історії повідомлень." + }, + { + "id": "ent.message_export.calculate_channel_exports.app_error", + "translation": "Не вдалося обчислити дані експорту каналу." + }, + { + "id": "ent.message_export.job_data_conversion.app_error", + "translation": "Не вдалося перетворити значення з поля даних завдання." + }, + { + "id": "model.channel.is_valid.banner_info.background_color.empty.app_error", + "translation": "Колір банера каналу не може бути порожнім, якщо банер каналу увімкнено" + }, + { + "id": "model.channel.is_valid.banner_info.channel_type.app_error", + "translation": "Банер каналу можна налаштувати лише на публічних та приватних каналах" + }, + { + "id": "model.channel.is_valid.banner_info.text.empty.app_error", + "translation": "Інформаційний текст банера каналу не може бути порожнім, якщо банер каналу увімкнено" + }, + { + "id": "model.channel.is_valid.banner_info.text.invalid_length.app_error", + "translation": "Текст інформації про банер каналу занадто довгий. Максимально допустима довжина - {{.maxLength}} символів." + }, + { + "id": "api.channel.update_channel.banner_info.channel_type.not_allowed", + "translation": "Банер каналу можна налаштувати лише на публічних та приватних каналах." } ] diff --git a/server/platform/services/remotecluster/README.md b/server/platform/services/remotecluster/README.md new file mode 100644 index 00000000000..8f903b67f42 --- /dev/null +++ b/server/platform/services/remotecluster/README.md @@ -0,0 +1,36 @@ +## Remote Cluster Service + +Package `remotecluster` implements Mattermost's "Secured Connections" feature, which enables communication between different Mattermost clusters. Specifically, this package provides: + + ### Service Management: + +- Manages inter-cluster communication via topic-based messages +- Handles connection state (active/inactive) based on cluster leadership +- Maintains concurrent send channels (MaxConcurrentSends = 10) for parallel message processing +- Implements periodic health checks (pings) to monitor remote cluster connectivity + + ### Message Handling: + +- Sends messages using a pool of goroutines to handle concurrent sends while preserving message order per remote +- Uses hash-based routing to ensure messages for the same remote ID go to the same channel +- Supports different types of sends: messages, files, and profile images +- Implements topic-based message routing with listener callbacks + + ### Connection Management: + +- Handles invitation confirmations between clusters +- Maintains HTTP client connections with proper timeouts and transport settings +- Supports connection state listeners for monitoring remote cluster availability +- Implements ping mechanism to verify remote cluster health + + ### Core Features: + +- Topic-based message routing +- File transfer capabilities +- Profile image synchronization +- Invitation system for establishing connections +- Health monitoring via pings +- Concurrent message processing +- Connection state management + +This package is designed to be thread-safe and handles leadership changes in clustered environments, only running active operations on the leader node. \ No newline at end of file diff --git a/server/platform/services/sharedchannel/README.md b/server/platform/services/sharedchannel/README.md new file mode 100644 index 00000000000..1b336762b9c --- /dev/null +++ b/server/platform/services/sharedchannel/README.md @@ -0,0 +1,34 @@ +## Shared Channel Service + +Package `sharedchannel` implements Mattermost's shared channels functionality, for sharing channel content across Mattermost instances/clusters. Here are the key responsibilities: + +### Channel Sharing: + +- Allows channels to be shared between different Mattermost instances/clusters +- Handles inviting remote clusters to shared channels +- Manages permissions and read-only status for shared channels + +### Content Synchronization: + +- Syncs posts, reactions, user profiles, and file attachments between instances +- Handles permalink processing between instances +- Manages user profile images sync +- Maintains sync state and cursors to track what has been synchronized + +### Remote Communication: + +- Processes incoming sync messages from remote clusters +- Sends updates to remote clusters when local changes occur +- Handles connection state changes with remote clusters +- Manages retry logic for failed sync attempts + +### Security: + +- Validates permissions for shared channel operations +- Ensures users can only sync content they have access to +- Verifies remote cluster authenticity +- Sanitizes user data during sync + +The service acts as a bridge between Mattermost instances, allowing users from different instances to collaborate in shared channels while keeping content synchronized across all participating instances. + +This is implemented through a Service struct that handles all the shared channel operations and maintains the synchronization state. It works in conjunction with the RemoteCluster service to handle the actual communication between instances. \ No newline at end of file diff --git a/server/public/model/config.go b/server/public/model/config.go index 43700a026cb..2de87a3a560 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -440,6 +440,7 @@ type ServiceSettings struct { MaximumURLLength *int `access:"environment_file_storage,write_restrictable,cloud_restrictable"` ScheduledPosts *bool `access:"site_posts"` EnableWebHubChannelIteration *bool `access:"write_restrictable,cloud_restrictable"` // telemetry: none + FrameAncestors *string `access:"write_restrictable,cloud_restrictable"` // telemetry: none } var MattermostGiphySdkKey string @@ -967,6 +968,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.EnableWebHubChannelIteration == nil { s.EnableWebHubChannelIteration = NewPointer(false) } + + if s.FrameAncestors == nil { + s.FrameAncestors = NewPointer("") + } } type CacheSettings struct { @@ -1132,7 +1137,7 @@ type ExperimentalSettings struct { ClientSideCertEnable *bool `access:"experimental_features,cloud_restrictable"` ClientSideCertCheck *string `access:"experimental_features,cloud_restrictable"` LinkMetadataTimeoutMilliseconds *int64 `access:"experimental_features,write_restrictable,cloud_restrictable"` - RestrictSystemAdmin *bool `access:"experimental_features,write_restrictable"` + RestrictSystemAdmin *bool `access:"*_read,write_restrictable"` EnableSharedChannels *bool `access:"experimental_features"` // Deprecated: use `ConnectedWorkspacesSettings.EnableSharedChannels` EnableRemoteClusterService *bool `access:"experimental_features"` // Deprecated: use `ConnectedWorkspacesSettings.EnableRemoteClusterService` DisableAppBar *bool `access:"experimental_features"` diff --git a/server/public/model/permission.go b/server/public/model/permission.go index 1525d25642b..744964c4c3f 100644 --- a/server/public/model/permission.go +++ b/server/public/model/permission.go @@ -382,7 +382,7 @@ var PermissionRunView *Permission var PermissionSysconsoleReadProductsBoards *Permission var PermissionSysconsoleWriteProductsBoards *Permission -// General permission that encompasses all system admin functions +// PermissionManageSystem is a general permission that encompasses all system admin functions // in the future this could be broken up to allow access to some // admin functions but not others var PermissionManageSystem *Permission diff --git a/server/public/model/session.go b/server/public/model/session.go index fbdd7d379a7..457c5cd9224 100644 --- a/server/public/model/session.go +++ b/server/public/model/session.go @@ -83,7 +83,7 @@ func (s *Session) Auditable() map[string]interface{} { } } -// Returns true if the session is unrestricted, which should grant it +// IsUnrestricted returns true if the session is unrestricted, which should grant it // with all permissions. This is used for local mode sessions func (s *Session) IsUnrestricted() bool { return s.Local diff --git a/server/public/model/status.go b/server/public/model/status.go index a0c5d073f3d..6c400063fec 100644 --- a/server/public/model/status.go +++ b/server/public/model/status.go @@ -5,6 +5,7 @@ package model import ( "encoding/json" + "time" ) const ( @@ -16,6 +17,9 @@ const ( StatusCacheSize = SessionCacheSize StatusChannelTimeout = 20000 // 20 seconds StatusMinUpdateTime = 120000 // 2 minutes + + // DNDExpiryInterval is how often the job to expire temporary DND statuses runs. + DNDExpiryInterval = 1 * time.Minute ) type Status struct { @@ -24,8 +28,12 @@ type Status struct { Manual bool `json:"manual"` LastActivityAt int64 `json:"last_activity_at"` ActiveChannel string `json:"active_channel,omitempty" db:"-"` - DNDEndTime int64 `json:"dnd_end_time"` - PrevStatus string `json:"-"` + + // DNDEndTime is the time that the user's DND status will expire. Unlike other timestamps in Mattermost, this value + // is in seconds instead of milliseconds. + DNDEndTime int64 `json:"dnd_end_time"` + + PrevStatus string `json:"-"` } func (s *Status) ToJSON() ([]byte, error) { diff --git a/server/public/plugin/client_rpc.go b/server/public/plugin/client_rpc.go index ffe849b844e..a62366927e6 100644 --- a/server/public/plugin/client_rpc.go +++ b/server/public/plugin/client_rpc.go @@ -16,6 +16,7 @@ import ( "log" "net/http" "net/rpc" + "net/url" "os" "reflect" "sync" @@ -59,7 +60,8 @@ func (p *hooksPlugin) Server(b *plugin.MuxBroker) (any, error) { } func (p *hooksPlugin) Client(b *plugin.MuxBroker, client *rpc.Client) (any, error) { - return &hooksRPCClient{client: client, + return &hooksRPCClient{ + client: client, log: p.log, muxBroker: b, apiImpl: p.apiImpl, @@ -171,8 +173,10 @@ func init() { // These enforce compile time checks to make sure types implement the interface // If you are getting an error here, you probably need to run `make pluginapi` to // autogenerate RPC glue code -var _ plugin.Plugin = &hooksPlugin{} -var _ Hooks = &hooksRPCClient{} +var ( + _ plugin.Plugin = &hooksPlugin{} + _ Hooks = &hooksRPCClient{} +) // // Below are special cases for hooks or APIs that can not be auto generated @@ -318,8 +322,7 @@ func (s *hooksRPCServer) OnActivate(args *Z_OnActivateArgs, returns *Z_OnActivat return nil } -type Z_LoadPluginConfigurationArgsArgs struct { -} +type Z_LoadPluginConfigurationArgsArgs struct{} type Z_LoadPluginConfigurationArgsReturns struct { A []byte @@ -358,9 +361,39 @@ func init() { hookNameToId["ServeHTTP"] = ServeHTTPID } +// Using a subset of http.Request prevents a known incompatibility when decoding Go v1.23+ gob-encoded x509.Certificate +// structs from Go v1.22 compiled plugins. These come from http.Request.TLS field (*tls.ConnectionState). +type HTTPRequestSubset struct { + Method string + URL *url.URL + Proto string + ProtoMajor int + ProtoMinor int + Header http.Header + Host string + RemoteAddr string + RequestURI string + Body io.ReadCloser +} + +func (r *HTTPRequestSubset) GetHTTPRequest() *http.Request { + return &http.Request{ + Method: r.Method, + URL: r.URL, + Proto: r.Proto, + ProtoMajor: r.ProtoMajor, + ProtoMinor: r.ProtoMinor, + Header: r.Header, + Host: r.Host, + RemoteAddr: r.RemoteAddr, + RequestURI: r.RequestURI, + Body: r.Body, + } +} + type Z_ServeHTTPArgs struct { ResponseWriterStream uint32 - Request *http.Request + Request *HTTPRequestSubset Context *Context RequestBodyStream uint32 } @@ -402,7 +435,7 @@ func (g *hooksRPCClient) ServeHTTP(c *Context, w http.ResponseWriter, r *http.Re }() } - forwardedRequest := &http.Request{ + forwardedRequest := &HTTPRequestSubset{ Method: r.Method, URL: r.URL, Proto: r.Proto, @@ -447,19 +480,21 @@ func (s *hooksRPCServer) ServeHTTP(args *Z_ServeHTTPArgs, returns *struct{}) err } defer r.Body.Close() + httpReq := r.GetHTTPRequest() + if hook, ok := s.impl.(interface { ServeHTTP(c *Context, w http.ResponseWriter, r *http.Request) }); ok { - hook.ServeHTTP(args.Context, w, r) + hook.ServeHTTP(args.Context, w, httpReq) } else { - http.NotFound(w, r) + http.NotFound(w, httpReq) } return nil } type Z_PluginHTTPArgs struct { - Request *http.Request + Request *HTTPRequestSubset RequestBody []byte } @@ -469,7 +504,7 @@ type Z_PluginHTTPReturns struct { } func (g *apiRPCClient) PluginHTTP(request *http.Request) *http.Response { - forwardedRequest := &http.Request{ + forwardedRequest := &HTTPRequestSubset{ Method: request.Method, URL: request.URL, Proto: request.Proto, @@ -514,7 +549,7 @@ func (s *apiRPCServer) PluginHTTP(args *Z_PluginHTTPArgs, returns *Z_PluginHTTPR if hook, ok := s.impl.(interface { PluginHTTP(request *http.Request) *http.Response }); ok { - response := hook.PluginHTTP(args.Request) + response := hook.PluginHTTP(args.Request.GetHTTPRequest()) responseBody, err := io.ReadAll(response.Body) if err != nil { @@ -743,8 +778,7 @@ type Z_LogDebugArgs struct { B []any } -type Z_LogDebugReturns struct { -} +type Z_LogDebugReturns struct{} func (g *apiRPCClient) LogDebug(msg string, keyValuePairs ...any) { stringifiedPairs := stringifyToObjects(keyValuePairs) @@ -771,8 +805,7 @@ type Z_LogInfoArgs struct { B []any } -type Z_LogInfoReturns struct { -} +type Z_LogInfoReturns struct{} func (g *apiRPCClient) LogInfo(msg string, keyValuePairs ...any) { stringifiedPairs := stringifyToObjects(keyValuePairs) @@ -799,8 +832,7 @@ type Z_LogWarnArgs struct { B []any } -type Z_LogWarnReturns struct { -} +type Z_LogWarnReturns struct{} func (g *apiRPCClient) LogWarn(msg string, keyValuePairs ...any) { stringifiedPairs := stringifyToObjects(keyValuePairs) @@ -827,8 +859,7 @@ type Z_LogErrorArgs struct { B []any } -type Z_LogErrorReturns struct { -} +type Z_LogErrorReturns struct{} func (g *apiRPCClient) LogError(msg string, keyValuePairs ...any) { stringifiedPairs := stringifyToObjects(keyValuePairs) @@ -960,7 +991,7 @@ func init() { type Z_ServeMetricsArgs struct { ResponseWriterStream uint32 - Request *http.Request + Request *HTTPRequestSubset Context *Context RequestBodyStream uint32 } @@ -1002,7 +1033,7 @@ func (g *hooksRPCClient) ServeMetrics(c *Context, w http.ResponseWriter, r *http }() } - forwardedRequest := &http.Request{ + forwardedRequest := &HTTPRequestSubset{ Method: r.Method, URL: r.URL, Proto: r.Proto, @@ -1047,12 +1078,14 @@ func (s *hooksRPCServer) ServeMetrics(args *Z_ServeMetricsArgs, returns *struct{ } defer r.Body.Close() + httpReq := r.GetHTTPRequest() + if hook, ok := s.impl.(interface { ServeMetrics(c *Context, w http.ResponseWriter, r *http.Request) }); ok { - hook.ServeMetrics(args.Context, w, r) + hook.ServeMetrics(args.Context, w, httpReq) } else { - http.NotFound(w, r) + http.NotFound(w, httpReq) } return nil diff --git a/server/public/plugin/utils/test_files_compiler.go b/server/public/plugin/utils/test_files_compiler.go index 904cb4b6e95..626a0a4ce26 100644 --- a/server/public/plugin/utils/test_files_compiler.go +++ b/server/public/plugin/utils/test_files_compiler.go @@ -15,6 +15,18 @@ import ( ) func CompileGo(t *testing.T, sourceCode, outputPath string) { + compileGo(t, "go", sourceCode, outputPath) +} + +func CompileGoVersion(t *testing.T, goVersion, sourceCode, outputPath string) { + var goBin string + if goVersion != "" { + goBin = os.Getenv("GOBIN") + } + compileGo(t, filepath.Join(goBin, "go"+goVersion), sourceCode, outputPath) +} + +func compileGo(t *testing.T, goBin, sourceCode, outputPath string) { dir, err := os.MkdirTemp(".", "") require.NoError(t, err) defer os.RemoveAll(dir) @@ -32,7 +44,7 @@ func CompileGo(t *testing.T, sourceCode, outputPath string) { serverPath := filepath.Dir(filepath.Dir(sourceFile)) out := &bytes.Buffer{} - cmd := exec.Command("go", "build", "-o", outputPath, main) + cmd := exec.Command(goBin, "build", "-o", outputPath, main) cmd.Dir = serverPath cmd.Stdout = out cmd.Stderr = out diff --git a/webapp/channels/package.json b/webapp/channels/package.json index 26ee8d9ce0b..5c77165c366 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -52,7 +52,7 @@ "lodash": "4.17.21", "luxon": "3.3.0", "mark.js": "8.11.1", - "marked": "github:mattermost/marked#2ef7f28cc7718e3f551c4ce9ea75fdd7580c2008", + "marked": "github:mattermost/marked#3b13ba8ddf725327ddf0298361d6d304a021f2d1", "memoize-one": "6.0.0", "moment-timezone": "0.5.38", "p-queue": "7.3.0", @@ -75,7 +75,7 @@ "react-popper": "2.3.0", "react-redux": "7.2.4", "react-router-dom": "5.3.4", - "react-select": "3.0.3", + "react-select": "5.9.0", "react-transition-group": "4.4.5", "react-virtualized-auto-sizer": "1.0.7", "react-window": "1.8.8", @@ -130,7 +130,6 @@ "@types/react-overlays": "1.1.3", "@types/react-redux": "7.1.31", "@types/react-router-dom": "5.3.3", - "@types/react-select": "3.0.19", "@types/react-transition-group": "4.4.5", "@types/react-virtualized-auto-sizer": "1.0.1", "@types/react-window": "1.8.5", diff --git a/webapp/channels/src/actions/views/onboarding_tasks.ts b/webapp/channels/src/actions/views/onboarding_tasks.ts index d20ad07fc14..968ccbb9856 100644 --- a/webapp/channels/src/actions/views/onboarding_tasks.ts +++ b/webapp/channels/src/actions/views/onboarding_tasks.ts @@ -43,8 +43,6 @@ export function openInvitationsModal(timeout = 1): ActionFunc { dispatch(openModal({ modalId: ModalIdentifiers.INVITATION, dialogType: InvitationModal, - dialogProps: { - }, })); }, timeout); return {data: true}; diff --git a/webapp/channels/src/components/admin_console/__snapshots__/schema_admin_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/__snapshots__/schema_admin_settings.test.tsx.snap index 019501bff1a..97bd0e04b2e 100644 --- a/webapp/channels/src/components/admin_console/__snapshots__/schema_admin_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/__snapshots__/schema_admin_settings.test.tsx.snap @@ -422,7 +422,7 @@ exports[`components/admin_console/SchemaAdminSettings should match snapshot with id="SecondSettings.settingj" key="Config_language_SecondSettings.settingj" label="label-j" - noResultText="no-result-j" + noOptionsMessage="no-result-j" onChange={[Function]} selected={Array []} setByEnv={false} diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index ac5f0fed6d1..8178c41fbbc 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -5833,6 +5833,25 @@ const AdminDefinition: AdminDefinitionType = { ], }, }, + embedding: { + url: 'integrations/embedding', + title: defineMessage({id: 'admin.sidebar.embedding', defaultMessage: 'Embedding'}), + isHidden: it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.INTEGRATIONS.CORS)), + schema: { + id: 'EmbeddingSettings', + name: defineMessage({id: 'admin.integrations.embedding', defaultMessage: 'Embedding'}), + settings: [ + { + type: 'text', + key: 'ServiceSettings.FrameAncestors', + label: defineMessage({id: 'admin.customization.frameAncestorTitle', defaultMessage: 'Frame Ancestors:'}), + help_text: defineMessage({id: 'admin.customization.frameAncestorDesc', defaultMessage: 'Allows the Mattermost web client to be embedded in other websites. Enter a space-separated list of domains that are allowed to embed the Mattermost web client. Leave blank to disallow embedding.'}), + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.INTEGRATIONS.CORS)), + }, + ], + }, + }, + }, }, compliance: { diff --git a/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap b/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap index 9bccb1107ce..961d99e17fe 100644 --- a/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.tsx.snap @@ -1121,6 +1121,17 @@ exports[`components/AdminSidebar should match snapshot 1`] = ` /> } /> + + } + /> } /> + + } + /> } /> + + } + /> } /> + + } + /> } /> + + } + /> } /> + + } + /> { - inputRef: RefObject>; + inputRef: RefObject>; constructor(props: Props) { super(props); this.inputRef = createRef(); diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/global_policy_form.tsx b/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/global_policy_form.tsx index d03bbb78e38..3cc7c758f24 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/global_policy_form.tsx +++ b/webapp/channels/src/components/admin_console/data_retention_settings/global_policy_form/global_policy_form.tsx @@ -214,7 +214,7 @@ export default class GlobalPolicyForm extends React.PureComponent inputValue={this.state.messageRetentionInputValue} width={90} exceptionToInput={[FOREVER]} - disabled={this.isMessageRetentionSetByEnv()} + isDisabled={this.isMessageRetentionSetByEnv()} defaultValue={keepForeverOption()} options={[hoursOption(), daysOption(), yearsOption(), keepForeverOption()]} legend={messages.channelAndMessageRetention} @@ -242,7 +242,7 @@ export default class GlobalPolicyForm extends React.PureComponent inputValue={this.state.fileRetentionInputValue} width={90} exceptionToInput={[FOREVER]} - disabled={this.isFileRetentionSetByEnv()} + isDisabled={this.isFileRetentionSetByEnv()} defaultValue={keepForeverOption()} options={[hoursOption(), daysOption(), yearsOption(), keepForeverOption()]} legend={messages.fileRetention} diff --git a/webapp/channels/src/components/admin_console/filter/team_filter_dropdown/team_filter_dropdown.tsx b/webapp/channels/src/components/admin_console/filter/team_filter_dropdown/team_filter_dropdown.tsx index 1b8f6ce504f..62905e564db 100644 --- a/webapp/channels/src/components/admin_console/filter/team_filter_dropdown/team_filter_dropdown.tsx +++ b/webapp/channels/src/components/admin_console/filter/team_filter_dropdown/team_filter_dropdown.tsx @@ -3,7 +3,7 @@ import React, {useEffect, useState} from 'react'; import {useIntl} from 'react-intl'; -import type {ActionMeta, OptionsType, ValueType} from 'react-select'; +import type {ActionMeta, Options, OnChangeValue} from 'react-select'; import AsyncSelect from 'react-select/async'; import type {PagedTeamSearchOpts, Team} from '@mattermost/types/teams'; @@ -28,7 +28,7 @@ export interface Props extends PropsFromRedux { function TeamFilterDropdown(props: Props) { const {formatMessage} = useIntl(); - const [list, setList] = useState>([]); + const [list, setList] = useState>([]); const [pageNumber, setPageNumber] = useState(0); async function loadListInPageNumber(page: number) { @@ -55,7 +55,7 @@ function TeamFilterDropdown(props: Props) { } } - async function searchInList(term: string, callBack: (options: OptionsType<{label: string; value: string}>) => void) { + async function searchInList(term: string) { try { const response = await props.searchTeams(term, {page: 0, per_page: TEAMS_PER_PAGE} as PagedTeamSearchOpts); if (response && response.data && response.data.teams && response.data.teams.length > 0) { @@ -64,13 +64,13 @@ function TeamFilterDropdown(props: Props) { label: team.display_name, })); - callBack(teams); + return teams; } - callBack([]); + return []; } catch (error) { console.error(error); // eslint-disable-line no-console - callBack([]); + return []; } } @@ -78,7 +78,7 @@ function TeamFilterDropdown(props: Props) { loadListInPageNumber(pageNumber); } - function handleOnChange(value: ValueType, actionMeta: ActionMeta) { + function handleOnChange(value: OnChangeValue, actionMeta: ActionMeta) { if (!actionMeta.action) { return; } diff --git a/webapp/channels/src/components/admin_console/list_table/list_table.tsx b/webapp/channels/src/components/admin_console/list_table/list_table.tsx index d6921c686aa..ba67ec4e84c 100644 --- a/webapp/channels/src/components/admin_console/list_table/list_table.tsx +++ b/webapp/channels/src/components/admin_console/list_table/list_table.tsx @@ -10,7 +10,7 @@ import type {DropResult} from 'react-beautiful-dnd'; import {DragDropContext, Draggable, Droppable} from 'react-beautiful-dnd'; import {FormattedMessage, defineMessages, useIntl} from 'react-intl'; import ReactSelect, {components} from 'react-select'; -import type {IndicatorContainerProps, ValueType} from 'react-select'; +import type {IndicatorsContainerProps, OnChangeValue} from 'react-select'; import {DragVerticalIcon} from '@mattermost/compass-icons/components'; @@ -107,7 +107,7 @@ export function ListTable( const selectedPageSize = pageSizeOptions.find((option) => option.value === props.table.getState().pagination.pageSize) || pageSizeOptions[0]; - function handlePageSizeChange(selectedOption: ValueType) { + function handlePageSizeChange(selectedOption: OnChangeValue) { const {value} = selectedOption as PageSizeOption; props.table.setPageSize(Number(value)); } @@ -370,7 +370,7 @@ export function ListTable( ); } -function IndicatorsContainer(props: IndicatorContainerProps) { +function IndicatorsContainer(props: IndicatorsContainerProps) { return ( diff --git a/webapp/channels/src/components/admin_console/multiselect_settings.tsx b/webapp/channels/src/components/admin_console/multiselect_settings.tsx index 54f7504d905..f213226e3a6 100644 --- a/webapp/channels/src/components/admin_console/multiselect_settings.tsx +++ b/webapp/channels/src/components/admin_console/multiselect_settings.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useState, useCallback, useMemo} from 'react'; -import type {ValueType} from 'react-select'; +import type {OnChangeValue} from 'react-select'; import ReactSelect from 'react-select'; import FormError from 'components/form_error'; @@ -23,7 +23,7 @@ interface Props { disabled?: boolean; setByEnv: boolean; helpText?: React.ReactNode; - noResultText?: React.ReactNode; + noOptionsMessage?: React.ReactNode; } const getOptionLabel = ({text}: { text: string}) => text; @@ -37,11 +37,11 @@ const MultiSelectSetting: React.FC = ({ disabled = false, setByEnv, helpText, - noResultText, + noOptionsMessage, }) => { const [error, setError] = useState(false); - const handleChange = useCallback((newValue: ValueType