Merge branch 'master' into MM-51456-update-overage-notices

Этот коммит содержится в:
Mattermost Build
2023-04-20 17:43:27 +03:00
коммит произвёл GitHub
родитель c8008142d7 b5b4749da5
Коммит dc4482e3e2
171 изменённых файлов: 4399 добавлений и 1026 удалений

10
.github/workflows/channels-ci.yml поставляемый
Просмотреть файл

@@ -83,6 +83,16 @@ jobs:
npm run mmjstool -- i18n clean-empty --webapp-dir ./src --mobile-dir /tmp/fake-mobile-dir --check
npm run mmjstool -- i18n check-empty-src --webapp-dir ./src --mobile-dir /tmp/fake-mobile-dir
rm -rf tmp
- name: ci/lint-boards
working-directory: webapp/boards
run: |
npm run i18n-extract
git --no-pager diff --exit-code i18n/en.json || (echo "Please run \"cd webapp/boards && npm run i18n-extract\" and commit the changes in webapp/boards/i18n/en.json." && exit 1)
- name: ci/lint-playbooks
working-directory: webapp/playbooks
run: |
npm run i18n-extract
git --no-pager diff --exit-code i18n/en.json || (echo "Please run \"cd webapp/playbooks && npm run i18n-extract\" and commit the changes in webapp/playbooks/i18n/en.json." && exit 1)
check-types:
runs-on: ubuntu-22.04
defaults:

Просмотреть файл

