Merge branch 'master' into MM-62695

This commit is contained in:
Mattermost Build
2025-03-11 22:24:06 +02:00
committed by GitHub
229 changed files with 7088 additions and 4121 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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)
@@ -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', () => {
@@ -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`);
});
});
@@ -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');
});
});
});
@@ -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
@@ -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
@@ -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).
@@ -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');
});
@@ -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
@@ -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
@@ -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();
@@ -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');
});
});
@@ -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});
@@ -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});
@@ -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');
@@ -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');
@@ -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?`);
}
@@ -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');
@@ -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);
});
@@ -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);
@@ -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');
@@ -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"', () => {
@@ -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);
@@ -196,7 +196,7 @@ function setTimezoneDisplayTo(isAutomatic, value) {
});
// # Close Settings modal
cy.get('#accountSettingsHeader > .close').should('be.visible').click();
cy.uiClose();
}
function setTimezoneDisplayToAutomatic(value) {
@@ -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);
@@ -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
@@ -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}');
@@ -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);
});
});
@@ -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}');
@@ -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);
@@ -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();
@@ -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
+12 -4
View File
@@ -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
+1
View File
@@ -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)
})
}
+160 -92
View File
@@ -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)
})
}
+1 -1
View File
@@ -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
}
+24
View File
@@ -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) {
+8
View File
@@ -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")
+49 -21
View File
@@ -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
}
+91
View File
@@ -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{
+1
View File
@@ -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)
@@ -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
}
@@ -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 {
+15 -1
View File
@@ -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
@@ -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))
}
+4 -2
View File
@@ -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
}
@@ -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
}
@@ -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])
})
}
+61 -21
View File
@@ -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)
})
}
}
+46 -21
View File
@@ -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)
@@ -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)
}
@@ -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)
}
+2 -2
View File
@@ -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)
@@ -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
}
@@ -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, "\\")
@@ -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")
@@ -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")
+4 -4
View File
@@ -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)
@@ -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)
}
@@ -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)
}
@@ -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)
})
@@ -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)
}
@@ -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 {
+2 -6
View File
@@ -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,
))
+6 -5
View File
@@ -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"])
})
}
+1 -1
View File
@@ -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")
+23 -3
View File
@@ -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}}."
}
]
+22 -2
View File
@@ -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."
}
]
+365 -53
View File
@@ -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"
}
]
+2 -2
View File
@@ -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",
+25 -5
View File
@@ -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"
}
]
+2 -2
View File
@@ -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",
+22 -2
View File
@@ -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."
}
]
+18 -2
View File
@@ -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": "Баннер канала можно настроить только на публичных и приватных каналах."
}
]
+2 -2
View File
@@ -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",
+52 -4
View File
@@ -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": "Банер каналу можна налаштувати лише на публічних та приватних каналах."
}
]
@@ -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.
@@ -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.
+6 -1
View File
@@ -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"`
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+10 -2
View File
@@ -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) {
+57 -24
View File
@@ -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
@@ -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
+2 -3
View File
@@ -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",
@@ -43,8 +43,6 @@ export function openInvitationsModal(timeout = 1): ActionFunc {
dispatch(openModal({
modalId: ModalIdentifiers.INVITATION,
dialogType: InvitationModal,
dialogProps: {
},
}));
}, timeout);
return {data: true};
@@ -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}
@@ -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: {
@@ -1121,6 +1121,17 @@ exports[`components/AdminSidebar should match snapshot 1`] = `
/>
}
/>
<AdminSidebarSection
definitionKey="integrations.embedding"
key="integrations.embedding"
name="integrations/embedding"
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Embedding"
id="admin.sidebar.embedding"
/>
}
/>
</AdminSidebarCategory>
<AdminSidebarCategory
definitionKey="experimental"
@@ -1815,6 +1826,17 @@ exports[`components/AdminSidebar should match snapshot with workspace optimizati
/>
}
/>
<AdminSidebarSection
definitionKey="integrations.embedding"
key="integrations.embedding"
name="integrations/embedding"
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Embedding"
id="admin.sidebar.embedding"
/>
}
/>
</AdminSidebarCategory>
<AdminSidebarCategory
definitionKey="experimental"
@@ -2564,6 +2586,17 @@ exports[`components/AdminSidebar should match snapshot, not prevent the console
/>
}
/>
<AdminSidebarSection
definitionKey="integrations.embedding"
key="integrations.embedding"
name="integrations/embedding"
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Embedding"
id="admin.sidebar.embedding"
/>
}
/>
</AdminSidebarCategory>
<AdminSidebarCategory
definitionKey="experimental"
@@ -3258,6 +3291,17 @@ exports[`components/AdminSidebar should match snapshot, render plugins without a
/>
}
/>
<AdminSidebarSection
definitionKey="integrations.embedding"
key="integrations.embedding"
name="integrations/embedding"
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Embedding"
id="admin.sidebar.embedding"
/>
}
/>
</AdminSidebarCategory>
<AdminSidebarCategory
definitionKey="experimental"
@@ -4062,6 +4106,17 @@ exports[`components/AdminSidebar should match snapshot, with license (with all f
/>
}
/>
<AdminSidebarSection
definitionKey="integrations.embedding"
key="integrations.embedding"
name="integrations/embedding"
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Embedding"
id="admin.sidebar.embedding"
/>
}
/>
</AdminSidebarCategory>
<AdminSidebarCategory
definitionKey="compliance"
@@ -4987,6 +5042,17 @@ exports[`components/AdminSidebar should match snapshot, with license (without an
/>
}
/>
<AdminSidebarSection
definitionKey="integrations.embedding"
key="integrations.embedding"
name="integrations/embedding"
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Embedding"
id="admin.sidebar.embedding"
/>
}
/>
</AdminSidebarCategory>
<AdminSidebarCategory
definitionKey="compliance"
@@ -5,6 +5,7 @@ import React, {createRef} from 'react';
import type {RefObject} from 'react';
import type {WrappedComponentProps} from 'react-intl';
import {FormattedMessage, defineMessages, injectIntl} from 'react-intl';
import type {SelectInstance} from 'react-select';
import ReactSelect from 'react-select';
import type {AdminConfig} from '@mattermost/types/config';
@@ -81,7 +82,7 @@ export const searchableStrings = [
];
class DataRetentionSettings extends React.PureComponent<Props, State> {
inputRef: RefObject<ReactSelect<OptionType>>;
inputRef: RefObject<SelectInstance<OptionType>>;
constructor(props: Props) {
super(props);
this.inputRef = createRef();
@@ -214,7 +214,7 @@ export default class GlobalPolicyForm extends React.PureComponent<Props, State>
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<Props, State>
inputValue={this.state.fileRetentionInputValue}
width={90}
exceptionToInput={[FOREVER]}
disabled={this.isFileRetentionSetByEnv()}
isDisabled={this.isFileRetentionSetByEnv()}
defaultValue={keepForeverOption()}
options={[hoursOption(), daysOption(), yearsOption(), keepForeverOption()]}
legend={messages.fileRetention}
@@ -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<OptionsType<TeamSelectOption>>([]);
const [list, setList] = useState<Options<TeamSelectOption>>([]);
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<TeamSelectOption>, actionMeta: ActionMeta<TeamSelectOption>) {
function handleOnChange(value: OnChangeValue<TeamSelectOption, true>, actionMeta: ActionMeta<TeamSelectOption>) {
if (!actionMeta.action) {
return;
}
@@ -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<TableType extends TableMandatoryTypes>(
const selectedPageSize = pageSizeOptions.find((option) => option.value === props.table.getState().pagination.pageSize) || pageSizeOptions[0];
function handlePageSizeChange(selectedOption: ValueType<PageSizeOption>) {
function handlePageSizeChange(selectedOption: OnChangeValue<PageSizeOption, false>) {
const {value} = selectedOption as PageSizeOption;
props.table.setPageSize(Number(value));
}
@@ -370,7 +370,7 @@ export function ListTable<TableType extends TableMandatoryTypes>(
);
}
function IndicatorsContainer(props: IndicatorContainerProps<PageSizeOption>) {
function IndicatorsContainer(props: IndicatorsContainerProps<PageSizeOption>) {
return (
<components.IndicatorsContainer {...props}>
<i className='icon icon-chevron-down'/>
@@ -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<Props> = ({
disabled = false,
setByEnv,
helpText,
noResultText,
noOptionsMessage,
}) => {
const [error, setError] = useState(false);
const handleChange = useCallback((newValue: ValueType<Option>) => {
const handleChange = useCallback((newValue: OnChangeValue<Option, true>) => {
const updatedValues = newValue ? (newValue as Option[]).map((n) => {
return n.value;
}) : [];
@@ -80,9 +80,9 @@ const MultiSelectSetting: React.FC<Props> = ({
getOptionLabel={getOptionLabel}
options={values}
delimiter={','}
clearable={false}
isClearable={false}
isDisabled={disabled || setByEnv}
noResultsText={noResultText}
noOptionsMessage={() => noOptionsMessage}
onChange={handleChange}
value={calculatedValue}
/>

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