@@ -5,3 +5,6 @@
/webapp/package-lock.json @mattermost/web-platform
/webapp/platform/*/package.json @mattermost/web-platform
/webapp/scripts @mattermost/web-platform
/server/channels/db/migrations @mattermost/server-platform
/server/boards/services/store/sqlstore/migrations @mattermost/server-platform
/server/playbooks/server/sqlstore/migrations @mattermost/server-platform

Просмотреть файл

@@ -98,6 +98,72 @@ describe('Create and delete board / card', () => {
cy.findByText('for testing purposes only').should('be.visible');
});
it('MM-T4276 Set up Board emoji', () => {
cy.visit('/boards');
// # Create an empty board and change tile to Testing
cy.findByText('Create an empty board').should('exist').click({force: true});
cy.get('.BoardComponent').should('exist');
// # Change Title
cy.findByPlaceholderText('Untitled board').should('be.visible').wait(timeouts.HALF_SEC);
// * Assert that the title is changed to "testing"
cy.findByPlaceholderText('Untitled board').
clear().
type('Testing').
type('{enter}').
should('have.value', 'Testing');
// # "Add icon" and "Show description" options appear
cy.findByText('Add icon').should('exist');
cy.findByText('show description').should('exist');
// # Click on "Add icon"
cy.findByText('Add icon').should('exist').click({force: true});
// * Assert that a random emoji is selected and added at the beginning of the board title
cy.get('.IconSelector').should('exist');
// # Click on the emoji next to the board title
cy.get('.IconSelector .MenuWrapper').should('exist').click({force: true});
// * Assert that Dropdown menu with 3 options appears
cy.findByText('Random').should('exist');
cy.findByText('Pick icon').should('exist');
cy.findByText('Remove icon').should('exist');
// # Hover your mouse over the "Pick Icon" option
cy.findByText('Pick icon').trigger('mouseover');
// * Assert that emoji picker menu appears
cy.get('.IconSelector .menu-contents').should('exist');
// # Click on the emoji from the picker
cy.get('.EmojiPicker').should('exist').and('be.visible').within(() => {
// # Click on the emoji
cy.get("[aria-label='😀, grinning']").should('exist');
cy.get("[aria-label='😀, grinning']").eq(0).click({force: true});
});
// * Assert that Selected emoji is now displayed next to the board title
cy.get('.IconSelector span').contains('😀');
// # Click on the emoji next to the board title
cy.get('.IconSelector .MenuWrapper').should('exist').click({force: true});
// * Assert that Dropdown menu with 3 options appears
cy.findByText('Random').should('exist');
cy.findByText('Pick icon').should('exist');
cy.findByText('Remove icon').should('exist');
// # Click "Remove icon"
cy.findByText('Remove icon').click({force: true});
// * Assert that Icon next to the board title is removed
cy.get('.IconSelector').should('not.exist');
});
it('MM-T5397 Can create and delete a board and a card', () => {
// Visit a page and create new empty board
cy.visit('/boards');

Просмотреть файл

@@ -2,8 +2,21 @@
#### 1. Start local server in a separate terminal.
```
# Typically run the local server with:
cd server && make run
# Or build and distribute webapp including channels, boards and playbooks
# so that their product URLs do not rely on Webpack dev server.
# Especially important when running test inside the Playwright's docker container.
cd webapp && make dist
cd server && make run-server
```
#### 2. Install dependencies and run the test.
Note: If you're using Node.js version 18 and above, you may need to set `NODE_OPTIONS='--no-experimental-fetch'`.
```
# Install npm packages
npm i
@@ -32,14 +45,16 @@ npm run test
Change to root directory, run docker container
```
docker run -it --rm -v "$(pwd):/mattermost/" --ipc=host mcr.microsoft.com/playwright:v1.30.0-focal /bin/bash
docker run -it --rm -v "$(pwd):/mattermost/" --ipc=host mcr.microsoft.com/playwright:v1.32.0-focal /bin/bash
```
#### 2. Inside the docker container
```
export NODE_OPTIONS='--no-experimental-fetch'
export PW_BASE_URL=http://host.docker.internal:8065
cd mattermost/e2e/playwright
export PW_HEADLESS=true
cd mattermost/e2e-tests/playwright
# Install npm packages. Use "npm ci" to match the automated environment
npm ci

973
e2e-tests/playwright/package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -1,33 +1,35 @@
{
"scripts": {
"test": "PW_SNAPSHOT_ENABLE=true playwright test",
"percy": "PERCY_TOKEN=$PERCY_TOKEN PW_PERCY_ENABLE=true percy exec -- playwright test --project=chrome --project=iphone --project=ipad",
"test": "cross-env PW_SNAPSHOT_ENABLE=true playwright test",
"percy": "cross-env PERCY_TOKEN=$PERCY_TOKEN PW_PERCY_ENABLE=true percy exec -- playwright test --project=chrome --project=iphone --project=ipad",
"tsc": "tsc -b",
"lint": "eslint . --ext .js,.ts",
"prettier": "prettier --write .",
"check": "npm run tsc && npm run lint && npm run prettier",
"codegen": "playwright codegen $PW_BASE_URL",
"test-slomo": "PW_SNAPSHOT_ENABLE=true PW_HEADLESS=false PW_SLOWMO=1000 playwright test",
"codegen": "cross-env playwright codegen $PW_BASE_URL",
"playwright-ui": "playwright test --ui",
"test-slomo": "cross-env PW_SNAPSHOT_ENABLE=true PW_SLOWMO=1000 playwright test",
"show-report": "npx playwright show-report"
},
"dependencies": {
"@percy/cli": "1.18.0",
"@percy/cli": "1.23.0",
"@percy/playwright": "1.0.4",
"@playwright/test": "1.32.3",
"async-wait-until": "2.0.12",
"chalk": "4.1.2",
"deepmerge": "4.3.0",
"deepmerge": "4.3.1",
"dotenv": "16.0.3",
"form-data": "4.0.0",
"isomorphic-unfetch": "4.0.2",
"uuid": "9.0.0"
},
"devDependencies": {
"@types/uuid": "9.0.0",
"@typescript-eslint/eslint-plugin": "5.51.0",
"@typescript-eslint/parser": "5.51.0",
"eslint": "8.34.0",
"prettier": "2.8.4",
"typescript": "4.9.5"
"@types/uuid": "9.0.1",
"@typescript-eslint/eslint-plugin": "5.59.0",
"@typescript-eslint/parser": "5.59.0",
"cross-env": "7.0.3",
"eslint": "8.38.0",
"prettier": "2.8.7",
"typescript": "5.0.4"
}
}

Просмотреть файл

@@ -35,7 +35,7 @@
# - Default to "false" if not set.
# 12. PW_HEADLESS
# - Default to "true" if not set. Set to false to run test in head mode.
# - Default to "false" or headless mode if not set. Set to true to run test in headed mode.
# 13. PW_SLOWMO
# - Default to "0" if not set which means normal test speed run. Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going on.

Просмотреть файл

@@ -3,16 +3,18 @@
import {writeFile} from 'node:fs/promises';
import {request, Browser} from '@playwright/test';
import {request, Browser, BrowserContext} from '@playwright/test';
import {UserProfile} from '@mattermost/types/users';
import testConfig from '@e2e-test.config';
export class TestBrowser {
readonly browser: Browser;
context: BrowserContext | null;
constructor(browser: Browser) {
this.browser = browser;
this.context = null;
}
async login(user: UserProfile | null) {
@@ -27,8 +29,16 @@ export class TestBrowser {
const context = await this.browser.newContext(options);
const page = await context.newPage();
this.context = context;
return {context, page};
}
async close() {
if (this.context) {
await this.context.close();
}
}
}
export async function loginByAPI(loginId: string, password: string, token = '', ldapOnly = false) {

Просмотреть файл

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// This is based on "packages/client/src/client4.ts". Modified for node client.
// This is based on "webapp/platform/client/src/client4.ts". Modified for node client.
// Update should be made in comparison with the base Client4.
import fs from 'node:fs';
@@ -134,7 +134,7 @@ export default class Client extends Client4 {
// *****************************************************************************
// Boards client
// based on https://github.com/mattermost/focalboard/blob/main/webapp/src/octoClient.ts
// based on "webapp/boards/src/octoClient.ts"
// *****************************************************************************
async patchUserConfig(userID: string, patch: UserConfigPatch): Promise<UserPreference[] | undefined> {

Просмотреть файл

@@ -318,7 +318,6 @@ const defaultServerConfig: AdminConfig = {
LoginButtonColor: '#0000',
LoginButtonBorderColor: '#2389D7',
LoginButtonTextColor: '#2389D7',
EnableInactivityEmail: true,
},
RateLimitSettings: {
Enable: false,
@@ -532,6 +531,7 @@ const defaultServerConfig: AdminConfig = {
EnableRemoteClusterService: false,
EnableAppBar: false,
PatchPluginsReactDOM: false,
DisableRefetchingOnBrowserFocus: false,
},
AnalyticsSettings: {
MaxUsersForStatistics: 2500,
@@ -621,12 +621,6 @@ const defaultServerConfig: AdminConfig = {
'com.mattermost.nps': {
Enable: true,
},
focalboard: {
Enable: true,
},
playbooks: {
Enable: true,
},
},
EnableMarketplace: true,
EnableRemoteMarketplace: true,
@@ -670,13 +664,11 @@ const defaultServerConfig: AdminConfig = {
BoardsFeatureFlags: '',
BoardsDataRetention: false,
NormalizeLdapDNs: false,
EnableInactivityCheckJob: true,
UseCaseOnboarding: true,
GraphQL: false,
InsightsEnabled: true,
CommandPalette: false,
SendWelcomePost: true,
WorkTemplate: false,
WorkTemplate: true,
PostPriority: true,
WysiwygEditor: false,
PeopleProduct: false,
@@ -685,7 +677,9 @@ const defaultServerConfig: AdminConfig = {
ThreadsEverywhere: false,
GlobalDrafts: true,
OnboardingTourTips: true,
DeprecateCloudFree: false,
AppsSidebarCategory: false,
CloudReverseTrial: false,
},
ImportSettings: {
Directory: './import',

Просмотреть файл

@@ -3,7 +3,9 @@
import path from 'node:path';
import {expect} from '@playwright/test';
import chalk from 'chalk';
import {ClientError} from '@mattermost/client/client4';
import {PreferenceType} from '@mattermost/types/preferences';
import testConfig from '@e2e-test.config';
@@ -77,10 +79,21 @@ export async function initSetup({
offTopicUrl: getUrl(team.name, 'off-topic'),
townSquareUrl: getUrl(team.name, 'town-square'),
};
} catch (err) {
} catch (error) {
// log an error for debugging
// eslint-disable-next-line no-console
console.log(err);
const err = error as ClientError;
if (err.message === 'Could not parse multipart form.') {
// eslint-disable-next-line no-console
console.log(chalk.yellow(`node version: ${process.version}\nNODE_OPTIONS: ${process.env.NODE_OPTIONS}`));
// eslint-disable-next-line no-console
console.log(
chalk.green(
`This failed due to the experimental fetch support in Node.js starting v18.0.0.\nYou may set environment variable: "export NODE_OPTIONS='--no-experimental-fetch'", then try again.'`
)
);
}
expect(err, 'Should not throw an error').toBeFalsy();
throw err;
}

Просмотреть файл

@@ -18,6 +18,7 @@ export const test = base.extend<ExtendedFixtures>({
pw: async ({browser}, use) => {
const pw = new PlaywrightExtended(browser);
await use(pw);
await pw.testBrowser.close();
},
// eslint-disable-next-line no-empty-pattern
pages: async ({}, use) => {

Просмотреть файл

@@ -16,7 +16,7 @@ export default class GlobalHeader {
async switchProduct(name: string) {
await this.productSwitchMenu.click();
await this.container.getByRole('link', {name: `${name}`}).click();
await this.container.getByRole('link', {name}).click();
}
async toBeVisible(name: string) {

Просмотреть файл

@@ -55,7 +55,7 @@ const config: TestConfig = {
// CI
isCI: !!process.env.CI,
// Playwright
headless: parseBool(process.env.PW_HEADLESS, false),
headless: parseBool(process.env.PW_HEADLESS, true),
slowMo: parseNumber(process.env.PW_SLOWMO, 0),
workers: parseNumber(process.env.PW_WORKERS, 1),
// Visual tests

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 129 KiB

После

Ширина:  |  Высота:  |  Размер: 127 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 184 KiB

После

Ширина:  |  Высота:  |  Размер: 182 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 246 KiB

После

Ширина:  |  Высота:  |  Размер: 238 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 38 KiB

После

Ширина:  |  Высота:  |  Размер: 38 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 65 KiB

После

Ширина:  |  Высота:  |  Размер: 63 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 85 KiB

После

Ширина:  |  Высота:  |  Размер: 84 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 77 KiB

После

Ширина:  |  Высота:  |  Размер: 71 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 113 KiB

После

Ширина:  |  Высота:  |  Размер: 106 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 183 KiB

После

Ширина:  |  Высота:  |  Размер: 167 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 178 KiB

После

Ширина:  |  Высота:  |  Размер: 148 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 105 KiB

После

Ширина:  |  Высота:  |  Размер: 105 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 169 KiB

После

Ширина:  |  Высота:  |  Размер: 170 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 212 KiB

После

Ширина:  |  Высота:  |  Размер: 213 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 157 KiB

После

Ширина:  |  Высота:  |  Размер: 156 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 150 KiB

После

Ширина:  |  Высота:  |  Размер: 150 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 147 KiB

После

Ширина:  |  Высота:  |  Размер: 147 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 270 KiB

После

Ширина:  |  Высота:  |  Размер: 271 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 312 KiB

После

Ширина:  |  Высота:  |  Размер: 312 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 241 KiB

После

Ширина:  |  Высота:  |  Размер: 241 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 276 KiB

После

Ширина:  |  Высота:  |  Размер: 277 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 297 KiB

После

Ширина:  |  Высота:  |  Размер: 297 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 230 KiB

После

Ширина:  |  Высота:  |  Размер: 230 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 156 KiB

После

Ширина:  |  Высота:  |  Размер: 165 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 160 KiB

После

Ширина:  |  Высота:  |  Размер: 170 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 285 KiB

После

Ширина:  |  Высота:  |  Размер: 296 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 368 KiB

После

Ширина:  |  Высота:  |  Размер: 411 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 276 KiB

После

Ширина:  |  Высота:  |  Размер: 355 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 280 KiB

После

Ширина:  |  Высота:  |  Размер: 291 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 348 KiB

После

Ширина:  |  Высота:  |  Размер: 393 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 256 KiB

После

Ширина:  |  Высота:  |  Размер: 266 KiB

Просмотреть файл

@@ -138,7 +138,7 @@ TEMPLATES_DIR=templates
# Plugins Packages
PLUGIN_PACKAGES ?= mattermost-plugin-antivirus-v0.1.2
PLUGIN_PACKAGES += mattermost-plugin-autolink-v1.2.2
PLUGIN_PACKAGES += mattermost-plugin-autolink-v1.4.0
PLUGIN_PACKAGES += mattermost-plugin-aws-SNS-v1.2.0
PLUGIN_PACKAGES += mattermost-plugin-calls-v0.15.1
PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.0.0

Просмотреть файл

@@ -70,7 +70,10 @@ func (s *SQLStore) getMigrationConnection() (*sql.DB, error) {
}
*settings.DriverName = s.dbType
db := sqlstore.SetupConnection("master", connectionString, &settings)
db, err := sqlstore.SetupConnection("master", connectionString, &settings, sqlstore.DBPingAttempts)
if err != nil {
return nil, err
}
return db, nil
}

Просмотреть файл

@@ -8,7 +8,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV PATH="/mattermost/bin:${PATH}"
ARG PUID=2000
ARG PGID=2000
ARG MM_PACKAGE="https://releases.mattermost.com/7.9.2/mattermost-7.9.2-linux-amd64.tar.gz?src=docker"
ARG MM_PACKAGE="https://releases.mattermost.com/7.10.0/mattermost-7.10.0-linux-amd64.tar.gz?src=docker"
# # Install needed packages and indirect dependencies
RUN apt-get update \

Просмотреть файл

@@ -1093,12 +1093,6 @@ func CheckErrorMessage(tb testing.TB, err error, message string) {
require.Equalf(tb, message, appError.Message, "incorrect error message, actual: %s, expected: %s", appError.Id, message)
}
func CheckStartsWith(tb testing.TB, value, prefix, message string) {
tb.Helper()
require.True(tb, strings.HasPrefix(value, prefix), message, value)
}
// Similar to s3.New() but allows initialization of signature v2 or signature v4 client.
// If signV2 input is false, function always returns signature v4.
//

Просмотреть файл

@@ -859,13 +859,22 @@ func TestGetPublicChannelsForTeam(t *testing.T) {
require.NoError(t, err)
require.Len(t, channels, 4, "wrong path")
for i, c := range channels {
var foundPublicChannel1, foundPublicChannel2 bool
for _, c := range channels {
// check all channels included are open
require.Equal(t, model.ChannelTypeOpen, c.Type, "should include open channel only")
// only check the created 2 public channels
require.False(t, i < 2 && !(c.DisplayName == publicChannel1.DisplayName || c.DisplayName == publicChannel2.DisplayName), "should match public channel display name")
switch c.DisplayName {
case publicChannel1.DisplayName:
foundPublicChannel1 = true
case publicChannel2.DisplayName:
foundPublicChannel2 = true
}
}
require.True(t, foundPublicChannel1, "failed to find publicChannel1")
require.True(t, foundPublicChannel2, "failed to find publicChannel2")
privateChannel := th.CreatePrivateChannel()
channels, _, err = client.GetPublicChannelsForTeam(team.Id, 0, 100, "")
@@ -1135,9 +1144,14 @@ func TestGetAllChannels(t *testing.T) {
require.NoError(t, err)
beforeCount := len(channels)
firstChannel := channels[0].Channel
deletedChannel := channels[0].Channel
_, err = client.DeleteChannel(firstChannel.Id)
// Never try to delete the default channel
if deletedChannel.Name == "town-square" {
deletedChannel = channels[1].Channel
}
_, err = client.DeleteChannel(deletedChannel.Id)
require.NoError(t, err)
channels, _, err = client.GetAllChannels(0, 10000, "")
@@ -1147,7 +1161,7 @@ func TestGetAllChannels(t *testing.T) {
}
require.NoError(t, err)
require.Len(t, channels, beforeCount-1)
require.NotContains(t, ids, firstChannel.Id)
require.NotContains(t, ids, deletedChannel.Id)
channels, _, err = client.GetAllChannelsIncludeDeleted(0, 10000, "")
ids = []string{}
@@ -1156,7 +1170,7 @@ func TestGetAllChannels(t *testing.T) {
}
require.NoError(t, err)
require.True(t, len(channels) > beforeCount)
require.Contains(t, ids, firstChannel.Id)
require.Contains(t, ids, deletedChannel.Id)
})
_, resp, err := client.GetAllChannels(0, 20, "")

Просмотреть файл

@@ -103,6 +103,7 @@ func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) {
DNS: "",
LastInvoice: &model.Invoice{},
DelinquentSince: subscription.DelinquentSince,
BillingType: "",
}
}

Просмотреть файл

@@ -323,7 +323,14 @@ func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) {
defer c.LogAuditRec(auditRec)
audit.AddEventParameterAuditable(auditRec, "command_args", &commandArgs)
// checks that user is a member of the specified channel, and that they have permission to use slash commands in it
// Checks that user is a member of the specified channel, and that they have permission to create a post in it.
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), commandArgs.ChannelId, model.PermissionCreatePost) {
c.SetPermissionError(model.PermissionCreatePost)
return
}
// For compatibility reasons, PermissionCreatePost is also checked.
// TODO: Remove in 8.0: https://mattermost.atlassian.net/browse/MM-51274
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), commandArgs.ChannelId, model.PermissionUseSlashCommands) {
c.SetPermissionError(model.PermissionUseSlashCommands)
return
@@ -343,6 +350,13 @@ func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) {
// if the slash command was used in a DM or GM, ensure that the user is a member of the specified team, so that
// they can't just execute slash commands against arbitrary teams
if c.AppContext.Session().GetTeamByTeamId(commandArgs.TeamId) == nil {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionCreatePost) {
c.SetPermissionError(model.PermissionCreatePost)
return
}
// For compatibility reasons, PermissionCreatePost is also checked.
// TODO: Remove in 8.0: https://mattermost.atlassian.net/browse/MM-51274
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionUseSlashCommands) {
c.SetPermissionError(model.PermissionUseSlashCommands)
return

Просмотреть файл

@@ -14,6 +14,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/server/v8/channels/app/request"
"github.com/mattermost/mattermost-server/server/v8/model"
"github.com/mattermost/mattermost-server/server/v8/platform/shared/mlog"
)
@@ -1065,3 +1066,80 @@ func TestExecuteCommandInTeamUserIsNotOn(t *testing.T) {
require.Error(t, err)
CheckForbiddenStatus(t, resp)
}
func TestExecuteCommandReadOnly(t *testing.T) {
th := Setup(t).InitBasic()
ctx := request.EmptyContext(th.TestLogger)
defer th.TearDown()
client := th.Client
enableCommands := *th.App.Config().ServiceSettings.EnableCommands
allowedInternalConnections := *th.App.Config().ServiceSettings.AllowedUntrustedInternalConnections
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableCommands = &enableCommands })
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.AllowedUntrustedInternalConnections = &allowedInternalConnections
})
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCommands = true })
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1"
})
expectedCommandResponse := &model.CommandResponse{
Text: "test post command response",
ResponseType: model.CommandResponseTypeInChannel,
Type: "custom_test",
Props: map[string]any{"someprop": "somevalue"},
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
r.ParseForm()
require.Equal(t, th.BasicTeam.Name, r.FormValue("team_domain"))
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(expectedCommandResponse); err != nil {
th.TestLogger.Warn("Error while writing response", mlog.Err(err))
}
}))
defer ts.Close()
// create a slash command on that team
postCmd := &model.Command{
CreatorId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
URL: ts.URL,
Method: model.CommandMethodPost,
Trigger: "postcommand",
}
_, appErr := th.App.CreateCommand(postCmd)
require.Nil(t, appErr, "failed to create post command")
// Confirm that the command works when the channel is not read only
_, resp, err := client.ExecuteCommandWithTeam(th.BasicChannel.Id, th.BasicChannel.TeamId, "/postcommand")
require.NoError(t, err)
CheckOKStatus(t, resp)
// Enable Enterprise features
th.App.Srv().SetLicense(model.NewTestLicense())
th.App.SetPhase2PermissionsMigrationStatus(true)
_, appErr = th.App.PatchChannelModerationsForChannel(
ctx,
th.BasicChannel,
[]*model.ChannelModerationPatch{{
Name: &model.PermissionCreatePost.Id,
Roles: &model.ChannelModeratedRolesPatch{
Guests: model.NewBool(false),
Members: model.NewBool(false),
},
}})
require.Nil(t, appErr)
// Confirm that the command fails when the channel is read only
_, resp, err = client.ExecuteCommandWithTeam(th.BasicChannel.Id, th.BasicChannel.TeamId, "/postcommand")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
}

Просмотреть файл

@@ -15,7 +15,6 @@ import (
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -790,6 +789,12 @@ func TestGetFileHeaders(t *testing.T) {
t.Skip("skipping because no file driver is enabled")
}
CheckStartsWith := func(tb testing.TB, value, prefix, message string) {
tb.Helper()
require.True(tb, strings.HasPrefix(value, prefix), fmt.Sprintf("%s: %s", message, value))
}
testHeaders := func(data []byte, filename string, expectedContentType string, getInline bool, loadFile bool) func(*testing.T) {
return func(t *testing.T) {
if loadFile {
@@ -832,11 +837,8 @@ func TestGetFileHeaders(t *testing.T) {
t.Run("txt", testHeaders(data, "test.txt", "text/plain", false, false))
t.Run("html", testHeaders(data, "test.html", "text/plain", false, false))
t.Run("js", testHeaders(data, "test.js", "text/plain", false, false))
if os.Getenv("IS_CI") == "true" {
t.Run("go", testHeaders(data, "test.go", "application/octet-stream", false, false))
} else if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
t.Run("go", testHeaders(data, "test.go", "text/x-go; charset=utf-8", false, false))
}
// *.go are categorized differently by different platforms
// t.Run("go", testHeaders(data, "test.go", "text/x-go; charset=utf-8", false, false))
t.Run("zip", testHeaders(data, "test.zip", "application/zip", false, false))
// Not every platform can recognize these
//t.Run("exe", testHeaders(data, "test.exe", "application/x-ms", false))

Просмотреть файл

@@ -10,7 +10,6 @@ import (
"fmt"
"io"
"net/http"
"reflect"
"time"
"github.com/pkg/errors"
@@ -32,6 +31,8 @@ func (api *API) InitHostedCustomer() {
api.BaseRoutes.HostedCustomer.Handle("/customer", api.APISessionRequired(selfHostedCustomer)).Methods("POST")
// POST /api/v4/hosted_customer/confirm
api.BaseRoutes.HostedCustomer.Handle("/confirm", api.APISessionRequired(selfHostedConfirm)).Methods("POST")
// POST /api.v4/hosted_customer/confirm-expand
api.BaseRoutes.HostedCustomer.Handle("/confirm-expand", api.APISessionRequired(selfHostedConfirmExpand)).Methods("POST")
// GET /api/v4/hosted_customer/invoices
api.BaseRoutes.HostedCustomer.Handle("/invoices", api.APISessionRequired(selfHostedInvoices)).Methods("GET")
// GET /api/v4/hosted_customer/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf
@@ -172,6 +173,7 @@ func selfHostedConfirm(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = userErr
return
}
confirmResponse, err := c.App.Cloud().ConfirmSelfHostedSignup(confirm, user.Email)
if err != nil {
if confirmResponse != nil {
@@ -185,9 +187,8 @@ func selfHostedConfirm(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
license, err := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License))
// dealing with an AppError
if !(reflect.ValueOf(err).Kind() == reflect.Ptr && reflect.ValueOf(err).IsNil()) {
license, appErr := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License))
if appErr != nil {
if confirmResponse != nil {
c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id)
}
@@ -325,3 +326,80 @@ func handleSubscribeToNewsletter(c *Context, w http.ResponseWriter, r *http.Requ
ReturnStatusOK(w)
}
func selfHostedConfirmExpand(c *Context, w http.ResponseWriter, r *http.Request) {
const where = "Api4.selfHostedConfirmExpand"
ensureSelfHostedAdmin(c, where)
if c.Err != nil {
return
}
if !checkSelfHostedPurchaseEnabled(c) {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
var confirm model.SelfHostedConfirmPaymentMethodRequest
err = json.Unmarshal(bodyBytes, &confirm)
if err != nil {
c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
user, userErr := c.App.GetUser(c.AppContext.Session().UserId)
if userErr != nil {
c.Err = userErr
return
}
confirmResponse, err := c.App.Cloud().ConfirmSelfHostedExpansion(confirm, user.Email)
if err != nil {
if confirmResponse != nil {
c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id)
}
if err.Error() == fmt.Sprintf("%d", http.StatusUnprocessableEntity) {
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusUnprocessableEntity).Wrap(err)
return
}
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
license, appErr := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License))
// dealing with an AppError
if appErr != nil {
if confirmResponse != nil {
c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id)
}
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
clientResponse, err := json.Marshal(model.SelfHostedSignupConfirmClientResponse{
License: utils.GetClientLicense(license),
Progress: confirmResponse.Progress,
})
if err != nil {
if confirmResponse != nil {
c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id)
}
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
go func() {
err := c.App.Cloud().ConfirmSelfHostedSignupLicenseApplication()
if err != nil {
c.Logger.Warn("Unable to confirm license application", mlog.Err(err))
}
}()
_, _ = w.Write(clientResponse)
}

Просмотреть файл

@@ -22,7 +22,7 @@ func TestNotifyAdmin(t *testing.T) {
})
require.Error(t, err)
require.Equal(t, err.Error(), ": Unable to save notify data.")
require.Equal(t, ": Unable to save notify data.", err.Error())
require.Equal(t, http.StatusInternalServerError, statusCode)
})
@@ -38,7 +38,7 @@ func TestNotifyAdmin(t *testing.T) {
})
require.Error(t, err)
require.Equal(t, err.Error(), ": Unable to save notify data.")
require.Equal(t, ": Unable to save notify data.", err.Error())
require.Equal(t, http.StatusInternalServerError, statusCode)
})
@@ -53,7 +53,7 @@ func TestNotifyAdmin(t *testing.T) {
})
require.Error(t, err)
require.Equal(t, err.Error(), ": Unable to save notify data.")
require.Equal(t, ": Unable to save notify data.", err.Error())
require.Equal(t, http.StatusInternalServerError, statusCode)
})
@@ -68,7 +68,7 @@ func TestNotifyAdmin(t *testing.T) {
})
require.Error(t, err)
require.Equal(t, err.Error(), ": Unable to save notify data.")
require.Equal(t, ": Unable to save notify data.", err.Error())
require.Equal(t, http.StatusInternalServerError, statusCode)
})
@@ -90,7 +90,7 @@ func TestNotifyAdmin(t *testing.T) {
})
require.Error(t, err)
require.Equal(t, err.Error(), ": Already notified admin")
require.Equal(t, ": Already notified admin", err.Error())
require.Equal(t, http.StatusForbidden, statusCode)
})
@@ -118,7 +118,7 @@ func TestTriggerNotifyAdmin(t *testing.T) {
statusCode, err := th.SystemAdminClient.TriggerNotifyAdmin(&model.NotifyAdminToUpgradeRequest{})
require.Error(t, err)
require.Equal(t, err.Error(), ": Internal error during cloud api request.")
require.Equal(t, ": Internal error during cloud api request.", err.Error())
require.Equal(t, http.StatusForbidden, statusCode)
})
@@ -132,7 +132,7 @@ func TestTriggerNotifyAdmin(t *testing.T) {
statusCode, err := th.Client.TriggerNotifyAdmin(&model.NotifyAdminToUpgradeRequest{})
require.Error(t, err)
require.Equal(t, err.Error(), ": You do not have the appropriate permissions.")
require.Equal(t, ": You do not have the appropriate permissions.", err.Error())
require.Equal(t, http.StatusForbidden, statusCode)
})

Просмотреть файл

@@ -76,24 +76,6 @@ func TestPlugin(t *testing.T) {
_, err = client.RemovePlugin(manifest.Id)
require.NoError(t, err)
t.Run("install plugin from URL with slow response time", func(t *testing.T) {
if testing.Short() {
t.Skip("skipping test to install plugin from a slow response server")
}
// Install from URL - slow server to simulate longer bundle download times
slowTestServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
time.Sleep(60 * time.Second) // Wait longer than the previous default 30 seconds timeout
res.WriteHeader(http.StatusOK)
res.Write(tarData)
}))
defer func() { slowTestServer.Close() }()
manifest, _, err = client.InstallPluginFromURL(slowTestServer.URL, true)
require.NoError(t, err)
assert.Equal(t, "testplugin", manifest.Id)
})
th.App.Channels().RemovePlugin(manifest.Id)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false })
@@ -121,6 +103,7 @@ func TestPlugin(t *testing.T) {
// Successful upload
manifest, _, err = client.UploadPlugin(bytes.NewReader(tarData))
require.NoError(t, err)
assert.Equal(t, "testplugin", manifest.Id)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.EnableUploads = true })
@@ -1652,6 +1635,59 @@ func TestInstallMarketplacePlugin(t *testing.T) {
require.Nil(t, manifest)
assert.True(t, requestHandled)
}, "verify EnterprisePlugins is true for E20")
}
func TestInstallMarketplacePluginPrepackagedDisabled(t *testing.T) {
path, _ := fileutils.FindDir("tests")
signatureFilename := "testplugin2.tar.gz.sig"
signatureFileReader, err := os.Open(filepath.Join(path, signatureFilename))
require.NoError(t, err)
sigFile, err := io.ReadAll(signatureFileReader)
require.NoError(t, err)
pluginSignature := base64.StdEncoding.EncodeToString(sigFile)
tarData, err := os.ReadFile(filepath.Join(path, "testplugin2.tar.gz"))
require.NoError(t, err)
pluginServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
res.WriteHeader(http.StatusOK)
res.Write(tarData)
}))
defer pluginServer.Close()
samplePlugins := []*model.MarketplacePlugin{
{
BaseMarketplacePlugin: &model.BaseMarketplacePlugin{
HomepageURL: "https://example.com/mattermost/mattermost-plugin-nps",
IconData: "https://example.com/icon.svg",
DownloadURL: pluginServer.URL,
Manifest: &model.Manifest{
Id: "testplugin2",
Name: "testplugin2",
Description: "a second plugin",
Version: "1.2.2",
MinServerVersion: "",
},
},
InstalledVersion: "",
},
{
BaseMarketplacePlugin: &model.BaseMarketplacePlugin{
HomepageURL: "https://example.com/mattermost/mattermost-plugin-nps",
IconData: "https://example.com/icon.svg",
DownloadURL: pluginServer.URL,
Manifest: &model.Manifest{
Id: "testplugin2",
Name: "testplugin2",
Description: "a second plugin",
Version: "1.2.3",
MinServerVersion: "",
},
Signature: pluginSignature,
},
InstalledVersion: "",
},
}
t.Run("install prepackaged and remote plugins through marketplace", func(t *testing.T) {
prepackagedPluginsDir := "prepackaged_plugins"
@@ -1669,13 +1705,13 @@ func TestInstallMarketplacePlugin(t *testing.T) {
err = testlib.CopyFile(filepath.Join(path, "testplugin.tar.gz.asc"), filepath.Join(prepackagedPluginsDir, "testplugin.tar.gz.sig"))
require.NoError(t, err)
th2 := SetupConfig(t, func(cfg *model.Config) {
th := SetupConfig(t, func(cfg *model.Config) {
// Disable auto-installing prepackaged plugins
*cfg.PluginSettings.AutomaticPrepackagedPlugins = false
}).InitBasic()
defer th2.TearDown()
defer th.TearDown()
th2.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
pluginSignatureFile, err := os.Open(filepath.Join(path, "testplugin.tar.gz.asc"))
require.NoError(t, err)
pluginSignatureData, err := io.ReadAll(pluginSignatureFile)
@@ -1683,7 +1719,7 @@ func TestInstallMarketplacePlugin(t *testing.T) {
key, err := os.Open(filepath.Join(path, "development-private-key.asc"))
require.NoError(t, err)
appErr := th2.App.AddPublicKey("pub_key", key)
appErr := th.App.AddPublicKey("pub_key", key)
require.Nil(t, appErr)
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
@@ -1698,14 +1734,14 @@ func TestInstallMarketplacePlugin(t *testing.T) {
}))
defer testServer.Close()
th2.App.UpdateConfig(func(cfg *model.Config) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.EnableMarketplace = true
*cfg.PluginSettings.EnableRemoteMarketplace = false
*cfg.PluginSettings.MarketplaceURL = testServer.URL
*cfg.PluginSettings.AllowInsecureDownloadURL = false
})
env := th2.App.GetPluginsEnvironment()
env := th.App.GetPluginsEnvironment()
pluginsResp, _, err := client.GetPlugins()
require.NoError(t, err)
@@ -1751,7 +1787,7 @@ func TestInstallMarketplacePlugin(t *testing.T) {
require.Nil(t, manifest)
// Enable remote marketplace
th2.App.UpdateConfig(func(cfg *model.Config) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.EnableMarketplace = true
*cfg.PluginSettings.EnableRemoteMarketplace = true
*cfg.PluginSettings.MarketplaceURL = testServer.URL
@@ -1784,12 +1820,12 @@ func TestInstallMarketplacePlugin(t *testing.T) {
_, err = client.RemovePlugin(manifest2.Id)
require.NoError(t, err)
appErr = th2.App.DeletePublicKey("pub_key")
appErr = th.App.DeletePublicKey("pub_key")
require.Nil(t, appErr)
})
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
t.Run("missing prepackaged and remote plugin signatures", func(t *testing.T) {
prepackagedPluginsDir := "prepackaged_plugins"
os.RemoveAll(prepackagedPluginsDir)
@@ -1809,6 +1845,7 @@ func TestInstallMarketplacePlugin(t *testing.T) {
}).InitBasic()
defer th.TearDown()
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
key, err := os.Open(filepath.Join(path, "development-private-key.asc"))
require.NoError(t, err)
appErr := th.App.AddPublicKey("pub_key", key)
@@ -1872,7 +1909,8 @@ func TestInstallMarketplacePlugin(t *testing.T) {
// Clean up
appErr = th.App.DeletePublicKey("pub_key")
require.Nil(t, appErr)
}, "missing prepackaged and remote plugin signatures")
})
})
}
func findClusterMessages(event model.ClusterEvent, msgs []*model.ClusterMessage) []*model.ClusterMessage {

Просмотреть файл

@@ -452,32 +452,68 @@ func testCreatePostWithOutgoingHook(
}
func TestCreatePostWithOutgoingHook_form_urlencoded(t *testing.T) {
t.Run("Case 1", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, false)
})
t.Run("Case 2", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, false)
})
t.Run("Case 3", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "", "", []string{"file_id_1"}, app.TriggerwordsExactMatch, false)
})
t.Run("Case 4", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, false)
})
t.Run("Case 5", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, true)
})
t.Run("Case 6", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, true)
})
}
func TestCreatePostWithOutgoingHook_json(t *testing.T) {
t.Run("Case 1", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, false)
})
t.Run("Case 2", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsStartsWith, false)
})
t.Run("Case 3", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsExactMatch, false)
})
t.Run("Case 4", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, false)
})
t.Run("Case 5", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, true)
})
t.Run("Case 6", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, true)
})
}
// hooks created before we added the ContentType field should be considered as
// application/x-www-form-urlencoded
func TestCreatePostWithOutgoingHook_no_content_type(t *testing.T) {
t.Run("Case 1", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, false)
})
t.Run("Case 2", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, false)
})
t.Run("Case 3", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, false)
})
t.Run("Case 4", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsStartsWith, false)
})
t.Run("Case 5", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, true)
})
t.Run("Case 6", func(t *testing.T) {
testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, true)
})
}
func TestCreatePostPublic(t *testing.T) {
@@ -3199,6 +3235,7 @@ func TestGetEditHistoryForPost(t *testing.T) {
func TestCreatePostNotificationsWithCRT(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
rpost := th.CreatePost()
th.App.UpdateConfig(func(cfg *model.Config) {

Просмотреть файл

@@ -892,6 +892,7 @@ func TestCompleteOnboarding(t *testing.T) {
req := &model.CompleteOnboardingRequest{
InstallPlugins: []string{"testplugin2"},
Organization: "my-org",
}
t.Run("as a regular user", func(t *testing.T) {

Просмотреть файл

@@ -1173,11 +1173,10 @@ func TestGetAllTeams(t *testing.T) {
}
var teams []*model.Team
var count int64
var resp *model.Response
var err2 error
if tc.WithCount {
teams, count, resp, err2 = client.GetAllTeamsWithTotalCount("", tc.Page, tc.PerPage)
teams, _, resp, err2 = client.GetAllTeamsWithTotalCount("", tc.Page, tc.PerPage)
} else {
teams, resp, err2 = client.GetAllTeams("", tc.Page, tc.PerPage)
}
@@ -1187,11 +1186,12 @@ func TestGetAllTeams(t *testing.T) {
return
}
require.NoError(t, err2)
require.Equal(t, len(tc.ExpectedTeams), len(teams))
for idx, team := range teams {
assert.Equal(t, tc.ExpectedTeams[idx], team.Id)
actualTeamIds := make([]string, 0, len(tc.ExpectedTeams))
for _, team := range teams {
actualTeamIds = append(actualTeamIds, team.Id)
}
require.Equal(t, tc.ExpectedCount, count)
require.ElementsMatch(t, tc.ExpectedTeams, actualTeamIds)
})
}

Просмотреть файл

@@ -3106,6 +3106,10 @@ func getThreadForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.ThreadId, model.PermissionReadChannel) {
c.SetPermissionError(model.PermissionReadChannel)
return
}
extendedStr := r.URL.Query().Get("extended")
extended, _ := strconv.ParseBool(extendedStr)
@@ -3136,6 +3140,10 @@ func getThreadsForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
options := model.GetUserThreadsOpts{
Since: 0,
@@ -3213,6 +3221,10 @@ func updateReadStateThreadByUser(c *Context, w http.ResponseWriter, r *http.Requ
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.ThreadId, model.PermissionReadChannel) {
c.SetPermissionError(model.PermissionReadChannel)
return
}
thread, err := c.App.UpdateThreadReadForUser(c.AppContext, c.AppContext.Session().Id, c.Params.UserId, c.Params.TeamId, c.Params.ThreadId, c.Params.Timestamp)
if err != nil {
@@ -3279,6 +3291,10 @@ func unfollowThreadByUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.ThreadId, model.PermissionReadChannel) {
c.SetPermissionError(model.PermissionReadChannel)
return
}
err := c.App.UpdateThreadFollowForUser(c.Params.UserId, c.Params.TeamId, c.Params.ThreadId, false)
if err != nil {
@@ -3338,6 +3354,10 @@ func updateReadStateAllThreadsByUser(c *Context, w http.ResponseWriter, r *http.
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
err := c.App.UpdateThreadsReadForUser(c.Params.UserId, c.Params.TeamId)
if err != nil {

Просмотреть файл

@@ -6360,6 +6360,15 @@ func TestGetThreadsForUser(t *testing.T) {
require.NoError(t, err)
require.Equal(t, uss.TotalUnreadThreads, int64(2))
})
t.Run("should error when not a team member", func(t *testing.T) {
th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam)
defer th.LinkUserToTeam(th.BasicUser, th.BasicTeam)
_, resp, err := th.Client.GetUserThreads(th.BasicUser.Id, th.BasicTeam.Id, model.GetUserThreadsOpts{})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}
func TestThreadSocketEvents(t *testing.T) {
@@ -6855,6 +6864,8 @@ func TestSingleThreadGet(t *testing.T) {
})
client := th.Client
t.Run("get single thread", func(t *testing.T) {
defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.BasicUser.Id)
defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.SystemAdminUser.Id)
@@ -6901,6 +6912,16 @@ func TestSingleThreadGet(t *testing.T) {
tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true)
require.NoError(t, err)
require.Equal(t, true, tr.IsUrgent)
})
t.Run("should error when not a team member", func(t *testing.T) {
th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam)
defer th.LinkUserToTeam(th.BasicUser, th.BasicTeam)
_, resp, err := th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, model.NewId(), false)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}
func TestMaintainUnreadMentionsInThread(t *testing.T) {
@@ -7072,6 +7093,23 @@ func TestReadThreads(t *testing.T) {
checkThreadListReplies(t, th, th.Client, th.BasicUser.Id, 1, 1, nil)
})
t.Run("should error when not a team member", func(t *testing.T) {
th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam)
defer th.LinkUserToTeam(th.BasicUser, th.BasicTeam)
_, resp, err := th.Client.UpdateThreadReadForUser(th.BasicUser.Id, th.BasicTeam.Id, model.NewId(), model.GetMillis())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.Client.SetThreadUnreadByPostId(th.BasicUser.Id, th.BasicTeam.Id, model.NewId(), model.NewId())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
resp, err = th.Client.UpdateThreadsReadForUser(th.BasicUser.Id, th.BasicTeam.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}
func TestMarkThreadUnreadMentionCount(t *testing.T) {

Просмотреть файл

@@ -424,10 +424,14 @@ func TestWebSocketUpgrade(t *testing.T) {
th := Setup(t)
defer th.TearDown()
buffer := &mlog.Buffer{}
err := mlog.AddWriterTarget(th.TestLogger, buffer, true, mlog.StdAll...)
require.NoError(t, err)
url := fmt.Sprintf("http://localhost:%v", th.App.Srv().ListenAddr.Port) + model.APIURLSuffix + "/websocket"
resp, err := http.Get(url)
require.NoError(t, err)
require.Equal(t, resp.StatusCode, http.StatusBadRequest)
require.NoError(t, th.TestLogger.Flush())
testlib.AssertLog(t, th.LogBuffer, mlog.LvlDebug.Name, "Failed to upgrade websocket connection.")
testlib.AssertLog(t, buffer, mlog.LvlDebug.Name, "Failed to upgrade websocket connection.")
}

Просмотреть файл

@@ -2518,6 +2518,9 @@ func (a *App) removeUserFromChannel(c request.CTX, userIDToRemove string, remove
if err := a.Srv().Store().ChannelMemberHistory().LogLeaveEvent(userIDToRemove, channel.Id, model.GetMillis()); err != nil {
return model.NewAppError("removeUserFromChannel", "app.channel_member_history.log_leave_event.internal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if err := a.Srv().Store().Thread().DeleteMembershipsForChannel(userIDToRemove, channel.Id); err != nil {
return model.NewAppError("removeUserFromChannel", model.NoTranslation, nil, "failed to delete threadmemberships upon leaving channel", http.StatusInternalServerError).Wrap(err)
}
if isGuest {
currentMembers, err := a.GetChannelMembersForUser(c, channel.TeamId, userIDToRemove)

Просмотреть файл

@@ -609,6 +609,85 @@ func TestLeaveDefaultChannel(t *testing.T) {
_, err = th.App.GetChannelMember(th.Context, townSquare.Id, guest.Id)
assert.NotNil(t, err)
})
t.Run("Trying to leave the default channel should not delete thread memberships", func(t *testing.T) {
post := &model.Post{
ChannelId: townSquare.Id,
Message: "root post",
UserId: th.BasicUser.Id,
}
rpost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, false, true)
require.Nil(t, err)
reply := &model.Post{
ChannelId: townSquare.Id,
Message: "reply post",
UserId: th.BasicUser.Id,
RootId: rpost.Id,
}
_, err = th.App.CreatePost(th.Context, reply, th.BasicChannel, false, true)
require.Nil(t, err)
threads, err := th.App.GetThreadsForUser(th.BasicUser.Id, townSquare.TeamId, model.GetUserThreadsOpts{})
require.Nil(t, err)
require.Len(t, threads.Threads, 1)
err = th.App.LeaveChannel(th.Context, townSquare.Id, th.BasicUser.Id)
assert.NotNil(t, err, "It should fail to remove a regular user from the default channel")
assert.Equal(t, err.Id, "api.channel.remove.default.app_error")
threads, err = th.App.GetThreadsForUser(th.BasicUser.Id, townSquare.TeamId, model.GetUserThreadsOpts{})
require.Nil(t, err)
require.Len(t, threads.Threads, 1)
})
}
func TestLeaveChannel(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
createThread := func(channel *model.Channel) (rpost *model.Post) {
t.Helper()
post := &model.Post{
ChannelId: channel.Id,
Message: "root post",
UserId: th.BasicUser.Id,
}
rpost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, false, true)
require.Nil(t, err)
reply := &model.Post{
ChannelId: channel.Id,
Message: "reply post",
UserId: th.BasicUser.Id,
RootId: rpost.Id,
}
_, err = th.App.CreatePost(th.Context, reply, th.BasicChannel, false, true)
require.Nil(t, err)
return rpost
}
t.Run("thread memberships are deleted", func(t *testing.T) {
createThread(th.BasicChannel)
channel2 := th.createChannel(th.Context, th.BasicTeam, model.ChannelTypeOpen)
createThread(channel2)
threads, err := th.App.GetThreadsForUser(th.BasicUser.Id, th.BasicChannel.TeamId, model.GetUserThreadsOpts{})
require.Nil(t, err)
require.Len(t, threads.Threads, 2)
err = th.App.LeaveChannel(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.Nil(t, err)
_, err = th.App.GetChannelMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NotNil(t, err, "It should remove channel membership")
threads, err = th.App.GetThreadsForUser(th.BasicUser.Id, th.BasicChannel.TeamId, model.GetUserThreadsOpts{})
require.Nil(t, err)
require.Len(t, threads.Threads, 1)
})
}
func TestLeaveLastChannel(t *testing.T) {

Просмотреть файл

@@ -71,8 +71,6 @@ func TestGetSanitizedClientLicense(t *testing.T) {
assert.False(t, ok)
_, ok = m["SkuName"]
assert.False(t, ok)
_, ok = m["SkuShortName"]
assert.False(t, ok)
}
func TestGenerateRenewalToken(t *testing.T) {

Просмотреть файл

@@ -28,6 +28,24 @@ func (a *App) markAdminOnboardingComplete(c *request.Context) *model.AppError {
}
func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnboardingRequest) *model.AppError {
isCloud := a.Srv().License() != nil && *a.Srv().License().Features.Cloud
if !isCloud && request.Organization == "" {
mlog.Error("No organization name provided for self hosted onboarding")
return model.NewAppError("CompleteOnboarding", "api.error_no_organization_name_provided_for_self_hosted_onboarding", nil, "", http.StatusBadRequest)
}
if request.Organization != "" {
err := a.Srv().Store().System().SaveOrUpdate(&model.System{
Name: model.SystemOrganizationName,
Value: request.Organization,
})
if err != nil {
// don't block onboarding because of that.
a.Log().Error("failed to save organization name", mlog.Err(err))
}
}
pluginsEnvironment := a.Channels().GetPluginsEnvironment()
if pluginsEnvironment == nil {
return a.markAdminOnboardingComplete(c)

30
server/channels/app/onboarding_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/server/v8/channels/app/request"
mm_model "github.com/mattermost/mattermost-server/server/v8/model"
)
func TestOnboardingSavesOrganizationName(t *testing.T) {
th := Setup(t)
defer th.TearDown()
err := th.App.CompleteOnboarding(&request.Context{}, &mm_model.CompleteOnboardingRequest{
Organization: "Mattermost In Tests",
})
require.Nil(t, err)
defer func() {
th.App.Srv().Store().System().PermanentDeleteByName(mm_model.SystemOrganizationName)
}()
sys, storeErr := th.App.Srv().Store().System().GetByName(mm_model.SystemOrganizationName)
require.NoError(t, storeErr)
require.Equal(t, "Mattermost In Tests", sys.Value)
}

Просмотреть файл

@@ -71,8 +71,6 @@ func TestGetSanitizedClientLicense(t *testing.T) {
assert.False(t, ok)
_, ok = m["SkuName"]
assert.False(t, ok)
_, ok = m["SkuShortName"]
assert.False(t, ok)
}
func TestGenerateRenewalToken(t *testing.T) {

Просмотреть файл

@@ -212,6 +212,8 @@ channels/db/migrations/mysql/000105_remove_tokens.down.sql
channels/db/migrations/mysql/000105_remove_tokens.up.sql
channels/db/migrations/mysql/000106_fileinfo_channelid.down.sql
channels/db/migrations/mysql/000106_fileinfo_channelid.up.sql
channels/db/migrations/mysql/000107_threadmemberships_cleanup.down.sql
channels/db/migrations/mysql/000107_threadmemberships_cleanup.up.sql
channels/db/migrations/postgres/000001_create_teams.down.sql
channels/db/migrations/postgres/000001_create_teams.up.sql
channels/db/migrations/postgres/000002_create_team_members.down.sql
@@ -424,3 +426,5 @@ channels/db/migrations/postgres/000105_remove_tokens.down.sql
channels/db/migrations/postgres/000105_remove_tokens.up.sql
channels/db/migrations/postgres/000106_fileinfo_channelid.down.sql
channels/db/migrations/postgres/000106_fileinfo_channelid.up.sql
channels/db/migrations/postgres/000107_threadmemberships_cleanup.down.sql
channels/db/migrations/postgres/000107_threadmemberships_cleanup.up.sql

Просмотреть файл

@@ -0,0 +1 @@
-- Skipping it because the forward migrations are destructive

Просмотреть файл

@@ -0,0 +1,5 @@
DELETE FROM
tm USING ThreadMemberships AS tm
JOIN Threads ON Threads.PostId = tm.PostId
WHERE
(tm.UserId, Threads.ChannelId) NOT IN (SELECT UserId, ChannelId FROM ChannelMembers);

Просмотреть файл

@@ -0,0 +1 @@
-- Skipping it because the forward migrations are destructive

Просмотреть файл

@@ -0,0 +1,12 @@
DELETE FROM threadmemberships WHERE (postid, userid) IN (
SELECT
threadmemberships.postid,
threadmemberships.userid
FROM
threadmemberships
JOIN threads ON threads.postid = threadmemberships.postid
LEFT JOIN channelmembers ON channelmembers.userid = threadmemberships.userid
AND threads.channelid = channelmembers.channelid
WHERE
channelmembers.channelid IS NULL
);

Просмотреть файл

@@ -37,6 +37,7 @@ type CloudInterface interface {
BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error)
CreateCustomerSelfHostedSignup(req model.SelfHostedCustomerForm, requesterEmail string) (*model.SelfHostedSignupCustomerResponse, error)
ConfirmSelfHostedSignup(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error)
ConfirmSelfHostedExpansion(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error)
ConfirmSelfHostedSignupLicenseApplication() error
GetSelfHostedInvoices() ([]*model.Invoice, error)
GetSelfHostedInvoicePDF(invoiceID string) ([]byte, string, error)

Просмотреть файл

@@ -13,6 +13,7 @@ import (
type MetricsInterface interface {
Register()
RegisterDBCollector(db *sql.DB, name string)
UnregisterDBCollector(db *sql.DB, name string)
IncrementPostCreate()
IncrementWebhookPost()

Просмотреть файл

@@ -94,6 +94,32 @@ func (_m *CloudInterface) ConfirmCustomerPayment(userID string, confirmRequest *
return r0
}
// ConfirmSelfHostedExpansion provides a mock function with given fields: req, requesterEmail
func (_m *CloudInterface) ConfirmSelfHostedExpansion(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) {
ret := _m.Called(req, requesterEmail)
var r0 *model.SelfHostedSignupConfirmResponse
var r1 error
if rf, ok := ret.Get(0).(func(model.SelfHostedConfirmPaymentMethodRequest, string) (*model.SelfHostedSignupConfirmResponse, error)); ok {
return rf(req, requesterEmail)
}
if rf, ok := ret.Get(0).(func(model.SelfHostedConfirmPaymentMethodRequest, string) *model.SelfHostedSignupConfirmResponse); ok {
r0 = rf(req, requesterEmail)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.SelfHostedSignupConfirmResponse)
}
}
if rf, ok := ret.Get(1).(func(model.SelfHostedConfirmPaymentMethodRequest, string) error); ok {
r1 = rf(req, requesterEmail)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ConfirmSelfHostedSignup provides a mock function with given fields: req, requesterEmail
func (_m *CloudInterface) ConfirmSelfHostedSignup(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) {
ret := _m.Called(req, requesterEmail)

Просмотреть файл

@@ -319,6 +319,11 @@ func (_m *MetricsInterface) SetReplicaLagTime(node string, value float64) {
_m.Called(node, value)
}
// UnregisterDBCollector provides a mock function with given fields: db, name
func (_m *MetricsInterface) UnregisterDBCollector(db *sql.DB, name string) {
_m.Called(db, name)
}
type mockConstructorTestingTNewMetricsInterface interface {
mock.TestingT
Cleanup(func())

Просмотреть файл

@@ -10123,6 +10123,24 @@ func (s *OpenTracingLayerThreadStore) DeleteMembershipForUser(userId string, pos
return err
}
func (s *OpenTracingLayerThreadStore) DeleteMembershipsForChannel(userID string, channelID string) error {
origCtx := s.Root.Store.Context()
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.DeleteMembershipsForChannel")
s.Root.Store.SetContext(newCtx)
defer func() {
s.Root.Store.SetContext(origCtx)
}()
defer span.Finish()
err := s.ThreadStore.DeleteMembershipsForChannel(userID, channelID)
if err != nil {
span.LogFields(spanlog.Error(err))
ext.Error.Set(span, true)
}
return err
}
func (s *OpenTracingLayerThreadStore) DeleteOrphanedRows(limit int) (int64, error) {
origCtx := s.Root.Store.Context()
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.DeleteOrphanedRows")

Просмотреть файл

@@ -11563,6 +11563,27 @@ func (s *RetryLayerThreadStore) DeleteMembershipForUser(userId string, postID st
}
func (s *RetryLayerThreadStore) DeleteMembershipsForChannel(userID string, channelID string) error {
tries := 0
for {
err := s.ThreadStore.DeleteMembershipsForChannel(userID, channelID)
if err == nil {
return nil
}
if !isRepeatableError(err) {
return err
}
tries++
if tries >= 3 {
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
return err
}
timepkg.Sleep(100 * timepkg.Millisecond)
}
}
func (s *RetryLayerThreadStore) DeleteOrphanedRows(limit int) (int64, error) {
tries := 0

Просмотреть файл

@@ -335,7 +335,7 @@ func (s SqlChannelStore) CreateSidebarCategory(userId, teamId string, newCategor
Id: newCategoryId,
UserId: userId,
TeamId: teamId,
Sorting: model.SidebarCategorySortDefault,
Sorting: newCategory.Sorting,
SortOrder: int64(model.MinimalSidebarSortDistance * len(newOrder)), // first we place it at the end of the list
Type: model.SidebarCategoryCustom,
Muted: newCategory.Muted,

Просмотреть файл

@@ -6,9 +6,12 @@ package sqlstore
import (
"context"
"database/sql"
"errors"
"net"
"regexp"
"strconv"
"strings"
"sync/atomic"
"time"
"unicode"
@@ -66,14 +69,18 @@ type sqlxDBWrapper struct {
*sqlx.DB
queryTimeout time.Duration
trace bool
isOnline *atomic.Bool
}
func newSqlxDBWrapper(db *sqlx.DB, timeout time.Duration, trace bool) *sqlxDBWrapper {
return &sqlxDBWrapper{
w := &sqlxDBWrapper{
DB: db,
queryTimeout: timeout,
trace: trace,
isOnline: &atomic.Bool{},
}
w.isOnline.Store(true)
return w
}
func (w *sqlxDBWrapper) Stats() sql.DBStats {
@@ -83,19 +90,19 @@ func (w *sqlxDBWrapper) Stats() sql.DBStats {
func (w *sqlxDBWrapper) Beginx() (*sqlxTxWrapper, error) {
tx, err := w.DB.Beginx()
if err != nil {
return nil, err
return nil, w.checkErr(err)
}
return newSqlxTxWrapper(tx, w.queryTimeout, w.trace), nil
return newSqlxTxWrapper(tx, w.queryTimeout, w.trace, w), nil
}
func (w *sqlxDBWrapper) BeginXWithIsolation(opts *sql.TxOptions) (*sqlxTxWrapper, error) {
tx, err := w.DB.BeginTxx(context.Background(), opts)
if err != nil {
return nil, err
return nil, w.checkErr(err)
}
return newSqlxTxWrapper(tx, w.queryTimeout, w.trace), nil
return newSqlxTxWrapper(tx, w.queryTimeout, w.trace, w), nil
}
func (w *sqlxDBWrapper) Get(dest any, query string, args ...any) error {
@@ -109,7 +116,7 @@ func (w *sqlxDBWrapper) Get(dest any, query string, args ...any) error {
}(time.Now())
}
return w.DB.GetContext(ctx, dest, query, args...)
return w.checkErr(w.DB.GetContext(ctx, dest, query, args...))
}
func (w *sqlxDBWrapper) GetBuilder(dest any, builder Builder) error {
@@ -134,7 +141,7 @@ func (w *sqlxDBWrapper) NamedExec(query string, arg any) (sql.Result, error) {
}(time.Now())
}
return w.DB.NamedExecContext(ctx, query, arg)
return w.checkErrWithResult(w.DB.NamedExecContext(ctx, query, arg))
}
func (w *sqlxDBWrapper) Exec(query string, args ...any) (sql.Result, error) {
@@ -161,7 +168,7 @@ func (w *sqlxDBWrapper) ExecNoTimeout(query string, args ...any) (sql.Result, er
}(time.Now())
}
return w.DB.ExecContext(context.Background(), query, args...)
return w.checkErrWithResult(w.DB.ExecContext(context.Background(), query, args...))
}
// ExecRaw is like Exec but without any rebinding of params. You need to pass
@@ -176,7 +183,7 @@ func (w *sqlxDBWrapper) ExecRaw(query string, args ...any) (sql.Result, error) {
}(time.Now())
}
return w.DB.ExecContext(ctx, query, args...)
return w.checkErrWithResult(w.DB.ExecContext(ctx, query, args...))
}
func (w *sqlxDBWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) {
@@ -192,7 +199,7 @@ func (w *sqlxDBWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) {
}(time.Now())
}
return w.DB.NamedQueryContext(ctx, query, arg)
return w.checkErrWithRows(w.DB.NamedQueryContext(ctx, query, arg))
}
func (w *sqlxDBWrapper) QueryRowX(query string, args ...any) *sqlx.Row {
@@ -220,7 +227,7 @@ func (w *sqlxDBWrapper) QueryX(query string, args ...any) (*sqlx.Rows, error) {
}(time.Now())
}
return w.DB.QueryxContext(ctx, query, args)
return w.checkErrWithRows(w.DB.QueryxContext(ctx, query, args))
}
func (w *sqlxDBWrapper) Select(dest any, query string, args ...any) error {
@@ -238,7 +245,7 @@ func (w *sqlxDBWrapper) SelectCtx(ctx context.Context, dest any, query string, a
}(time.Now())
}
return w.DB.SelectContext(ctx, dest, query, args...)
return w.checkErr(w.DB.SelectContext(ctx, dest, query, args...))
}
func (w *sqlxDBWrapper) SelectBuilder(dest any, builder Builder) error {
@@ -254,13 +261,15 @@ type sqlxTxWrapper struct {
*sqlx.Tx
queryTimeout time.Duration
trace bool
dbw *sqlxDBWrapper
}
func newSqlxTxWrapper(tx *sqlx.Tx, timeout time.Duration, trace bool) *sqlxTxWrapper {
func newSqlxTxWrapper(tx *sqlx.Tx, timeout time.Duration, trace bool, dbw *sqlxDBWrapper) *sqlxTxWrapper {
return &sqlxTxWrapper{
Tx: tx,
queryTimeout: timeout,
trace: trace,
dbw: dbw,
}
}
@@ -275,7 +284,7 @@ func (w *sqlxTxWrapper) Get(dest any, query string, args ...any) error {
}(time.Now())
}
return w.Tx.GetContext(ctx, dest, query, args...)
return w.dbw.checkErr(w.Tx.GetContext(ctx, dest, query, args...))
}
func (w *sqlxTxWrapper) GetBuilder(dest any, builder Builder) error {
@@ -284,13 +293,13 @@ func (w *sqlxTxWrapper) GetBuilder(dest any, builder Builder) error {
return err
}
return w.Get(dest, query, args...)
return w.dbw.checkErr(w.Get(dest, query, args...))
}
func (w *sqlxTxWrapper) Exec(query string, args ...any) (sql.Result, error) {
query = w.Tx.Rebind(query)
return w.ExecRaw(query, args...)
return w.dbw.checkErrWithResult(w.ExecRaw(query, args...))
}
func (w *sqlxTxWrapper) ExecNoTimeout(query string, args ...any) (sql.Result, error) {
@@ -302,7 +311,7 @@ func (w *sqlxTxWrapper) ExecNoTimeout(query string, args ...any) (sql.Result, er
}(time.Now())
}
return w.Tx.ExecContext(context.Background(), query, args...)
return w.dbw.checkErrWithResult(w.Tx.ExecContext(context.Background(), query, args...))
}
func (w *sqlxTxWrapper) ExecBuilder(builder Builder) (sql.Result, error) {
@@ -326,7 +335,7 @@ func (w *sqlxTxWrapper) ExecRaw(query string, args ...any) (sql.Result, error) {
}(time.Now())
}
return w.Tx.ExecContext(ctx, query, args...)
return w.dbw.checkErrWithResult(w.Tx.ExecContext(ctx, query, args...))
}
func (w *sqlxTxWrapper) NamedExec(query string, arg any) (sql.Result, error) {
@@ -342,7 +351,7 @@ func (w *sqlxTxWrapper) NamedExec(query string, arg any) (sql.Result, error) {
}(time.Now())
}
return w.Tx.NamedExecContext(ctx, query, arg)
return w.dbw.checkErrWithResult(w.Tx.NamedExecContext(ctx, query, arg))
}
func (w *sqlxTxWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) {
@@ -386,7 +395,7 @@ func (w *sqlxTxWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) {
}
}
return res.rows, res.err
return res.rows, w.dbw.checkErr(res.err)
}
func (w *sqlxTxWrapper) QueryRowX(query string, args ...any) *sqlx.Row {
@@ -414,7 +423,7 @@ func (w *sqlxTxWrapper) QueryX(query string, args ...any) (*sqlx.Rows, error) {
}(time.Now())
}
return w.Tx.QueryxContext(ctx, query, args)
return w.dbw.checkErrWithRows(w.Tx.QueryxContext(ctx, query, args))
}
func (w *sqlxTxWrapper) Select(dest any, query string, args ...any) error {
@@ -428,7 +437,7 @@ func (w *sqlxTxWrapper) Select(dest any, query string, args ...any) error {
}(time.Now())
}
return w.Tx.SelectContext(ctx, dest, query, args...)
return w.dbw.checkErr(w.Tx.SelectContext(ctx, dest, query, args...))
}
func (w *sqlxTxWrapper) SelectBuilder(dest any, builder Builder) error {
@@ -459,3 +468,23 @@ func printArgs(query string, dur time.Duration, args ...any) {
}
mlog.Debug(query, fields...)
}
func (w *sqlxDBWrapper) checkErrWithResult(res sql.Result, err error) (sql.Result, error) {
return res, w.checkErr(err)
}
func (w *sqlxDBWrapper) checkErrWithRows(res *sqlx.Rows, err error) (*sqlx.Rows, error) {
return res, w.checkErr(err)
}
func (w *sqlxDBWrapper) checkErr(err error) error {
var netError *net.OpError
if errors.As(err, &netError) && (!netError.Temporary() && !netError.Timeout()) {
w.isOnline.Store(false)
}
return err
}
func (w *sqlxDBWrapper) Online() bool {
return w.isOnline.Load()
}

Просмотреть файл

@@ -6,6 +6,7 @@ package sqlstore
import (
"context"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
@@ -31,9 +32,11 @@ func TestSqlX(t *testing.T) {
rrCounter: 0,
srCounter: 0,
settings: settings,
quitMonitor: make(chan struct{}),
wgMonitor: &sync.WaitGroup{},
}
store.initConnection()
require.NoError(t, store.initConnection())
defer store.Close()

Просмотреть файл

@@ -49,7 +49,7 @@ const (
MySQLForeignKeyViolationErrorCode = 1452
PGDuplicateObjectErrorCode = "42710"
MySQLDuplicateObjectErrorCode = 1022
DBPingAttempts = 18
DBPingAttempts = 5
DBPingTimeoutSecs = 10
// This is a numerical version string by postgres. The format is
// 2 characters for major, minor, and patch version prior to 10.
@@ -123,9 +123,9 @@ type SqlStore struct {
masterX *sqlxDBWrapper
ReplicaXs []*sqlxDBWrapper
ReplicaXs []*atomic.Pointer[sqlxDBWrapper]
searchReplicaXs []*sqlxDBWrapper
searchReplicaXs []*atomic.Pointer[sqlxDBWrapper]
replicaLagHandles []*dbsql.DB
stores SqlStoreStores
@@ -138,6 +138,9 @@ type SqlStore struct {
isBinaryParam bool
pgDefaultTextSearchConfig string
quitMonitor chan struct{}
wgMonitor *sync.WaitGroup
}
func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlStore {
@@ -146,9 +149,17 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS
srCounter: 0,
settings: &settings,
metrics: metrics,
quitMonitor: make(chan struct{}),
wgMonitor: &sync.WaitGroup{},
}
store.initConnection()
err := store.initConnection()
if err != nil {
mlog.Fatal("Error setting up connections", mlog.Err(err))
}
store.wgMonitor.Add(1)
go store.monitorReplicas()
ver, err := store.GetDbVersion(true)
if err != nil {
@@ -230,29 +241,28 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS
// SetupConnection sets up the connection to the database and pings it to make sure it's alive.
// It also applies any database configuration settings that are required.
func SetupConnection(connType string, dataSource string, settings *model.SqlSettings) *dbsql.DB {
func SetupConnection(connType string, dataSource string, settings *model.SqlSettings, attempts int) (*dbsql.DB, error) {
db, err := dbsql.Open(*settings.DriverName, dataSource)
if err != nil {
mlog.Fatal("Failed to open SQL connection to err.", mlog.Err(err))
return nil, errors.Wrap(err, "failed to open SQL connection")
}
for i := 0; i < DBPingAttempts; i++ {
for i := 0; i < attempts; i++ {
// At this point, we have passed sql.Open, so we deliberately ignore any errors.
sanitized, _ := SanitizeDataSource(*settings.DriverName, dataSource)
mlog.Info("Pinging SQL", mlog.String("database", connType), mlog.String("dataSource", sanitized))
ctx, cancel := context.WithTimeout(context.Background(), DBPingTimeoutSecs*time.Second)
defer cancel()
err = db.PingContext(ctx)
if err == nil {
break
} else {
if i == DBPingAttempts-1 {
mlog.Fatal("Failed to ping DB, server will exit.", mlog.Err(err))
} else {
if err != nil {
if i == attempts-1 {
return nil, err
}
mlog.Error("Failed to ping DB", mlog.Err(err), mlog.Int("retrying in seconds", DBPingTimeoutSecs))
time.Sleep(DBPingTimeoutSecs * time.Second)
continue
}
}
break
}
if strings.HasPrefix(connType, replicaLagPrefix) {
@@ -272,7 +282,7 @@ func SetupConnection(connType string, dataSource string, settings *model.SqlSett
db.SetConnMaxLifetime(time.Duration(*settings.ConnMaxLifetimeMilliseconds) * time.Millisecond)
db.SetConnMaxIdleTime(time.Duration(*settings.ConnMaxIdleTimeMilliseconds) * time.Millisecond)
return db
return db, nil
}
func (ss *SqlStore) SetContext(context context.Context) {
@@ -285,7 +295,7 @@ func (ss *SqlStore) Context() context.Context {
func noOpMapper(s string) string { return s }
func (ss *SqlStore) initConnection() {
func (ss *SqlStore) initConnection() error {
dataSource := *ss.settings.DataSource
if ss.DriverName() == model.DatabaseDriverMysql {
// TODO: We ignore the readTimeout datasource parameter for MySQL since QueryTimeout
@@ -294,11 +304,14 @@ func (ss *SqlStore) initConnection() {
var err error
dataSource, err = ResetReadTimeout(dataSource)
if err != nil {
mlog.Fatal("Failed to reset read timeout from datasource.", mlog.Err(err), mlog.String("src", dataSource))
return errors.Wrap(err, "failed to reset read timeout from datasource")
}
}
handle := SetupConnection("master", dataSource, ss.settings)
handle, err := SetupConnection("master", dataSource, ss.settings, DBPingAttempts)
if err != nil {
return err
}
ss.masterX = newSqlxDBWrapper(sqlx.NewDb(handle, ss.DriverName()),
time.Duration(*ss.settings.QueryTimeout)*time.Second,
*ss.settings.Trace)
@@ -310,34 +323,32 @@ func (ss *SqlStore) initConnection() {
}
if len(ss.settings.DataSourceReplicas) > 0 {
ss.ReplicaXs = make([]*sqlxDBWrapper, len(ss.settings.DataSourceReplicas))
ss.ReplicaXs = make([]*atomic.Pointer[sqlxDBWrapper], len(ss.settings.DataSourceReplicas))
for i, replica := range ss.settings.DataSourceReplicas {
handle := SetupConnection(fmt.Sprintf("replica-%v", i), replica, ss.settings)
ss.ReplicaXs[i] = newSqlxDBWrapper(sqlx.NewDb(handle, ss.DriverName()),
time.Duration(*ss.settings.QueryTimeout)*time.Second,
*ss.settings.Trace)
if ss.DriverName() == model.DatabaseDriverMysql {
ss.ReplicaXs[i].MapperFunc(noOpMapper)
}
if ss.metrics != nil {
ss.metrics.RegisterDBCollector(ss.ReplicaXs[i].DB.DB, "replica-"+strconv.Itoa(i))
ss.ReplicaXs[i] = &atomic.Pointer[sqlxDBWrapper]{}
handle, err = SetupConnection(fmt.Sprintf("replica-%v", i), replica, ss.settings, DBPingAttempts)
if err != nil {
// Initializing to be offline
ss.ReplicaXs[i].Store(&sqlxDBWrapper{isOnline: &atomic.Bool{}})
mlog.Warn("Failed to setup connection. Skipping..", mlog.String("db", fmt.Sprintf("replica-%v", i)), mlog.Err(err))
continue
}
ss.setDB(ss.ReplicaXs[i], handle, "replica-"+strconv.Itoa(i))
}
}
if len(ss.settings.DataSourceSearchReplicas) > 0 {
ss.searchReplicaXs = make([]*sqlxDBWrapper, len(ss.settings.DataSourceSearchReplicas))
ss.searchReplicaXs = make([]*atomic.Pointer[sqlxDBWrapper], len(ss.settings.DataSourceSearchReplicas))
for i, replica := range ss.settings.DataSourceSearchReplicas {
handle := SetupConnection(fmt.Sprintf("search-replica-%v", i), replica, ss.settings)
ss.searchReplicaXs[i] = newSqlxDBWrapper(sqlx.NewDb(handle, ss.DriverName()),
time.Duration(*ss.settings.QueryTimeout)*time.Second,
*ss.settings.Trace)
if ss.DriverName() == model.DatabaseDriverMysql {
ss.searchReplicaXs[i].MapperFunc(noOpMapper)
}
if ss.metrics != nil {
ss.metrics.RegisterDBCollector(ss.searchReplicaXs[i].DB.DB, "searchreplica-"+strconv.Itoa(i))
ss.searchReplicaXs[i] = &atomic.Pointer[sqlxDBWrapper]{}
handle, err = SetupConnection(fmt.Sprintf("search-replica-%v", i), replica, ss.settings, DBPingAttempts)
if err != nil {
// Initializing to be offline
ss.searchReplicaXs[i].Store(&sqlxDBWrapper{isOnline: &atomic.Bool{}})
mlog.Warn("Failed to setup connection. Skipping..", mlog.String("db", fmt.Sprintf("search-replica-%v", i)), mlog.Err(err))
continue
}
ss.setDB(ss.searchReplicaXs[i], handle, "searchreplica-"+strconv.Itoa(i))
}
}
@@ -347,10 +358,15 @@ func (ss *SqlStore) initConnection() {
if src.DataSource == nil {
continue
}
ss.replicaLagHandles[i] = SetupConnection(fmt.Sprintf(replicaLagPrefix+"-%d", i), *src.DataSource, ss.settings)
ss.replicaLagHandles[i], err = SetupConnection(fmt.Sprintf(replicaLagPrefix+"-%d", i), *src.DataSource, ss.settings, DBPingAttempts)
if err != nil {
mlog.Warn("Failed to setup replica lag handle. Skipping..", mlog.String("db", fmt.Sprintf(replicaLagPrefix+"-%d", i)), mlog.Err(err))
continue
}
}
}
return nil
}
func (ss *SqlStore) DriverName() string {
return *ss.settings.DriverName
@@ -455,8 +471,15 @@ func (ss *SqlStore) GetSearchReplicaX() *sqlxDBWrapper {
return ss.GetReplicaX()
}
for i := 0; i < len(ss.searchReplicaXs); i++ {
rrNum := atomic.AddInt64(&ss.srCounter, 1) % int64(len(ss.searchReplicaXs))
return ss.searchReplicaXs[rrNum]
if ss.searchReplicaXs[rrNum].Load().Online() {
return ss.searchReplicaXs[rrNum].Load()
}
}
// If all search replicas are down, then go with replica.
return ss.GetReplicaX()
}
func (ss *SqlStore) GetReplicaX() *sqlxDBWrapper {
@@ -464,23 +487,64 @@ func (ss *SqlStore) GetReplicaX() *sqlxDBWrapper {
return ss.GetMasterX()
}
for i := 0; i < len(ss.ReplicaXs); i++ {
rrNum := atomic.AddInt64(&ss.rrCounter, 1) % int64(len(ss.ReplicaXs))
return ss.ReplicaXs[rrNum]
}
func (ss *SqlStore) GetInternalReplicaDBs() []*sql.DB {
if len(ss.settings.DataSourceReplicas) == 0 || ss.lockedToMaster || !ss.hasLicense() {
return []*sql.DB{
ss.GetMasterX().DB.DB,
if ss.ReplicaXs[rrNum].Load().Online() {
return ss.ReplicaXs[rrNum].Load()
}
}
dbs := make([]*sql.DB, len(ss.ReplicaXs))
for i, rx := range ss.ReplicaXs {
dbs[i] = rx.DB.DB
// If all replicas are down, then go with master.
return ss.GetMasterX()
}
return dbs
func (ss *SqlStore) monitorReplicas() {
t := time.NewTicker(time.Duration(*ss.settings.ReplicaMonitorIntervalSeconds) * time.Second)
defer func() {
t.Stop()
ss.wgMonitor.Done()
}()
for {
select {
case <-ss.quitMonitor:
return
case <-t.C:
setupReplica := func(r *atomic.Pointer[sqlxDBWrapper], dsn, name string) {
if r.Load().Online() {
return
}
handle, err := SetupConnection(name, dsn, ss.settings, 1)
if err != nil {
mlog.Warn("Failed to setup connection. Skipping..", mlog.String("db", name), mlog.Err(err))
return
}
if ss.metrics != nil && r.Load() != nil && r.Load().DB != nil {
ss.metrics.UnregisterDBCollector(r.Load().DB.DB, name)
}
ss.setDB(r, handle, name)
}
for i, replica := range ss.ReplicaXs {
setupReplica(replica, ss.settings.DataSourceReplicas[i], "replica-"+strconv.Itoa(i))
}
for i, replica := range ss.searchReplicaXs {
setupReplica(replica, ss.settings.DataSourceSearchReplicas[i], "search-replica-"+strconv.Itoa(i))
}
}
}
}
func (ss *SqlStore) setDB(replica *atomic.Pointer[sqlxDBWrapper], handle *dbsql.DB, name string) {
replica.Store(newSqlxDBWrapper(sqlx.NewDb(handle, ss.DriverName()),
time.Duration(*ss.settings.QueryTimeout)*time.Second,
*ss.settings.Trace))
if ss.DriverName() == model.DatabaseDriverMysql {
replica.Load().MapperFunc(noOpMapper)
}
if ss.metrics != nil {
ss.metrics.RegisterDBCollector(replica.Load().DB.DB, name)
}
}
func (ss *SqlStore) GetInternalReplicaDB() *sql.DB {
@@ -489,7 +553,7 @@ func (ss *SqlStore) GetInternalReplicaDB() *sql.DB {
}
rrNum := atomic.AddInt64(&ss.rrCounter, 1) % int64(len(ss.ReplicaXs))
return ss.ReplicaXs[rrNum].DB.DB
return ss.ReplicaXs[rrNum].Load().DB.DB
}
func (ss *SqlStore) TotalMasterDbConnections() int {
@@ -541,7 +605,10 @@ func (ss *SqlStore) TotalReadDbConnections() int {
count := 0
for _, db := range ss.ReplicaXs {
count = count + db.Stats().OpenConnections
if !db.Load().Online() {
continue
}
count = count + db.Load().Stats().OpenConnections
}
return count
@@ -554,7 +621,10 @@ func (ss *SqlStore) TotalSearchDbConnections() int {
count := 0
for _, db := range ss.searchReplicaXs {
count = count + db.Stats().OpenConnections
if !db.Load().Online() {
continue
}
count = count + db.Load().Stats().OpenConnections
}
return count
@@ -782,9 +852,14 @@ func IsUniqueConstraintError(err error, indexName []string) bool {
}
func (ss *SqlStore) GetAllConns() []*sqlxDBWrapper {
all := make([]*sqlxDBWrapper, len(ss.ReplicaXs)+1)
copy(all, ss.ReplicaXs)
all[len(ss.ReplicaXs)] = ss.masterX
all := make([]*sqlxDBWrapper, 0, len(ss.ReplicaXs)+1)
for i := range ss.ReplicaXs {
if !ss.ReplicaXs[i].Load().Online() {
continue
}
all = append(all, ss.ReplicaXs[i].Load())
}
all = append(all, ss.masterX)
return all
}
@@ -807,11 +882,24 @@ func (ss *SqlStore) RecycleDBConnections(d time.Duration) {
func (ss *SqlStore) Close() {
ss.masterX.Close()
// Closing monitor and waiting for it to be done.
// This needs to be done before closing the replica handles.
close(ss.quitMonitor)
ss.wgMonitor.Wait()
for _, replica := range ss.ReplicaXs {
replica.Close()
if replica.Load().Online() {
replica.Load().Close()
}
}
for _, replica := range ss.searchReplicaXs {
if replica.Load().Online() {
replica.Load().Close()
}
}
for _, replica := range ss.replicaLagHandles {
replica.Close()
}
}
@@ -1132,7 +1220,10 @@ func (ss *SqlStore) migrate(direction migrationDirection) error {
if err != nil {
return err
}
db := SetupConnection("master", dataSource, ss.settings)
db, err2 := SetupConnection("master", dataSource, ss.settings, DBPingAttempts)
if err2 != nil {
return err2
}
driver, err = ms.WithInstance(db)
defer db.Close()
case model.DatabaseDriverPostgres:

Просмотреть файл

@@ -765,9 +765,11 @@ func TestReplicaLagQuery(t *testing.T) {
srCounter: 0,
settings: settings,
metrics: mockMetrics,
quitMonitor: make(chan struct{}),
wgMonitor: &sync.WaitGroup{},
}
store.initConnection()
require.NoError(t, store.initConnection())
store.stores.post = newSqlPostStore(store, mockMetrics)
err = store.migrate(migrationsDirectionUp)
require.NoError(t, err)
@@ -840,8 +842,10 @@ func TestMySQLReadTimeout(t *testing.T) {
store := &SqlStore{
settings: settings,
quitMonitor: make(chan struct{}),
wgMonitor: &sync.WaitGroup{},
}
store.initConnection()
require.NoError(t, store.initConnection())
defer store.Close()
_, err = store.GetMasterX().ExecNoTimeout(`SELECT SLEEP(3)`)

Просмотреть файл

@@ -688,6 +688,28 @@ func (s *SqlThreadStore) UpdateMembership(membership *model.ThreadMembership) (*
return s.updateMembership(s.GetMasterX(), membership)
}
func (s *SqlThreadStore) DeleteMembershipsForChannel(userID, channelID string) error {
subQuery := s.getSubQueryBuilder().
Select("1").
From("Threads").
Where(sq.And{
sq.Expr("Threads.PostId = ThreadMemberships.PostId"),
sq.Eq{"Threads.ChannelId": channelID},
})
query := s.getQueryBuilder().
Delete("ThreadMemberships").
Where(sq.Eq{"UserId": userID}).
Where(sq.Expr("EXISTS (?)", subQuery))
_, err := s.GetMasterX().ExecBuilder(query)
if err != nil {
return errors.Wrapf(err, "failed to remove thread memberships with userid=%s channelid=%s", userID, channelID)
}
return nil
}
func (s *SqlThreadStore) updateMembership(ex sqlxExecutor, membership *model.ThreadMembership) (*model.ThreadMembership, error) {
query := s.getQueryBuilder().
Update("ThreadMemberships").
@@ -712,7 +734,14 @@ func (s *SqlThreadStore) GetMembershipsForUser(userId, teamId string) ([]*model.
memberships := []*model.ThreadMembership{}
query := s.getQueryBuilder().
Select("ThreadMemberships.*").
Select(
"ThreadMemberships.PostId",
"ThreadMemberships.UserId",
"ThreadMemberships.Following",
"ThreadMemberships.LastUpdated",
"ThreadMemberships.LastViewed",
"ThreadMemberships.UnreadMentions",
).
Join("Threads ON Threads.PostId = ThreadMemberships.PostId").
From("ThreadMemberships").
Where(sq.Or{sq.Eq{"Threads.ThreadTeamId": teamId}, sq.Eq{"Threads.ThreadTeamId": ""}}).
@@ -732,7 +761,14 @@ func (s *SqlThreadStore) GetMembershipForUser(userId, postId string) (*model.Thr
func (s *SqlThreadStore) getMembershipForUser(ex sqlxExecutor, userId, postId string) (*model.ThreadMembership, error) {
var membership model.ThreadMembership
query := s.getQueryBuilder().
Select("*").
Select(
"PostId",
"UserId",
"Following",
"LastUpdated",
"LastViewed",
"UnreadMentions",
).
From("ThreadMemberships").
Where(sq.And{
sq.Eq{"PostId": postId},

Просмотреть файл

@@ -72,10 +72,7 @@ type Store interface {
// GetInternalMasterDB allows access to the raw master DB
// handle for the multi-product architecture.
GetInternalMasterDB() *sql.DB
// GetInternalReplicaDBs allows access to the raw replica DB
// handles for the multi-product architecture.
GetInternalReplicaDB() *sql.DB
GetInternalReplicaDBs() []*sql.DB
TotalMasterDbConnections() int
TotalReadDbConnections() int
TotalSearchDbConnections() int
@@ -347,6 +344,7 @@ type ThreadStore interface {
PermanentDeleteBatchThreadMembershipsForRetentionPolicies(now, globalPolicyEndTime, limit int64, cursor model.RetentionPolicyCursor) (int64, model.RetentionPolicyCursor, error)
DeleteOrphanedRows(limit int) (deleted int64, err error)
GetThreadUnreadReplyCount(threadMembership *model.ThreadMembership) (int64, error)
DeleteMembershipsForChannel(userID, channelID string) error
// Insights - threads
GetTopThreadsForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopThreadList, error)

Просмотреть файл

@@ -672,6 +672,38 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) {
require.NoError(t, err)
assert.Equal(t, []string{}, res2.Channels)
})
t.Run("should store the correct sorting value", func(t *testing.T) {
userId := model.NewId()
team := setupTeam(t, ss, userId)
opts := &store.SidebarCategorySearchOpts{
TeamID: team.Id,
ExcludeTeam: false,
}
res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts)
require.NoError(t, nErr)
require.NotEmpty(t, res)
// Create the category
created, err := ss.Channel().CreateSidebarCategory(userId, team.Id, &model.SidebarCategoryWithChannels{
SidebarCategory: model.SidebarCategory{
DisplayName: model.NewId(),
Sorting: model.SidebarCategorySortManual,
},
})
require.NoError(t, err)
// Confirm that sorting value is correct
res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id)
require.NoError(t, err)
require.Len(t, res.Categories, 4)
// first category will be favorites and second will be newly created
assert.Equal(t, model.SidebarCategoryCustom, res.Categories[1].Type)
assert.Equal(t, created.Id, res.Categories[1].Id)
assert.Equal(t, model.SidebarCategorySortManual, res.Categories[1].Sorting)
assert.Equal(t, model.SidebarCategorySortManual, created.Sorting)
})
}
func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) {

Просмотреть файл

@@ -346,22 +346,6 @@ func (_m *Store) GetInternalReplicaDB() *sql.DB {
return r0
}
// GetInternalReplicaDBs provides a mock function with given fields:
func (_m *Store) GetInternalReplicaDBs() []*sql.DB {
ret := _m.Called()
var r0 []*sql.DB
if rf, ok := ret.Get(0).(func() []*sql.DB); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*sql.DB)
}
}
return r0
}
// Group provides a mock function with given fields:
func (_m *Store) Group() store.GroupStore {
ret := _m.Called()

Просмотреть файл

@@ -29,6 +29,20 @@ func (_m *ThreadStore) DeleteMembershipForUser(userId string, postID string) err
return r0
}
// DeleteMembershipsForChannel provides a mock function with given fields: userID, channelID
func (_m *ThreadStore) DeleteMembershipsForChannel(userID string, channelID string) error {
ret := _m.Called(userID, channelID)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(userID, channelID)
} else {
r0 = ret.Error(0)
}
return r0
}
// DeleteOrphanedRows provides a mock function with given fields: limit
func (_m *ThreadStore) DeleteOrphanedRows(limit int) (int64, error) {
ret := _m.Called(limit)

Просмотреть файл

@@ -261,6 +261,7 @@ func MakeSqlSettings(driver string, withReplica bool) *model.SqlSettings {
}
log("Created temporary " + driver + " database " + dbName)
settings.ReplicaMonitorIntervalSeconds = model.NewInt(5)
return settings
}

Просмотреть файл

@@ -29,6 +29,7 @@ func TestThreadStore(t *testing.T, ss store.Store, s SqlStore) {
t.Run("MarkAllAsReadByChannels", func(t *testing.T) { testMarkAllAsReadByChannels(t, ss) })
t.Run("GetTopThreads", func(t *testing.T) { testGetTopThreads(t, ss) })
t.Run("MarkAllAsReadByTeam", func(t *testing.T) { testMarkAllAsReadByTeam(t, ss) })
t.Run("DeleteMembershipsForChannel", func(t *testing.T) { testDeleteMembershipsForChannel(t, ss) })
}
func testThreadStorePopulation(t *testing.T, ss store.Store) {
@@ -1914,3 +1915,121 @@ func testMarkAllAsReadByTeam(t *testing.T, ss store.Store) {
assertThreadReplyCount(t, userBID, team2.Id, 1, "expected 1 unread message in team2 for userB")
})
}
func testDeleteMembershipsForChannel(t *testing.T, ss store.Store) {
createThreadMembership := func(userID, postID string) (*model.ThreadMembership, func()) {
t.Helper()
opts := store.ThreadMembershipOpts{
Following: true,
IncrementMentions: false,
UpdateFollowing: true,
UpdateViewedTimestamp: false,
UpdateParticipants: false,
}
mem, err := ss.Thread().MaintainMembership(userID, postID, opts)
require.NoError(t, err)
return mem, func() {
err := ss.Thread().DeleteMembershipForUser(userID, postID)
require.NoError(t, err)
}
}
postingUserID := model.NewId()
userAID := model.NewId()
userBID := model.NewId()
team, err := ss.Team().Save(&model.Team{
DisplayName: "DisplayName",
Name: "team" + model.NewId(),
Email: MakeEmail(),
Type: model.TeamOpen,
})
require.NoError(t, err)
channel1, err := ss.Channel().Save(&model.Channel{
TeamId: team.Id,
DisplayName: "DisplayName",
Name: "channel1" + model.NewId(),
Type: model.ChannelTypeOpen,
}, -1)
require.NoError(t, err)
channel2, err := ss.Channel().Save(&model.Channel{
TeamId: team.Id,
DisplayName: "DisplayName2",
Name: "channel2" + model.NewId(),
Type: model.ChannelTypeOpen,
}, -1)
require.NoError(t, err)
rootPost1, err := ss.Post().Save(&model.Post{
ChannelId: channel1.Id,
UserId: postingUserID,
Message: model.NewRandomString(10),
})
require.NoError(t, err)
_, err = ss.Post().Save(&model.Post{
ChannelId: channel1.Id,
UserId: postingUserID,
Message: model.NewRandomString(10),
RootId: rootPost1.Id,
})
require.NoError(t, err)
rootPost2, err := ss.Post().Save(&model.Post{
ChannelId: channel2.Id,
UserId: postingUserID,
Message: model.NewRandomString(10),
})
require.NoError(t, err)
_, err = ss.Post().Save(&model.Post{
ChannelId: channel2.Id,
UserId: postingUserID,
Message: model.NewRandomString(10),
RootId: rootPost2.Id,
})
require.NoError(t, err)
t.Run("should return memberships for user", func(t *testing.T) {
memA1, cleanupA1 := createThreadMembership(userAID, rootPost1.Id)
defer cleanupA1()
memA2, cleanupA2 := createThreadMembership(userAID, rootPost2.Id)
defer cleanupA2()
membershipsA, err := ss.Thread().GetMembershipsForUser(userAID, team.Id)
require.NoError(t, err)
require.Len(t, membershipsA, 2)
require.ElementsMatch(t, []*model.ThreadMembership{memA1, memA2}, membershipsA)
})
t.Run("should delete memberships for user for channel", func(t *testing.T) {
_, cleanupA1 := createThreadMembership(userAID, rootPost1.Id)
defer cleanupA1()
memA2, cleanupA2 := createThreadMembership(userAID, rootPost2.Id)
defer cleanupA2()
ss.Thread().DeleteMembershipsForChannel(userAID, channel1.Id)
membershipsA, err := ss.Thread().GetMembershipsForUser(userAID, team.Id)
require.NoError(t, err)
require.Len(t, membershipsA, 1)
require.ElementsMatch(t, []*model.ThreadMembership{memA2}, membershipsA)
})
t.Run("deleting memberships for channel for userA should not affect userB", func(t *testing.T) {
_, cleanupA1 := createThreadMembership(userAID, rootPost1.Id)
defer cleanupA1()
_, cleanupA2 := createThreadMembership(userAID, rootPost2.Id)
defer cleanupA2()
memB1, cleanupB2 := createThreadMembership(userBID, rootPost1.Id)
defer cleanupB2()
membershipsB, err := ss.Thread().GetMembershipsForUser(userBID, team.Id)
require.NoError(t, err)
require.Len(t, membershipsB, 1)
require.ElementsMatch(t, []*model.ThreadMembership{memB1}, membershipsB)
})
}

Просмотреть файл

@@ -9112,6 +9112,22 @@ func (s *TimerLayerThreadStore) DeleteMembershipForUser(userId string, postID st
return err
}
func (s *TimerLayerThreadStore) DeleteMembershipsForChannel(userID string, channelID string) error {
start := time.Now()
err := s.ThreadStore.DeleteMembershipsForChannel(userID, channelID)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
success := "false"
if err == nil {
success = "true"
}
s.Root.Metrics.ObserveStoreMethodDuration("ThreadStore.DeleteMembershipsForChannel", success, elapsed)
}
return err
}
func (s *TimerLayerThreadStore) DeleteOrphanedRows(limit int) (int64, error) {
start := time.Now()

Просмотреть файл

@@ -58,6 +58,11 @@ func NewMainHelperWithOptions(options *HelperOptions) *MainHelper {
os.Unsetenv("MM_SQLSETTINGS_DATASOURCE")
}
// Unset environment variables commonly set for development that interfere with tests.
os.Unsetenv("MM_SERVICESETTINGS_SITEURL")
os.Unsetenv("MM_SERVICESETTINGS_LISTENADDRESS")
os.Unsetenv("MM_SERVICESETTINGS_ENABLEDEVELOPER")
var mainHelper MainHelper
flag.Parse()
@@ -331,7 +336,7 @@ func (h *MainHelper) SetReplicationLagForTesting(seconds int) error {
func (h *MainHelper) execOnEachReplica(query string, args ...any) error {
for _, replica := range h.SQLStore.ReplicaXs {
_, err := replica.Exec(query, args...)
_, err := replica.Load().Exec(query, args...)
if err != nil {
return err
}

Просмотреть файл

@@ -210,7 +210,6 @@ func GetSanitizedClientLicense(l map[string]string) map[string]string {
delete(sanitizedLicense, "StartsAt")
delete(sanitizedLicense, "ExpiresAt")
delete(sanitizedLicense, "SkuName")
delete(sanitizedLicense, "SkuShortName")
return sanitizedLicense
}

Просмотреть файл

@@ -94,6 +94,8 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
props["CWSURL"] = *c.CloudSettings.CWSURL
props["DisableRefetchingOnBrowserFocus"] = strconv.FormatBool(*c.ExperimentalSettings.DisableRefetchingOnBrowserFocus)
// Set default values for all options that require a license.
props["ExperimentalEnableAuthenticationTransfer"] = "true"
props["LdapNicknameAttributeSet"] = "false"

Просмотреть файл

@@ -1777,6 +1777,10 @@
"id": "api.error_get_first_admin_visit_marketplace_status",
"translation": "Error trying to retrieve the first admin visit marketplace status from the store."
},
{
"id": "api.error_no_organization_name_provided_for_self_hosted_onboarding",
"translation": "Error no organization name provided for self hosted onboarding."
},
{
"id": "api.error_set_first_admin_complete_setup",
"translation": "Error trying to save first admin complete setup in the store."

Просмотреть файл

@@ -179,6 +179,7 @@ type Subscription struct {
DelinquentSince *int64 `json:"delinquent_since"`
OriginallyLicensedSeats int `json:"originally_licensed_seats"`
ComplianceBlocked string `json:"compliance_blocked"`
BillingType string `json:"billing_type"`
}
// Subscription History model represents true up event in a yearly subscription

Просмотреть файл

@@ -974,6 +974,7 @@ type ExperimentalSettings struct {
EnableRemoteClusterService *bool `access:"experimental_features"`
EnableAppBar *bool `access:"experimental_features"`
PatchPluginsReactDOM *bool `access:"experimental_features"`
DisableRefetchingOnBrowserFocus *bool `access:"experimental_features"`
}
func (s *ExperimentalSettings) SetDefaults() {
@@ -1012,6 +1013,10 @@ func (s *ExperimentalSettings) SetDefaults() {
if s.PatchPluginsReactDOM == nil {
s.PatchPluginsReactDOM = NewBool(false)
}
if s.DisableRefetchingOnBrowserFocus == nil {
s.DisableRefetchingOnBrowserFocus = NewBool(false)
}
}
type AnalyticsSettings struct {
@@ -1163,6 +1168,7 @@ type SqlSettings struct {
DisableDatabaseSearch *bool `access:"environment_database,write_restrictable,cloud_restrictable"`
MigrationsStatementTimeoutSeconds *int `access:"environment_database,write_restrictable,cloud_restrictable"`
ReplicaLagSettings []*ReplicaLagSettings `access:"environment_database,write_restrictable,cloud_restrictable"` // telemetry: none
ReplicaMonitorIntervalSeconds *int `access:"environment_database,write_restrictable,cloud_restrictable"`
}
func (s *SqlSettings) SetDefaults(isUpdate bool) {
@@ -1227,6 +1233,10 @@ func (s *SqlSettings) SetDefaults(isUpdate bool) {
if s.ReplicaLagSettings == nil {
s.ReplicaLagSettings = []*ReplicaLagSettings{}
}
if s.ReplicaMonitorIntervalSeconds == nil {
s.ReplicaMonitorIntervalSeconds = NewInt(5)
}
}
type LogSettings struct {

Просмотреть файл

@@ -36,7 +36,8 @@ type SelfHostedCustomerForm struct {
type SelfHostedConfirmPaymentMethodRequest struct {
StripeSetupIntentID string `json:"stripe_setup_intent_id"`
Subscription CreateSubscriptionRequest `json:"subscription"`
Subscription *CreateSubscriptionRequest `json:"subscription"`
ExpandRequest *SelfHostedExpansionRequest `json:"expand_request"`
}
// SelfHostedSignupPaymentResponse contains feels needed for self hosted signup to confirm payment and receive license.
@@ -65,3 +66,8 @@ type SelfHostedBillingAccessRequest struct {
type SelfHostedBillingAccessResponse struct {
Token string `json:"token"`
}
type SelfHostedExpansionRequest struct {
Seats int `json:"seats"`
LicenseId string `json:"license_id"`
}

Просмотреть файл

@@ -10,6 +10,7 @@ import (
// CompleteOnboardingRequest describes parameters of the requested plugin.
type CompleteOnboardingRequest struct {
Organization string `json:"organization"` // Organization is the name of the organization
InstallPlugins []string `json:"install_plugins"` // InstallPlugins is a list of plugins to be installed
}

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше