Doug Lauder
2023-03-22 17:22:27 -04:00
коммит произвёл GitHub
родитель b61c096497
Коммит c943ed6859
13276 изменённых файлов: 1695615 добавлений и 223189 удалений

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

@@ -0,0 +1,119 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"io"
"sync"
"time"
"github.com/mattermost/mattermost-server/v6/server/boards/auth"
"github.com/mattermost/mattermost-server/v6/server/boards/services/config"
"github.com/mattermost/mattermost-server/v6/server/boards/services/metrics"
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions"
"github.com/mattermost/mattermost-server/v6/server/boards/services/store"
"github.com/mattermost/mattermost-server/v6/server/boards/services/webhook"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/mattermost/mattermost-server/v6/server/boards/ws"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
const (
blockChangeNotifierQueueSize = 1000
blockChangeNotifierPoolSize = 10
blockChangeNotifierShutdownTimeout = time.Second * 10
)
type servicesAPI interface {
GetUsersFromProfiles(options *mm_model.UserGetOptions) ([]*mm_model.User, error)
}
type ReadCloseSeeker = filestore.ReadCloseSeeker
type fileBackend interface {
Reader(path string) (ReadCloseSeeker, error)
FileExists(path string) (bool, error)
CopyFile(oldPath, newPath string) error
MoveFile(oldPath, newPath string) error
WriteFile(fr io.Reader, path string) (int64, error)
RemoveFile(path string) error
}
type Services struct {
Auth *auth.Auth
Store store.Store
FilesBackend fileBackend
Webhook *webhook.Client
Metrics *metrics.Metrics
Notifications *notify.Service
Logger mlog.LoggerIFace
Permissions permissions.PermissionsService
SkipTemplateInit bool
ServicesAPI servicesAPI
}
type App struct {
config *config.Configuration
store store.Store
auth *auth.Auth
wsAdapter ws.Adapter
filesBackend fileBackend
webhook *webhook.Client
metrics *metrics.Metrics
notifications *notify.Service
logger mlog.LoggerIFace
permissions permissions.PermissionsService
blockChangeNotifier *utils.CallbackQueue
servicesAPI servicesAPI
cardLimitMux sync.RWMutex
cardLimit int
}
func (a *App) SetConfig(config *config.Configuration) {
a.config = config
}
func (a *App) GetConfig() *config.Configuration {
return a.config
}
func New(config *config.Configuration, wsAdapter ws.Adapter, services Services) *App {
app := &App{
config: config,
store: services.Store,
auth: services.Auth,
wsAdapter: wsAdapter,
filesBackend: services.FilesBackend,
webhook: services.Webhook,
metrics: services.Metrics,
notifications: services.Notifications,
logger: services.Logger,
permissions: services.Permissions,
blockChangeNotifier: utils.NewCallbackQueue("blockChangeNotifier", blockChangeNotifierQueueSize, blockChangeNotifierPoolSize, services.Logger),
servicesAPI: services.ServicesAPI,
}
app.initialize(services.SkipTemplateInit)
return app
}
func (a *App) CardLimit() int {
a.cardLimitMux.RLock()
defer a.cardLimitMux.RUnlock()
return a.cardLimit
}
func (a *App) SetCardLimit(cardLimit int) {
a.cardLimitMux.Lock()
defer a.cardLimitMux.Unlock()
a.cardLimit = cardLimit
}
func (a *App) GetLicense() *mm_model.License {
return a.store.GetLicense()
}

26
server/boards/app/app_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,26 @@
// 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/v6/server/boards/services/config"
)
func TestSetConfig(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("Test Update Config", func(t *testing.T) {
require.False(t, th.App.config.EnablePublicSharedBoards)
newConfiguration := config.Configuration{}
newConfiguration.EnablePublicSharedBoards = true
th.App.SetConfig(&newConfiguration)
require.True(t, th.App.config.EnablePublicSharedBoards)
})
}

234
server/boards/app/auth.go Обычный файл
Просмотреть файл

@@ -0,0 +1,234 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/services/auth"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
"github.com/pkg/errors"
)
const (
DaysPerMonth = 30
DaysPerWeek = 7
HoursPerDay = 24
MinutesPerHour = 60
SecondsPerMinute = 60
)
// GetSession Get a user active session and refresh the session if is needed.
func (a *App) GetSession(token string) (*model.Session, error) {
return a.auth.GetSession(token)
}
// IsValidReadToken validates the read token for a block.
func (a *App) IsValidReadToken(boardID string, readToken string) (bool, error) {
return a.auth.IsValidReadToken(boardID, readToken)
}
// GetRegisteredUserCount returns the number of registered users.
func (a *App) GetRegisteredUserCount() (int, error) {
return a.store.GetRegisteredUserCount()
}
// GetDailyActiveUsers returns the number of daily active users.
func (a *App) GetDailyActiveUsers() (int, error) {
secondsAgo := int64(SecondsPerMinute * MinutesPerHour * HoursPerDay)
return a.store.GetActiveUserCount(secondsAgo)
}
// GetWeeklyActiveUsers returns the number of weekly active users.
func (a *App) GetWeeklyActiveUsers() (int, error) {
secondsAgo := int64(SecondsPerMinute * MinutesPerHour * HoursPerDay * DaysPerWeek)
return a.store.GetActiveUserCount(secondsAgo)
}
// GetMonthlyActiveUsers returns the number of monthly active users.
func (a *App) GetMonthlyActiveUsers() (int, error) {
secondsAgo := int64(SecondsPerMinute * MinutesPerHour * HoursPerDay * DaysPerMonth)
return a.store.GetActiveUserCount(secondsAgo)
}
// GetUser gets an existing active user by id.
func (a *App) GetUser(id string) (*model.User, error) {
if len(id) < 1 {
return nil, errors.New("no user ID")
}
user, err := a.store.GetUserByID(id)
if err != nil {
return nil, errors.Wrap(err, "unable to find user")
}
return user, nil
}
func (a *App) GetUsersList(userIDs []string) ([]*model.User, error) {
if len(userIDs) == 0 {
return nil, errors.New("No User IDs")
}
users, err := a.store.GetUsersList(userIDs, a.config.ShowEmailAddress, a.config.ShowFullName)
if err != nil {
return nil, errors.Wrap(err, "unable to find users")
}
return users, nil
}
// Login create a new user session if the authentication data is valid.
func (a *App) Login(username, email, password, mfaToken string) (string, error) {
var user *model.User
if username != "" {
var err error
user, err = a.store.GetUserByUsername(username)
if err != nil && !model.IsErrNotFound(err) {
a.metrics.IncrementLoginFailCount(1)
return "", errors.Wrap(err, "invalid username or password")
}
}
if user == nil && email != "" {
var err error
user, err = a.store.GetUserByEmail(email)
if err != nil && model.IsErrNotFound(err) {
a.metrics.IncrementLoginFailCount(1)
return "", errors.Wrap(err, "invalid username or password")
}
}
if user == nil {
a.metrics.IncrementLoginFailCount(1)
return "", errors.New("invalid username or password")
}
if !auth.ComparePassword(user.Password, password) {
a.metrics.IncrementLoginFailCount(1)
a.logger.Debug("Invalid password for user", mlog.String("userID", user.ID))
return "", errors.New("invalid username or password")
}
authService := user.AuthService
if authService == "" {
authService = "native"
}
session := model.Session{
ID: utils.NewID(utils.IDTypeSession),
Token: utils.NewID(utils.IDTypeToken),
UserID: user.ID,
AuthService: authService,
Props: map[string]interface{}{},
}
err := a.store.CreateSession(&session)
if err != nil {
return "", errors.Wrap(err, "unable to create session")
}
a.metrics.IncrementLoginCount(1)
// TODO: MFA verification
return session.Token, nil
}
// Logout invalidates the user session.
func (a *App) Logout(sessionID string) error {
err := a.store.DeleteSession(sessionID)
if err != nil {
return errors.Wrap(err, "unable to delete the session")
}
a.metrics.IncrementLogoutCount(1)
return nil
}
// RegisterUser creates a new user if the provided data is valid.
func (a *App) RegisterUser(username, email, password string) error {
var user *model.User
if username != "" {
var err error
user, err = a.store.GetUserByUsername(username)
if err != nil && !model.IsErrNotFound(err) {
return err
}
if user != nil {
return errors.New("The username already exists")
}
}
if user == nil && email != "" {
var err error
user, err = a.store.GetUserByEmail(email)
if err != nil && !model.IsErrNotFound(err) {
return err
}
if user != nil {
return errors.New("The email already exists")
}
}
// TODO: Move this into the config
passwordSettings := auth.PasswordSettings{
MinimumLength: 6,
}
err := auth.IsPasswordValid(password, passwordSettings)
if err != nil {
return errors.Wrap(err, "Invalid password")
}
_, err = a.store.CreateUser(&model.User{
ID: utils.NewID(utils.IDTypeUser),
Username: username,
Email: email,
Password: auth.HashPassword(password),
MfaSecret: "",
AuthService: a.config.AuthMode,
AuthData: "",
})
if err != nil {
return errors.Wrap(err, "Unable to create the new user")
}
return nil
}
func (a *App) UpdateUserPassword(username, password string) error {
err := a.store.UpdateUserPassword(username, auth.HashPassword(password))
if err != nil {
return err
}
return nil
}
func (a *App) ChangePassword(userID, oldPassword, newPassword string) error {
var user *model.User
if userID != "" {
var err error
user, err = a.store.GetUserByID(userID)
if err != nil {
return errors.Wrap(err, "invalid username or password")
}
}
if user == nil {
return errors.New("invalid username or password")
}
if !auth.ComparePassword(user.Password, oldPassword) {
a.logger.Debug("Invalid password for user", mlog.String("userID", user.ID))
return errors.New("invalid username or password")
}
err := a.store.UpdateUserPasswordByID(userID, auth.HashPassword(newPassword))
if err != nil {
return errors.Wrap(err, "unable to update password")
}
return nil
}

192
server/boards/app/auth_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,192 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/golang/mock/gomock"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/services/auth"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
)
var mockUser = &model.User{
ID: utils.NewID(utils.IDTypeUser),
Username: "testUsername",
Email: "testEmail",
Password: auth.HashPassword("testPassword"),
}
func TestLogin(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
testcases := []struct {
title string
userName string
email string
password string
mfa string
isError bool
}{
{"fail, missing login information", "", "", "", "", true},
{"fail, invalid username", "badUsername", "", "", "", true},
{"fail, invalid email", "", "badEmail", "", "", true},
{"fail, invalid password", "testUsername", "", "badPassword", "", true},
{"success, using username", "testUsername", "", "testPassword", "", false},
{"success, using email", "", "testEmail", "testPassword", "", false},
}
th.Store.EXPECT().GetUserByUsername("badUsername").Return(nil, errors.New("Bad Username"))
th.Store.EXPECT().GetUserByEmail("badEmail").Return(nil, errors.New("Bad Email"))
th.Store.EXPECT().GetUserByUsername("testUsername").Return(mockUser, nil).Times(2)
th.Store.EXPECT().GetUserByEmail("testEmail").Return(mockUser, nil)
th.Store.EXPECT().CreateSession(gomock.Any()).Return(nil).Times(2)
for _, test := range testcases {
t.Run(test.title, func(t *testing.T) {
token, err := th.App.Login(test.userName, test.email, test.password, test.mfa)
if test.isError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.NotNil(t, token)
}
})
}
}
func TestGetUser(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
testcases := []struct {
title string
id string
isError bool
}{
{"fail, missing id", "", true},
{"fail, invalid id", "badID", true},
{"success", "goodID", false},
}
th.Store.EXPECT().GetUserByID("badID").Return(nil, errors.New("Bad Id"))
th.Store.EXPECT().GetUserByID("goodID").Return(mockUser, nil)
for _, test := range testcases {
t.Run(test.title, func(t *testing.T) {
token, err := th.App.GetUser(test.id)
if test.isError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.NotNil(t, token)
}
})
}
}
func TestRegisterUser(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
testcases := []struct {
title string
userName string
email string
password string
isError bool
}{
{"fail, missing login information", "", "", "", true},
{"fail, username exists", "existingUsername", "", "", true},
{"fail, email exists", "", "existingEmail", "", true},
{"fail, invalid password", "newUsername", "", "test", true},
{"success, using email", "", "newEmail", "testPassword", false},
}
th.Store.EXPECT().GetUserByUsername("existingUsername").Return(mockUser, nil)
th.Store.EXPECT().GetUserByUsername("newUsername").Return(mockUser, errors.New("user not found"))
th.Store.EXPECT().GetUserByEmail("existingEmail").Return(mockUser, nil)
th.Store.EXPECT().GetUserByEmail("newEmail").Return(nil, model.NewErrNotFound("user"))
th.Store.EXPECT().CreateUser(gomock.Any()).Return(nil, nil)
for _, test := range testcases {
t.Run(test.title, func(t *testing.T) {
err := th.App.RegisterUser(test.userName, test.email, test.password)
if test.isError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestUpdateUserPassword(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
testcases := []struct {
title string
userName string
password string
isError bool
}{
{"fail, missing login information", "", "", true},
{"fail, invalid username", "badUsername", "", true},
{"success, username", "testUsername", "testPassword", false},
}
th.Store.EXPECT().UpdateUserPassword("", gomock.Any()).Return(errors.New("user not found"))
th.Store.EXPECT().UpdateUserPassword("badUsername", gomock.Any()).Return(errors.New("user not found"))
th.Store.EXPECT().UpdateUserPassword("testUsername", gomock.Any()).Return(nil)
for _, test := range testcases {
t.Run(test.title, func(t *testing.T) {
err := th.App.UpdateUserPassword(test.userName, test.password)
if test.isError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestChangePassword(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
testcases := []struct {
title string
userName string
oldPassword string
password string
isError bool
}{
{"fail, missing login information", "", "", "", true},
{"fail, invalid userId", "badID", "", "", true},
{"fail, invalid password", mockUser.ID, "wrongPassword", "newPassword", true},
{"success, using username", mockUser.ID, "testPassword", "newPassword", false},
}
th.Store.EXPECT().GetUserByID("badID").Return(nil, errors.New("userID not found"))
th.Store.EXPECT().GetUserByID(mockUser.ID).Return(mockUser, nil).Times(2)
th.Store.EXPECT().UpdateUserPasswordByID(mockUser.ID, gomock.Any()).Return(nil)
for _, test := range testcases {
t.Run(test.title, func(t *testing.T) {
err := th.App.ChangePassword(test.userName, test.oldPassword, test.password)
if test.isError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

588
server/boards/app/blocks.go Обычный файл
Просмотреть файл

@@ -0,0 +1,588 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
var ErrBlocksFromMultipleBoards = errors.New("the block set contain blocks from multiple boards")
func (a *App) GetBlocks(boardID, parentID string, blockType string) ([]*model.Block, error) {
if boardID == "" {
return []*model.Block{}, nil
}
if blockType != "" && parentID != "" {
return a.store.GetBlocksWithParentAndType(boardID, parentID, blockType)
}
if blockType != "" {
return a.store.GetBlocksWithType(boardID, blockType)
}
return a.store.GetBlocksWithParent(boardID, parentID)
}
func (a *App) DuplicateBlock(boardID string, blockID string, userID string, asTemplate bool) ([]*model.Block, error) {
board, err := a.GetBoard(boardID)
if err != nil {
return nil, err
}
if board == nil {
return nil, fmt.Errorf("cannot fetch board %s for DuplicateBlock: %w", boardID, err)
}
blocks, err := a.store.DuplicateBlock(boardID, blockID, userID, asTemplate)
if err != nil {
return nil, err
}
a.blockChangeNotifier.Enqueue(func() error {
for _, block := range blocks {
a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
}
return nil
})
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed duplicating a block",
mlog.Err(uErr),
)
}
}()
return blocks, err
}
func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedByID string) (*model.Block, error) {
return a.PatchBlockAndNotify(blockID, blockPatch, modifiedByID, false)
}
func (a *App) PatchBlockAndNotify(blockID string, blockPatch *model.BlockPatch, modifiedByID string, disableNotify bool) (*model.Block, error) {
oldBlock, err := a.store.GetBlock(blockID)
if err != nil {
return nil, err
}
if a.IsCloudLimited() {
containsLimitedBlocks, lErr := a.ContainsLimitedBlocks([]*model.Block{oldBlock})
if lErr != nil {
return nil, lErr
}
if containsLimitedBlocks {
return nil, model.ErrPatchUpdatesLimitedCards
}
}
board, err := a.store.GetBoard(oldBlock.BoardID)
if err != nil {
return nil, err
}
err = a.store.PatchBlock(blockID, blockPatch, modifiedByID)
if err != nil {
return nil, err
}
a.metrics.IncrementBlocksPatched(1)
block, err := a.store.GetBlock(blockID)
if err != nil {
return nil, err
}
a.blockChangeNotifier.Enqueue(func() error {
// broadcast on websocket
a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
// broadcast on webhooks
a.webhook.NotifyUpdate(block)
// send notifications
if !disableNotify {
a.notifyBlockChanged(notify.Update, block, oldBlock, modifiedByID)
}
return nil
})
return block, nil
}
func (a *App) PatchBlocks(teamID string, blockPatches *model.BlockPatchBatch, modifiedByID string) error {
return a.PatchBlocksAndNotify(teamID, blockPatches, modifiedByID, false)
}
func (a *App) PatchBlocksAndNotify(teamID string, blockPatches *model.BlockPatchBatch, modifiedByID string, disableNotify bool) error {
oldBlocks, err := a.store.GetBlocksByIDs(blockPatches.BlockIDs)
if err != nil {
return err
}
if a.IsCloudLimited() {
containsLimitedBlocks, err := a.ContainsLimitedBlocks(oldBlocks)
if err != nil {
return err
}
if containsLimitedBlocks {
return model.ErrPatchUpdatesLimitedCards
}
}
if err := a.store.PatchBlocks(blockPatches, modifiedByID); err != nil {
return err
}
a.blockChangeNotifier.Enqueue(func() error {
a.metrics.IncrementBlocksPatched(len(oldBlocks))
for i, blockID := range blockPatches.BlockIDs {
newBlock, err := a.store.GetBlock(blockID)
if err != nil {
return err
}
a.wsAdapter.BroadcastBlockChange(teamID, newBlock)
a.webhook.NotifyUpdate(newBlock)
if !disableNotify {
a.notifyBlockChanged(notify.Update, newBlock, oldBlocks[i], modifiedByID)
}
}
return nil
})
return nil
}
func (a *App) InsertBlock(block *model.Block, modifiedByID string) error {
return a.InsertBlockAndNotify(block, modifiedByID, false)
}
func (a *App) InsertBlockAndNotify(block *model.Block, modifiedByID string, disableNotify bool) error {
board, bErr := a.store.GetBoard(block.BoardID)
if bErr != nil {
return bErr
}
err := a.store.InsertBlock(block, modifiedByID)
if err == nil {
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
a.metrics.IncrementBlocksInserted(1)
a.webhook.NotifyUpdate(block)
if !disableNotify {
a.notifyBlockChanged(notify.Add, block, nil, modifiedByID)
}
return nil
})
}
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after inserting a block",
mlog.Err(uErr),
)
}
}()
return err
}
func (a *App) isWithinViewsLimit(boardID string, block *model.Block) (bool, error) {
// ToDo: Cloud Limits have been disabled by design. We should
// revisit the decision and update the related code accordingly
/*
limits, err := a.GetBoardsCloudLimits()
if err != nil {
return false, err
}
if limits.Views == model.LimitUnlimited {
return true, nil
}
views, err := a.store.GetBlocksWithParentAndType(boardID, block.ParentID, model.TypeView)
if err != nil {
return false, err
}
// < rather than <= because we'll be creating new view if this
// check passes. When that view is created, the limit will be reached.
// That's why we need to check for if existing + the being-created
// view doesn't exceed the limit.
return len(views) < limits.Views, nil
*/
return true, nil
}
func (a *App) InsertBlocks(blocks []*model.Block, modifiedByID string) ([]*model.Block, error) {
return a.InsertBlocksAndNotify(blocks, modifiedByID, false)
}
func (a *App) InsertBlocksAndNotify(blocks []*model.Block, modifiedByID string, disableNotify bool) ([]*model.Block, error) {
if len(blocks) == 0 {
return []*model.Block{}, nil
}
// all blocks must belong to the same board
boardID := blocks[0].BoardID
for _, block := range blocks {
if block.BoardID != boardID {
return nil, ErrBlocksFromMultipleBoards
}
}
board, err := a.store.GetBoard(boardID)
if err != nil {
return nil, err
}
needsNotify := make([]*model.Block, 0, len(blocks))
for i := range blocks {
// this check is needed to whitelist inbuilt template
// initialization. They do contain more than 5 views per board.
if boardID != "0" && blocks[i].Type == model.TypeView {
withinLimit, err := a.isWithinViewsLimit(board.ID, blocks[i])
if err != nil {
return nil, err
}
if !withinLimit {
a.logger.Info("views limit reached on board", mlog.String("board_id", blocks[i].ParentID), mlog.String("team_id", board.TeamID))
return nil, model.ErrViewsLimitReached
}
}
err := a.store.InsertBlock(blocks[i], modifiedByID)
if err != nil {
return nil, err
}
needsNotify = append(needsNotify, blocks[i])
a.wsAdapter.BroadcastBlockChange(board.TeamID, blocks[i])
a.metrics.IncrementBlocksInserted(1)
}
a.blockChangeNotifier.Enqueue(func() error {
for _, b := range needsNotify {
block := b
a.webhook.NotifyUpdate(block)
if !disableNotify {
a.notifyBlockChanged(notify.Add, block, nil, modifiedByID)
}
}
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after inserting blocks",
mlog.Err(err),
)
}
}()
return blocks, nil
}
func (a *App) CopyCardFiles(sourceBoardID string, copiedBlocks []*model.Block) error {
// Images attached in cards have a path comprising the card's board ID.
// When we create a template from this board, we need to copy the files
// with the new board ID in path.
// Not doing so causing images in templates (and boards created from this
// template) to fail to load.
// look up ID of source sourceBoard, which may be different than the blocks.
sourceBoard, err := a.GetBoard(sourceBoardID)
if err != nil || sourceBoard == nil {
return fmt.Errorf("cannot fetch source board %s for CopyCardFiles: %w", sourceBoardID, err)
}
var destTeamID string
var destBoardID string
for i := range copiedBlocks {
block := copiedBlocks[i]
fileName := ""
isOk := false
switch block.Type {
case model.TypeImage:
fileName, isOk = block.Fields["fileId"].(string)
if !isOk || fileName == "" {
continue
}
case model.TypeAttachment:
fileName, isOk = block.Fields["attachmentId"].(string)
if !isOk || fileName == "" {
continue
}
default:
continue
}
// create unique filename in case we are copying cards within the same board.
ext := filepath.Ext(fileName)
destFilename := utils.NewID(utils.IDTypeNone) + ext
if destBoardID == "" || block.BoardID != destBoardID {
destBoardID = block.BoardID
destBoard, err := a.GetBoard(destBoardID)
if err != nil {
return fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err)
}
destTeamID = destBoard.TeamID
}
sourceFilePath := filepath.Join(sourceBoard.TeamID, sourceBoard.ID, fileName)
destinationFilePath := filepath.Join(destTeamID, block.BoardID, destFilename)
a.logger.Debug(
"Copying card file",
mlog.String("sourceFilePath", sourceFilePath),
mlog.String("destinationFilePath", destinationFilePath),
)
if err := a.filesBackend.CopyFile(sourceFilePath, destinationFilePath); err != nil {
a.logger.Error(
"CopyCardFiles failed to copy file",
mlog.String("sourceFilePath", sourceFilePath),
mlog.String("destinationFilePath", destinationFilePath),
mlog.Err(err),
)
}
if block.Type == model.TypeAttachment {
block.Fields["attachmentId"] = destFilename
parts := strings.Split(fileName, ".")
fileInfoID := parts[0][1:]
fileInfo, err := a.store.GetFileInfo(fileInfoID)
if err != nil {
return fmt.Errorf("CopyCardFiles: cannot retrieve original fileinfo: %w", err)
}
newParts := strings.Split(destFilename, ".")
newFileID := newParts[0][1:]
fileInfo.Id = newFileID
err = a.store.SaveFileInfo(fileInfo)
if err != nil {
return fmt.Errorf("CopyCardFiles: cannot create fileinfo: %w", err)
}
} else {
block.Fields["fileId"] = destFilename
}
}
return nil
}
func (a *App) GetBlockByID(blockID string) (*model.Block, error) {
return a.store.GetBlock(blockID)
}
func (a *App) DeleteBlock(blockID string, modifiedBy string) error {
return a.DeleteBlockAndNotify(blockID, modifiedBy, false)
}
func (a *App) DeleteBlockAndNotify(blockID string, modifiedBy string, disableNotify bool) error {
block, err := a.store.GetBlock(blockID)
if err != nil {
return err
}
board, err := a.store.GetBoard(block.BoardID)
if err != nil {
return err
}
if block == nil {
// deleting non-existing block not considered an error
return nil
}
err = a.store.DeleteBlock(blockID, modifiedBy)
if err != nil {
return err
}
if block.Type == model.TypeImage {
fileName, fileIDExists := block.Fields["fileId"]
if fileName, fileIDIsString := fileName.(string); fileIDExists && fileIDIsString {
filePath := filepath.Join(block.BoardID, fileName)
err = a.filesBackend.RemoveFile(filePath)
if err != nil {
a.logger.Error("Error deleting image file",
mlog.String("FilePath", filePath),
mlog.Err(err))
}
}
}
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastBlockDelete(board.TeamID, blockID, block.BoardID)
a.metrics.IncrementBlocksDeleted(1)
if !disableNotify {
a.notifyBlockChanged(notify.Delete, block, block, modifiedBy)
}
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after deleting a block",
mlog.Err(err),
)
}
}()
return nil
}
func (a *App) GetLastBlockHistoryEntry(blockID string) (*model.Block, error) {
blocks, err := a.store.GetBlockHistory(blockID, model.QueryBlockHistoryOptions{Limit: 1, Descending: true})
if err != nil {
return nil, err
}
if len(blocks) == 0 {
return nil, nil
}
return blocks[0], nil
}
func (a *App) UndeleteBlock(blockID string, modifiedBy string) (*model.Block, error) {
blocks, err := a.store.GetBlockHistory(blockID, model.QueryBlockHistoryOptions{Limit: 1, Descending: true})
if err != nil {
return nil, err
}
if len(blocks) == 0 {
// undeleting non-existing block not considered an error
return nil, nil
}
err = a.store.UndeleteBlock(blockID, modifiedBy)
if err != nil {
return nil, err
}
block, err := a.store.GetBlock(blockID)
if model.IsErrNotFound(err) {
a.logger.Error("Error loading the block after a successful undelete, not propagating through websockets or notifications", mlog.String("blockID", blockID))
return nil, err
}
if err != nil {
return nil, err
}
board, err := a.store.GetBoard(block.BoardID)
if err != nil {
return nil, err
}
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastBlockChange(board.TeamID, block)
a.metrics.IncrementBlocksInserted(1)
a.webhook.NotifyUpdate(block)
a.notifyBlockChanged(notify.Add, block, nil, modifiedBy)
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after undeleting a block",
mlog.Err(err),
)
}
}()
return block, nil
}
func (a *App) GetBlockCountsByType() (map[string]int64, error) {
return a.store.GetBlockCountsByType()
}
func (a *App) GetBlocksForBoard(boardID string) ([]*model.Block, error) {
return a.store.GetBlocksForBoard(boardID)
}
func (a *App) notifyBlockChanged(action notify.Action, block *model.Block, oldBlock *model.Block, modifiedByID string) {
// don't notify if notifications service disabled, or block change is generated via system user.
if a.notifications == nil || modifiedByID == model.SystemUserID {
return
}
// find card and board for the changed block.
board, card, err := a.getBoardAndCard(block)
if err != nil {
a.logger.Error("Error notifying for block change; cannot determine board or card", mlog.Err(err))
return
}
boardMember, _ := a.GetMemberForBoard(board.ID, modifiedByID)
if boardMember == nil {
// create temporary guest board member
boardMember = &model.BoardMember{
BoardID: board.ID,
UserID: modifiedByID,
}
}
evt := notify.BlockChangeEvent{
Action: action,
TeamID: board.TeamID,
Board: board,
Card: card,
BlockChanged: block,
BlockOld: oldBlock,
ModifiedBy: boardMember,
}
a.notifications.BlockChanged(evt)
}
const (
maxSearchDepth = 50
)
// getBoardAndCard returns the first parent of type `card` its board for the specified block.
// `board` and/or `card` may return nil without error if the block does not belong to a board or card.
func (a *App) getBoardAndCard(block *model.Block) (board *model.Board, card *model.Block, err error) {
board, err = a.store.GetBoard(block.BoardID)
if err != nil {
return board, card, err
}
var count int // don't let invalid blocks hierarchy cause infinite loop.
iter := block
for {
count++
if card == nil && iter.Type == model.TypeCard {
card = iter
}
if iter.ParentID == "" || (board != nil && card != nil) || count > maxSearchDepth {
break
}
iter, err = a.store.GetBlock(iter.ParentID)
if model.IsErrNotFound(err) {
return board, card, nil
}
if err != nil {
return board, card, err
}
}
return board, card, nil
}

417
server/boards/app/blocks_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,417 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"database/sql"
"testing"
"github.com/stretchr/testify/assert"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
type blockError struct {
msg string
}
func (be blockError) Error() string {
return be.msg
}
func TestInsertBlock(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("success scenario", func(t *testing.T) {
boardID := testBoardID
block := &model.Block{BoardID: boardID}
board := &model.Board{ID: boardID}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(nil)
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
err := th.App.InsertBlock(block, "user-id-1")
require.NoError(t, err)
})
t.Run("error scenario", func(t *testing.T) {
boardID := testBoardID
block := &model.Block{BoardID: boardID}
board := &model.Board{ID: boardID}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(blockError{"error"})
err := th.App.InsertBlock(block, "user-id-1")
require.Error(t, err, "error")
})
}
func TestPatchBlocks(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("patchBlocks success scenario", func(t *testing.T) {
blockPatches := model.BlockPatchBatch{
BlockIDs: []string{"block1"},
BlockPatches: []model.BlockPatch{
{Title: mm_model.NewString("new title")},
},
}
block1 := &model.Block{ID: "block1"}
th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]*model.Block{block1}, nil)
th.Store.EXPECT().PatchBlocks(gomock.Eq(&blockPatches), gomock.Eq("user-id-1")).Return(nil)
th.Store.EXPECT().GetBlock("block1").Return(block1, nil)
// this call comes from the WS server notification
th.Store.EXPECT().GetMembersForBoard(gomock.Any()).Times(1)
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
require.NoError(t, err)
})
t.Run("patchBlocks error scenario", func(t *testing.T) {
blockPatches := model.BlockPatchBatch{BlockIDs: []string{}}
th.Store.EXPECT().GetBlocksByIDs([]string{}).Return(nil, sql.ErrNoRows)
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
require.ErrorIs(t, err, sql.ErrNoRows)
})
t.Run("cloud limit error scenario", func(t *testing.T) {
t.Skipf("The Cloud Limits feature has been disabled")
th.App.SetCardLimit(5)
fakeLicense := &mm_model.License{
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
}
blockPatches := model.BlockPatchBatch{
BlockIDs: []string{"block1"},
BlockPatches: []model.BlockPatch{
{Title: mm_model.NewString("new title")},
},
}
block1 := &model.Block{
ID: "block1",
Type: model.TypeCard,
ParentID: "board-id",
BoardID: "board-id",
UpdateAt: 100,
}
board1 := &model.Board{
ID: "board-id",
Type: model.BoardTypeOpen,
}
th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]*model.Block{block1}, nil)
th.Store.EXPECT().GetBoard("board-id").Return(board1, nil)
th.Store.EXPECT().GetLicense().Return(fakeLicense)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(150), nil)
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
require.ErrorIs(t, err, model.ErrPatchUpdatesLimitedCards)
})
}
func TestDeleteBlock(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("success scenario", func(t *testing.T) {
boardID := testBoardID
board := &model.Board{ID: boardID}
block := &model.Block{
ID: "block-id",
BoardID: board.ID,
}
th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(block, nil)
th.Store.EXPECT().DeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(nil)
th.Store.EXPECT().GetBoard(gomock.Eq(testBoardID)).Return(board, nil)
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
err := th.App.DeleteBlock("block-id", "user-id-1")
require.NoError(t, err)
})
t.Run("error scenario", func(t *testing.T) {
boardID := testBoardID
board := &model.Board{ID: boardID}
block := &model.Block{
ID: "block-id",
BoardID: board.ID,
}
th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(block, nil)
th.Store.EXPECT().DeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(blockError{"error"})
th.Store.EXPECT().GetBoard(gomock.Eq(testBoardID)).Return(board, nil)
err := th.App.DeleteBlock("block-id", "user-id-1")
require.Error(t, err, "error")
})
}
func TestUndeleteBlock(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("success scenario", func(t *testing.T) {
boardID := testBoardID
board := &model.Board{ID: boardID}
block := &model.Block{
ID: "block-id",
BoardID: board.ID,
}
th.Store.EXPECT().GetBlockHistory(
gomock.Eq("block-id"),
gomock.Eq(model.QueryBlockHistoryOptions{Limit: 1, Descending: true}),
).Return([]*model.Block{block}, nil)
th.Store.EXPECT().UndeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(nil)
th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(block, nil)
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
_, err := th.App.UndeleteBlock("block-id", "user-id-1")
require.NoError(t, err)
})
t.Run("error scenario", func(t *testing.T) {
block := &model.Block{
ID: "block-id",
}
th.Store.EXPECT().GetBlockHistory(
gomock.Eq("block-id"),
gomock.Eq(model.QueryBlockHistoryOptions{Limit: 1, Descending: true}),
).Return([]*model.Block{block}, nil)
th.Store.EXPECT().UndeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(blockError{"error"})
_, err := th.App.UndeleteBlock("block-id", "user-id-1")
require.Error(t, err, "error")
})
}
func TestIsWithinViewsLimit(t *testing.T) {
t.Skipf("The Cloud Limits feature has been disabled")
th, tearDown := SetupTestHelper(t)
defer tearDown()
fakeLicense := &mm_model.License{
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
}
t.Run("within views limit", func(t *testing.T) {
th.Store.EXPECT().GetLicense().Return(fakeLicense)
cloudLimit := &mm_model.ProductLimits{
Boards: &mm_model.BoardsLimits{
Views: mm_model.NewInt(2),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]*model.Block{{}}, nil)
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
assert.NoError(t, err)
assert.True(t, withinLimits)
})
t.Run("view limit exactly reached", func(t *testing.T) {
th.Store.EXPECT().GetLicense().Return(fakeLicense)
cloudLimit := &mm_model.ProductLimits{
Boards: &mm_model.BoardsLimits{
Views: mm_model.NewInt(1),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]*model.Block{{}}, nil)
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
assert.NoError(t, err)
assert.False(t, withinLimits)
})
t.Run("view limit already exceeded", func(t *testing.T) {
th.Store.EXPECT().GetLicense().Return(fakeLicense)
cloudLimit := &mm_model.ProductLimits{
Boards: &mm_model.BoardsLimits{
Views: mm_model.NewInt(2),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]*model.Block{{}, {}, {}}, nil)
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
assert.NoError(t, err)
assert.False(t, withinLimits)
})
t.Run("creating first view", func(t *testing.T) {
th.Store.EXPECT().GetLicense().Return(fakeLicense)
cloudLimit := &mm_model.ProductLimits{
Boards: &mm_model.BoardsLimits{
Views: mm_model.NewInt(2),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]*model.Block{}, nil)
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
assert.NoError(t, err)
assert.True(t, withinLimits)
})
t.Run("is not a cloud SKU so limits don't apply", func(t *testing.T) {
nonCloudLicense := &mm_model.License{
Features: &mm_model.Features{Cloud: mm_model.NewBool(false)},
}
th.Store.EXPECT().GetLicense().Return(nonCloudLicense)
withinLimits, err := th.App.isWithinViewsLimit("board_id", &model.Block{ParentID: "parent_id"})
assert.NoError(t, err)
assert.True(t, withinLimits)
})
}
func TestInsertBlocks(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("success scenario", func(t *testing.T) {
boardID := testBoardID
block := &model.Block{BoardID: boardID}
board := &model.Board{ID: boardID}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(nil)
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
_, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1")
require.NoError(t, err)
})
t.Run("error scenario", func(t *testing.T) {
boardID := testBoardID
block := &model.Block{BoardID: boardID}
board := &model.Board{ID: boardID}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(blockError{"error"})
_, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1")
require.Error(t, err, "error")
})
t.Run("create view within limits", func(t *testing.T) {
t.Skipf("The Cloud Limits feature has been disabled")
boardID := testBoardID
block := &model.Block{
Type: model.TypeView,
ParentID: "parent_id",
BoardID: boardID,
}
board := &model.Board{ID: boardID}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
th.Store.EXPECT().InsertBlock(block, "user-id-1").Return(nil)
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
// setting up mocks for limits
fakeLicense := &mm_model.License{
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
cloudLimit := &mm_model.ProductLimits{
Boards: &mm_model.BoardsLimits{
Views: mm_model.NewInt(2),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]*model.Block{{}}, nil)
_, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1")
require.NoError(t, err)
})
t.Run("create view exceeding limits", func(t *testing.T) {
t.Skipf("The Cloud Limits feature has been disabled")
boardID := testBoardID
block := &model.Block{
Type: model.TypeView,
ParentID: "parent_id",
BoardID: boardID,
}
board := &model.Board{ID: boardID}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
// setting up mocks for limits
fakeLicense := &mm_model.License{
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
cloudLimit := &mm_model.ProductLimits{
Boards: &mm_model.BoardsLimits{
Views: mm_model.NewInt(2),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]*model.Block{{}, {}}, nil)
_, err := th.App.InsertBlocks([]*model.Block{block}, "user-id-1")
require.Error(t, err)
})
t.Run("creating multiple views, reaching limit in the process", func(t *testing.T) {
t.Skipf("Will be fixed soon")
boardID := testBoardID
view1 := &model.Block{
Type: model.TypeView,
ParentID: "parent_id",
BoardID: boardID,
}
view2 := &model.Block{
Type: model.TypeView,
ParentID: "parent_id",
BoardID: boardID,
}
board := &model.Board{ID: boardID}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
th.Store.EXPECT().InsertBlock(view1, "user-id-1").Return(nil).Times(2)
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
// setting up mocks for limits
fakeLicense := &mm_model.License{
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
}
th.Store.EXPECT().GetLicense().Return(fakeLicense).Times(2)
cloudLimit := &mm_model.ProductLimits{
Boards: &mm_model.BoardsLimits{
Views: mm_model.NewInt(2),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil).Times(2)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil).Times(2)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil).Times(2)
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]*model.Block{{}}, nil).Times(2)
_, err := th.App.InsertBlocks([]*model.Block{view1, view2}, "user-id-1")
require.Error(t, err)
})
}

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

@@ -0,0 +1,759 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"fmt"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
var (
ErrNewBoardCannotHaveID = errors.New("new board cannot have an ID")
)
const linkBoardMessage = "@%s linked the board [%s](%s) with this channel"
const unlinkBoardMessage = "@%s unlinked the board [%s](%s) with this channel"
var errNoDefaultCategoryFound = errors.New("no default category found for user")
func (a *App) GetBoard(boardID string) (*model.Board, error) {
board, err := a.store.GetBoard(boardID)
if err != nil {
return nil, err
}
return board, nil
}
func (a *App) GetBoardCount() (int64, error) {
return a.store.GetBoardCount()
}
func (a *App) GetBoardMetadata(boardID string) (*model.Board, *model.BoardMetadata, error) {
license := a.store.GetLicense()
if license == nil || !(*license.Features.Compliance) {
return nil, nil, model.ErrInsufficientLicense
}
board, err := a.GetBoard(boardID)
if model.IsErrNotFound(err) {
// Board may have been deleted, retrieve most recent history instead
board, err = a.getBoardHistory(boardID, true)
if err != nil {
return nil, nil, err
}
}
if err != nil {
return nil, nil, err
}
earliestTime, _, err := a.getBoardDescendantModifiedInfo(boardID, false)
if err != nil {
return nil, nil, err
}
latestTime, lastModifiedBy, err := a.getBoardDescendantModifiedInfo(boardID, true)
if err != nil {
return nil, nil, err
}
boardMetadata := model.BoardMetadata{
BoardID: boardID,
DescendantFirstUpdateAt: earliestTime,
DescendantLastUpdateAt: latestTime,
CreatedBy: board.CreatedBy,
LastModifiedBy: lastModifiedBy,
}
return board, &boardMetadata, nil
}
// getBoardForBlock returns the board that owns the specified block.
func (a *App) getBoardForBlock(blockID string) (*model.Board, error) {
block, err := a.GetBlockByID(blockID)
if err != nil {
return nil, fmt.Errorf("cannot get block %s: %w", blockID, err)
}
board, err := a.GetBoard(block.BoardID)
if err != nil {
return nil, fmt.Errorf("cannot get board %s: %w", block.BoardID, err)
}
return board, nil
}
func (a *App) getBoardHistory(boardID string, latest bool) (*model.Board, error) {
opts := model.QueryBoardHistoryOptions{
Limit: 1,
Descending: latest,
}
boards, err := a.store.GetBoardHistory(boardID, opts)
if err != nil {
return nil, fmt.Errorf("could not get history for board: %w", err)
}
if len(boards) == 0 {
return nil, nil
}
return boards[0], nil
}
func (a *App) getBoardDescendantModifiedInfo(boardID string, latest bool) (int64, string, error) {
board, err := a.getBoardHistory(boardID, latest)
if err != nil {
return 0, "", err
}
if board == nil {
return 0, "", fmt.Errorf("history not found for board: %w", err)
}
var timestamp int64
modifiedBy := board.ModifiedBy
if latest {
timestamp = board.UpdateAt
} else {
timestamp = board.CreateAt
}
// use block_history to fetch blocks in case they were deleted and no longer exist in blocks table.
opts := model.QueryBlockHistoryOptions{
Limit: 1,
Descending: latest,
}
blocks, err := a.store.GetBlockHistoryDescendants(boardID, opts)
if err != nil {
return 0, "", fmt.Errorf("could not get blocks history descendants for board: %w", err)
}
if len(blocks) > 0 {
// Compare the board history info with the descendant block info, if it exists
block := blocks[0]
if latest && block.UpdateAt > timestamp {
timestamp = block.UpdateAt
modifiedBy = block.ModifiedBy
} else if !latest && block.CreateAt < timestamp {
timestamp = block.CreateAt
modifiedBy = block.ModifiedBy
}
}
return timestamp, modifiedBy, nil
}
func (a *App) setBoardCategoryFromSource(sourceBoardID, destinationBoardID, userID, teamID string, asTemplate bool) error {
// find source board's category ID for the user
userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
if err != nil {
return err
}
var destinationCategoryID string
for _, categoryBoard := range userCategoryBoards {
for _, metadata := range categoryBoard.BoardMetadata {
if metadata.BoardID == sourceBoardID {
// category found!
destinationCategoryID = categoryBoard.ID
break
}
}
}
if destinationCategoryID == "" {
// if source board is not mapped to a category for this user,
// then move new board to default category
if !asTemplate {
return a.addBoardsToDefaultCategory(userID, teamID, []*model.Board{{ID: destinationBoardID}})
}
return nil
}
// now that we have source board's category,
// we send destination board to the same category
return a.AddUpdateUserCategoryBoard(teamID, userID, destinationCategoryID, []string{destinationBoardID})
}
func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*model.BoardsAndBlocks, []*model.BoardMember, error) {
bab, members, err := a.store.DuplicateBoard(boardID, userID, toTeam, asTemplate)
if err != nil {
return nil, nil, err
}
// copy any file attachments from the duplicated blocks.
if err = a.CopyCardFiles(boardID, bab.Blocks); err != nil {
a.logger.Error("Could not copy files while duplicating board", mlog.String("BoardID", boardID), mlog.Err(err))
}
if !asTemplate {
for _, board := range bab.Boards {
if categoryErr := a.setBoardCategoryFromSource(boardID, board.ID, userID, toTeam, asTemplate); categoryErr != nil {
return nil, nil, categoryErr
}
}
}
// bab.Blocks now has updated file ids for any blocks containing files. We need to store them.
blockIDs := make([]string, 0)
blockPatches := make([]model.BlockPatch, 0)
for _, block := range bab.Blocks {
fieldName := ""
if block.Type == model.TypeImage {
fieldName = "fileId"
} else if block.Type == model.TypeAttachment {
fieldName = "attachmentId"
}
if fieldName != "" {
if fieldID, ok := block.Fields[fieldName]; ok {
blockIDs = append(blockIDs, block.ID)
blockPatches = append(blockPatches, model.BlockPatch{
UpdatedFields: map[string]interface{}{
fieldName: fieldID,
},
})
}
}
}
a.logger.Debug("Duplicate boards patching file IDs", mlog.Int("count", len(blockIDs)))
if len(blockIDs) != 0 {
patches := &model.BlockPatchBatch{
BlockIDs: blockIDs,
BlockPatches: blockPatches,
}
if err = a.store.PatchBlocks(patches, userID); err != nil {
dbab := model.NewDeleteBoardsAndBlocksFromBabs(bab)
if err = a.store.DeleteBoardsAndBlocks(dbab, userID); err != nil {
a.logger.Error("Cannot delete board after duplication error when updating block's file info", mlog.String("boardID", bab.Boards[0].ID), mlog.Err(err))
}
return nil, nil, fmt.Errorf("could not patch file IDs while duplicating board %s: %w", boardID, err)
}
}
a.blockChangeNotifier.Enqueue(func() error {
teamID := ""
for _, board := range bab.Boards {
teamID = board.TeamID
a.wsAdapter.BroadcastBoardChange(teamID, board)
}
for _, block := range bab.Blocks {
blk := block
a.wsAdapter.BroadcastBlockChange(teamID, blk)
a.notifyBlockChanged(notify.Add, blk, nil, userID)
}
for _, member := range members {
a.wsAdapter.BroadcastMemberChange(teamID, member.BoardID, member)
}
return nil
})
if len(bab.Blocks) != 0 {
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after duplicating a board",
mlog.Err(uErr),
)
}
}()
}
return bab, members, err
}
func (a *App) GetBoardsForUserAndTeam(userID, teamID string, includePublicBoards bool) ([]*model.Board, error) {
return a.store.GetBoardsForUserAndTeam(userID, teamID, includePublicBoards)
}
func (a *App) GetTemplateBoards(teamID, userID string) ([]*model.Board, error) {
return a.store.GetTemplateBoards(teamID, userID)
}
func (a *App) CreateBoard(board *model.Board, userID string, addMember bool) (*model.Board, error) {
if board.ID != "" {
return nil, ErrNewBoardCannotHaveID
}
board.ID = utils.NewID(utils.IDTypeBoard)
var newBoard *model.Board
var member *model.BoardMember
var err error
if addMember {
newBoard, member, err = a.store.InsertBoardWithAdmin(board, userID)
} else {
newBoard, err = a.store.InsertBoard(board, userID)
}
if err != nil {
return nil, err
}
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastBoardChange(newBoard.TeamID, newBoard)
if newBoard.ChannelID != "" {
members, err := a.GetMembersForBoard(board.ID)
if err != nil {
a.logger.Error("Unable to get the board members", mlog.Err(err))
}
for _, member := range members {
a.wsAdapter.BroadcastMemberChange(newBoard.TeamID, member.BoardID, member)
}
} else if addMember {
a.wsAdapter.BroadcastMemberChange(newBoard.TeamID, newBoard.ID, member)
}
return nil
})
if !board.IsTemplate {
if err := a.addBoardsToDefaultCategory(userID, newBoard.TeamID, []*model.Board{newBoard}); err != nil {
return nil, err
}
}
return newBoard, nil
}
func (a *App) addBoardsToDefaultCategory(userID, teamID string, boards []*model.Board) error {
userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
if err != nil {
return err
}
defaultCategoryID := ""
for _, categoryBoard := range userCategoryBoards {
if categoryBoard.Name == defaultCategoryBoards {
defaultCategoryID = categoryBoard.ID
break
}
}
if defaultCategoryID == "" {
return fmt.Errorf("%w userID: %s", errNoDefaultCategoryFound, userID)
}
boardIDs := make([]string, len(boards))
for i := range boards {
boardIDs[i] = boards[i].ID
}
if err := a.AddUpdateUserCategoryBoard(teamID, userID, defaultCategoryID, boardIDs); err != nil {
return err
}
return nil
}
func (a *App) PatchBoard(patch *model.BoardPatch, boardID, userID string) (*model.Board, error) {
var oldChannelID string
var isTemplate bool
var oldMembers []*model.BoardMember
if patch.Type != nil || patch.ChannelID != nil {
testChannel := ""
if patch.ChannelID != nil && *patch.ChannelID == "" {
var err error
oldMembers, err = a.GetMembersForBoard(boardID)
if err != nil {
a.logger.Error("Unable to get the board members", mlog.Err(err))
}
} else if patch.ChannelID != nil && *patch.ChannelID != "" {
testChannel = *patch.ChannelID
}
board, err := a.store.GetBoard(boardID)
if model.IsErrNotFound(err) {
return nil, model.NewErrNotFound("board ID=" + boardID)
}
if err != nil {
return nil, err
}
oldChannelID = board.ChannelID
isTemplate = board.IsTemplate
if testChannel == "" {
testChannel = oldChannelID
}
if testChannel != "" {
if !a.permissions.HasPermissionToChannel(userID, testChannel, model.PermissionCreatePost) {
return nil, model.NewErrPermission("access denied to channel")
}
}
}
updatedBoard, err := a.store.PatchBoard(boardID, patch, userID)
if err != nil {
return nil, err
}
// Post message to channel if linked/unlinked
if patch.ChannelID != nil {
var username string
user, err := a.store.GetUserByID(userID)
if err != nil {
a.logger.Error("Unable to get the board updater", mlog.Err(err))
username = "unknown"
} else {
username = user.Username
}
boardLink := utils.MakeBoardLink(a.config.ServerRoot, updatedBoard.TeamID, updatedBoard.ID)
title := updatedBoard.Title
if title == "" {
title = "Untitled board" // todo: localize this when server has i18n
}
if *patch.ChannelID != "" {
a.postChannelMessage(fmt.Sprintf(linkBoardMessage, username, title, boardLink), updatedBoard.ChannelID)
} else if *patch.ChannelID == "" {
a.postChannelMessage(fmt.Sprintf(unlinkBoardMessage, username, title, boardLink), oldChannelID)
}
}
// Broadcast Messages to affected users
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastBoardChange(updatedBoard.TeamID, updatedBoard)
if patch.ChannelID != nil {
if *patch.ChannelID != "" {
members, err := a.GetMembersForBoard(updatedBoard.ID)
if err != nil {
a.logger.Error("Unable to get the board members", mlog.Err(err))
}
for _, member := range members {
if member.Synthetic {
a.wsAdapter.BroadcastMemberChange(updatedBoard.TeamID, member.BoardID, member)
}
}
} else {
for _, oldMember := range oldMembers {
if oldMember.Synthetic {
a.wsAdapter.BroadcastMemberDelete(updatedBoard.TeamID, boardID, oldMember.UserID)
}
}
}
}
if patch.Type != nil && isTemplate {
members, err := a.GetMembersForBoard(updatedBoard.ID)
if err != nil {
a.logger.Error("Unable to get the board members", mlog.Err(err))
}
a.broadcastTeamUsers(updatedBoard.TeamID, updatedBoard.ID, *patch.Type, members)
}
return nil
})
return updatedBoard, nil
}
func (a *App) postChannelMessage(message, channelID string) {
err := a.store.PostMessage(message, "", channelID)
if err != nil {
a.logger.Error("Unable to post the link message to channel", mlog.Err(err))
}
}
// broadcastTeamUsers notifies the members of a team when a template changes its type
// from public to private or viceversa.
func (a *App) broadcastTeamUsers(teamID, boardID string, boardType model.BoardType, members []*model.BoardMember) {
users, err := a.GetTeamUsers(teamID, "")
if err != nil {
a.logger.Error("Unable to get the team users", mlog.Err(err))
}
for _, user := range users {
isMember := false
for _, member := range members {
if member.UserID == user.ID {
isMember = true
break
}
}
if !isMember {
if boardType == model.BoardTypePrivate {
a.wsAdapter.BroadcastMemberDelete(teamID, boardID, user.ID)
} else if boardType == model.BoardTypeOpen {
a.wsAdapter.BroadcastMemberChange(teamID, boardID, &model.BoardMember{UserID: user.ID, BoardID: boardID, SchemeViewer: true, Synthetic: true})
}
}
}
}
func (a *App) DeleteBoard(boardID, userID string) error {
board, err := a.store.GetBoard(boardID)
if model.IsErrNotFound(err) {
return nil
}
if err != nil {
return err
}
if err := a.store.DeleteBoard(boardID, userID); err != nil {
return err
}
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastBoardDelete(board.TeamID, boardID)
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after deleting a board",
mlog.Err(err),
)
}
}()
return nil
}
func (a *App) GetMembersForBoard(boardID string) ([]*model.BoardMember, error) {
members, err := a.store.GetMembersForBoard(boardID)
if err != nil {
return nil, err
}
board, err := a.store.GetBoard(boardID)
if err != nil && !model.IsErrNotFound(err) {
return nil, err
}
if board != nil {
for i, m := range members {
if !m.SchemeAdmin {
if a.permissions.HasPermissionToTeam(m.UserID, board.TeamID, model.PermissionManageTeam) {
members[i].SchemeAdmin = true
}
}
}
}
return members, nil
}
func (a *App) GetMembersForUser(userID string) ([]*model.BoardMember, error) {
members, err := a.store.GetMembersForUser(userID)
if err != nil {
return nil, err
}
for i, m := range members {
if !m.SchemeAdmin {
board, err := a.store.GetBoard(m.BoardID)
if err != nil && !model.IsErrNotFound(err) {
return nil, err
}
if board != nil {
if a.permissions.HasPermissionToTeam(m.UserID, board.TeamID, model.PermissionManageTeam) {
// if system/team admin
members[i].SchemeAdmin = true
}
}
}
}
return members, nil
}
func (a *App) GetMemberForBoard(boardID string, userID string) (*model.BoardMember, error) {
return a.store.GetMemberForBoard(boardID, userID)
}
func (a *App) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, error) {
board, err := a.store.GetBoard(member.BoardID)
if model.IsErrNotFound(err) {
return nil, nil
}
if err != nil {
return nil, err
}
existingMembership, err := a.store.GetMemberForBoard(member.BoardID, member.UserID)
if err != nil && !model.IsErrNotFound(err) {
return nil, err
}
if existingMembership != nil && !existingMembership.Synthetic {
return existingMembership, nil
}
newMember, err := a.store.SaveMember(member)
if err != nil {
return nil, err
}
if !newMember.SchemeAdmin {
if board != nil {
if a.permissions.HasPermissionToTeam(newMember.UserID, board.TeamID, model.PermissionManageTeam) {
newMember.SchemeAdmin = true
}
}
}
if !board.IsTemplate {
if err = a.addBoardsToDefaultCategory(member.UserID, board.TeamID, []*model.Board{board}); err != nil {
return nil, err
}
}
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastMemberChange(board.TeamID, member.BoardID, member)
return nil
})
return newMember, nil
}
func (a *App) UpdateBoardMember(member *model.BoardMember) (*model.BoardMember, error) {
board, bErr := a.store.GetBoard(member.BoardID)
if model.IsErrNotFound(bErr) {
return nil, nil
}
if bErr != nil {
return nil, bErr
}
oldMember, err := a.store.GetMemberForBoard(member.BoardID, member.UserID)
if model.IsErrNotFound(err) {
return nil, nil
}
if err != nil {
return nil, err
}
// if we're updating an admin, we need to check that there is at
// least still another admin on the board
if oldMember.SchemeAdmin && !member.SchemeAdmin {
isLastAdmin, err2 := a.isLastAdmin(member.UserID, member.BoardID)
if err2 != nil {
return nil, err2
}
if isLastAdmin {
return nil, model.ErrBoardMemberIsLastAdmin
}
}
newMember, err := a.store.SaveMember(member)
if err != nil {
return nil, err
}
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastMemberChange(board.TeamID, member.BoardID, member)
return nil
})
return newMember, nil
}
func (a *App) isLastAdmin(userID, boardID string) (bool, error) {
members, err := a.store.GetMembersForBoard(boardID)
if err != nil {
return false, err
}
for _, m := range members {
if m.SchemeAdmin && m.UserID != userID {
return false, nil
}
}
return true, nil
}
func (a *App) DeleteBoardMember(boardID, userID string) error {
board, bErr := a.store.GetBoard(boardID)
if model.IsErrNotFound(bErr) {
return nil
}
if bErr != nil {
return bErr
}
oldMember, err := a.store.GetMemberForBoard(boardID, userID)
if model.IsErrNotFound(err) {
return nil
}
if err != nil {
return err
}
// if we're removing an admin, we need to check that there is at
// least still another admin on the board
if oldMember.SchemeAdmin {
isLastAdmin, err := a.isLastAdmin(userID, boardID)
if err != nil {
return err
}
if isLastAdmin {
return model.ErrBoardMemberIsLastAdmin
}
}
if err := a.store.DeleteMember(boardID, userID); err != nil {
return err
}
a.blockChangeNotifier.Enqueue(func() error {
if syntheticMember, _ := a.GetMemberForBoard(boardID, userID); syntheticMember != nil {
a.wsAdapter.BroadcastMemberChange(board.TeamID, boardID, syntheticMember)
} else {
a.wsAdapter.BroadcastMemberDelete(board.TeamID, boardID, userID)
}
return nil
})
return nil
}
func (a *App) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
return a.store.SearchBoardsForUser(term, searchField, userID, includePublicBoards)
}
func (a *App) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) {
return a.store.SearchBoardsForUserInTeam(teamID, term, userID)
}
func (a *App) UndeleteBoard(boardID string, modifiedBy string) error {
boards, err := a.store.GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true})
if err != nil {
return err
}
if len(boards) == 0 {
// undeleting non-existing board not considered an error
return nil
}
err = a.store.UndeleteBoard(boardID, modifiedBy)
if err != nil {
return err
}
board, err := a.store.GetBoard(boardID)
if err != nil {
return err
}
if board == nil {
a.logger.Error("Error loading the board after undelete, not propagating through websockets or notifications")
return nil
}
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastBoardChange(board.TeamID, board)
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after undeleting a board",
mlog.Err(err),
)
}
}()
return nil
}

170
server/boards/app/boards_and_blocks.go Обычный файл
Просмотреть файл

@@ -0,0 +1,170 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/services/notify"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
func (a *App) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks, userID string, addMember bool) (*model.BoardsAndBlocks, error) {
var newBab *model.BoardsAndBlocks
var members []*model.BoardMember
var err error
if addMember {
newBab, members, err = a.store.CreateBoardsAndBlocksWithAdmin(bab, userID)
} else {
newBab, err = a.store.CreateBoardsAndBlocks(bab, userID)
}
if err != nil {
return nil, err
}
// all new boards should belong to the same team
teamID := newBab.Boards[0].TeamID
// This can be synchronous because this action is not common
for _, board := range newBab.Boards {
a.wsAdapter.BroadcastBoardChange(teamID, board)
}
for _, block := range newBab.Blocks {
b := block
a.wsAdapter.BroadcastBlockChange(teamID, b)
a.metrics.IncrementBlocksInserted(1)
a.webhook.NotifyUpdate(b)
a.notifyBlockChanged(notify.Add, b, nil, userID)
}
if addMember {
for _, member := range members {
a.wsAdapter.BroadcastMemberChange(teamID, member.BoardID, member)
}
}
if len(newBab.Blocks) != 0 {
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after creating boards and blocks",
mlog.Err(uErr),
)
}
}()
}
for _, board := range newBab.Boards {
if !board.IsTemplate {
if err := a.addBoardsToDefaultCategory(userID, board.TeamID, []*model.Board{board}); err != nil {
return nil, err
}
}
}
return newBab, nil
}
func (a *App) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) {
oldBlocks, err := a.store.GetBlocksByIDs(pbab.BlockIDs)
if err != nil {
return nil, err
}
if a.IsCloudLimited() {
containsLimitedBlocks, cErr := a.ContainsLimitedBlocks(oldBlocks)
if cErr != nil {
return nil, cErr
}
if containsLimitedBlocks {
return nil, model.ErrPatchUpdatesLimitedCards
}
}
oldBlocksMap := map[string]*model.Block{}
for _, block := range oldBlocks {
oldBlocksMap[block.ID] = block
}
bab, err := a.store.PatchBoardsAndBlocks(pbab, userID)
if err != nil {
return nil, err
}
a.blockChangeNotifier.Enqueue(func() error {
teamID := bab.Boards[0].TeamID
for _, block := range bab.Blocks {
oldBlock, ok := oldBlocksMap[block.ID]
if !ok {
a.logger.Error("Error notifying for block change on patch boards and blocks; cannot get old block", mlog.String("blockID", block.ID))
continue
}
b := block
a.metrics.IncrementBlocksPatched(1)
a.wsAdapter.BroadcastBlockChange(teamID, b)
a.webhook.NotifyUpdate(b)
a.notifyBlockChanged(notify.Update, b, oldBlock, userID)
}
for _, board := range bab.Boards {
a.wsAdapter.BroadcastBoardChange(board.TeamID, board)
}
return nil
})
return bab, nil
}
func (a *App) DeleteBoardsAndBlocks(dbab *model.DeleteBoardsAndBlocks, userID string) error {
firstBoard, err := a.store.GetBoard(dbab.Boards[0])
if err != nil {
return err
}
// we need the block entity to notify of the block changes, so we
// fetch and store the blocks first
blocks := []*model.Block{}
for _, blockID := range dbab.Blocks {
block, err := a.store.GetBlock(blockID)
if err != nil {
return err
}
blocks = append(blocks, block)
}
if err := a.store.DeleteBoardsAndBlocks(dbab, userID); err != nil {
return err
}
a.blockChangeNotifier.Enqueue(func() error {
for _, block := range blocks {
a.wsAdapter.BroadcastBlockDelete(firstBoard.TeamID, block.ID, block.BoardID)
a.metrics.IncrementBlocksDeleted(1)
a.notifyBlockChanged(notify.Update, block, block, userID)
}
for _, boardID := range dbab.Boards {
a.wsAdapter.BroadcastBoardDelete(firstBoard.TeamID, boardID)
}
return nil
})
if len(dbab.Blocks) != 0 {
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after deleting boards and blocks",
mlog.Err(uErr),
)
}
}()
}
return nil
}

779
server/boards/app/boards_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,779 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
func TestAddMemberToBoard(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_1"
boardMember := &model.BoardMember{
BoardID: boardID,
UserID: userID,
SchemeEditor: true,
}
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: "board_id_1",
TeamID: "team_id_1",
}, nil)
th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(nil, nil)
th.Store.EXPECT().SaveMember(mock.MatchedBy(func(i interface{}) bool {
p := i.(*model.BoardMember)
return p.BoardID == boardID && p.UserID == userID
})).Return(&model.BoardMember{
BoardID: boardID,
}, nil)
// for WS change broadcast
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
{
Category: model.Category{
ID: "default_category_id",
Name: "Boards",
Type: "system",
},
},
}, nil).Times(2)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", []string{"board_id_1"}).Return(nil)
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
require.NoError(t, err)
require.Equal(t, boardID, addedBoardMember.BoardID)
})
t.Run("return existing non-synthetic membership if any", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_1"
boardMember := &model.BoardMember{
BoardID: boardID,
UserID: userID,
SchemeEditor: true,
}
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
TeamID: "team_id_1",
}, nil)
th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(&model.BoardMember{
UserID: userID,
BoardID: boardID,
Synthetic: false,
}, nil)
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
require.NoError(t, err)
require.Equal(t, boardID, addedBoardMember.BoardID)
})
t.Run("should convert synthetic membership into natural membership", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_1"
boardMember := &model.BoardMember{
BoardID: boardID,
UserID: userID,
SchemeEditor: true,
}
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: "board_id_1",
TeamID: "team_id_1",
}, nil)
th.Store.EXPECT().GetMemberForBoard(boardID, userID).Return(&model.BoardMember{
UserID: userID,
BoardID: boardID,
Synthetic: true,
}, nil)
th.Store.EXPECT().SaveMember(mock.MatchedBy(func(i interface{}) bool {
p := i.(*model.BoardMember)
return p.BoardID == boardID && p.UserID == userID
})).Return(&model.BoardMember{
UserID: userID,
BoardID: boardID,
Synthetic: false,
}, nil)
// for WS change broadcast
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
{
Category: model.Category{
ID: "default_category_id",
Name: "Boards",
Type: "system",
},
},
}, nil).Times(2)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", []string{"board_id_1"}).Return(nil)
th.API.EXPECT().HasPermissionToTeam("user_id_1", "team_id_1", model.PermissionManageTeam).Return(false).Times(1)
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
require.NoError(t, err)
require.Equal(t, boardID, addedBoardMember.BoardID)
})
}
func TestPatchBoard(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case, title patch", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_1"
const teamID = "team_id_1"
patchTitle := "Patched Title"
patch := &model.BoardPatch{
Title: &patchTitle,
}
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
Title: patchTitle,
},
nil)
// for WS BroadcastBoardChange
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(1)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, patchTitle, patchedBoard.Title)
})
t.Run("patch type open, no users", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
patchType := model.BoardTypeOpen
patch := &model.BoardPatch{
Type: &patchType,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil).Times(2)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{}, nil)
th.Store.EXPECT().GetUserByID(userID).Return(&model.User{ID: userID, Username: "UserName"}, nil)
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
},
nil)
// Should call GetMembersForBoard 2 times
// - for WS BroadcastBoardChange
// - for AddTeamMembers check
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, boardID, patchedBoard.ID)
})
t.Run("patch type private, no users", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
patchType := model.BoardTypePrivate
patch := &model.BoardPatch{
Type: &patchType,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil).Times(2)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{}, nil)
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
},
nil)
// Should call GetMembersForBoard 2 times
// - for WS BroadcastBoardChange
// - for AddTeamMembers check
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, boardID, patchedBoard.ID)
})
t.Run("patch type open, single user", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
patchType := model.BoardTypeOpen
patch := &model.BoardPatch{
Type: &patchType,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil).Times(2)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
},
nil)
// Should call GetMembersForBoard 3 times
// for WS BroadcastBoardChange
// for AddTeamMembers check
// for WS BroadcastMemberChange
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(3)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, boardID, patchedBoard.ID)
})
t.Run("patch type private, single user", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
patchType := model.BoardTypePrivate
patch := &model.BoardPatch{
Type: &patchType,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil).Times(2)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
},
nil)
// Should call GetMembersForBoard 3 times
// for WS BroadcastBoardChange
// for AddTeamMembers check
// for WS BroadcastMemberChange
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(3)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, boardID, patchedBoard.ID)
})
t.Run("patch type open, user with member", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
patchType := model.BoardTypeOpen
patch := &model.BoardPatch{
Type: &patchType,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil).Times(3)
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
},
nil)
// Should call GetMembersForBoard 2 times
// for WS BroadcastBoardChange
// for AddTeamMembers check
// We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).Times(2)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, boardID, patchedBoard.ID)
})
t.Run("patch type private, user with member", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
patchType := model.BoardTypePrivate
patch := &model.BoardPatch{
Type: &patchType,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
ChannelID: "",
}, nil).Times(1)
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
// Type not null will retrieve team members
th.Store.EXPECT().GetUsersByTeam(teamID, "", false, false).Return([]*model.User{{ID: userID}}, nil)
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
},
nil)
// Should call GetMembersForBoard 2 times
// for WS BroadcastBoardChange
// for AddTeamMembers check
// We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).Times(2)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, boardID, patchedBoard.ID)
})
t.Run("patch type channel, user without post permissions", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
channelID := "myChannel"
patchType := model.BoardTypeOpen
patch := &model.BoardPatch{
Type: &patchType,
ChannelID: &channelID,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
}, nil).Times(1)
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(false).Times(1)
_, err := th.App.PatchBoard(patch, boardID, userID)
require.Error(t, err)
})
t.Run("patch type channel, user with post permissions", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
channelID := "myChannel"
patch := &model.BoardPatch{
ChannelID: &channelID,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
}, nil).Times(2)
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(true).Times(1)
th.Store.EXPECT().PatchBoard(boardID, patch, userID).Return(
&model.Board{
ID: boardID,
TeamID: teamID,
},
nil)
// Should call GetMembersForBoard 2 times
// - for WS BroadcastBoardChange
// - for AddTeamMembers check
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
th.Store.EXPECT().PostMessage(utils.Anything, "", "").Times(1)
patchedBoard, err := th.App.PatchBoard(patch, boardID, userID)
require.NoError(t, err)
require.Equal(t, boardID, patchedBoard.ID)
})
}
func TestPatchBoard2(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("patch type remove channel, user without post permissions", func(t *testing.T) {
const boardID = "board_id_1"
const userID = "user_id_2"
const teamID = "team_id_1"
const channelID = "myChannel"
clearChannel := ""
patchType := model.BoardTypeOpen
patch := &model.BoardPatch{
Type: &patchType,
ChannelID: &clearChannel,
}
// Type not nil, will cause board to be reteived
// to check isTemplate
th.Store.EXPECT().GetBoard(boardID).Return(&model.Board{
ID: boardID,
TeamID: teamID,
IsTemplate: true,
ChannelID: channelID,
}, nil).Times(2)
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(false).Times(1)
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
// Should call GetMembersForBoard 2 times
// for WS BroadcastBoardChange
// for AddTeamMembers check
// We are returning the user as a direct Board Member, so BroadcastMemberDelete won't be called
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{{BoardID: boardID, UserID: userID, SchemeEditor: true}}, nil).AnyTimes()
_, err := th.App.PatchBoard(patch, boardID, userID)
require.Error(t, err)
})
}
func TestGetBoardCount(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
boardCount := int64(100)
th.Store.EXPECT().GetBoardCount().Return(boardCount, nil)
count, err := th.App.GetBoardCount()
require.NoError(t, err)
require.Equal(t, boardCount, count)
})
}
func TestBoardCategory(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("no boards default category exists", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_1"},
{BoardID: "board_id_2"},
},
},
{
Category: model.Category{ID: "category_id_2", Name: "Category 2"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_3"},
},
},
{
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
BoardMetadata: []model.CategoryBoardMetadata{},
},
}, nil).Times(1)
// when this function is called the second time, the default category is created
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_1"},
{BoardID: "board_id_2"},
},
},
{
Category: model.Category{ID: "category_id_2", Name: "Category 2"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_3"},
},
},
{
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
BoardMetadata: []model.CategoryBoardMetadata{},
},
{
Category: model.Category{ID: "default_category_id", Type: model.CategoryTypeSystem, Name: "Boards"},
},
}, nil).Times(1)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "default_category_id",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", []string{
"board_id_1",
"board_id_2",
"board_id_3",
}).Return(nil)
boards := []*model.Board{
{ID: "board_id_1"},
{ID: "board_id_2"},
{ID: "board_id_3"},
}
err := th.App.addBoardsToDefaultCategory("user_id", "team_id", boards)
assert.NoError(t, err)
})
}
func TestDuplicateBoard(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
board := &model.Board{
ID: "board_id_2",
Title: "Duplicated Board",
}
block := &model.Block{
ID: "block_id_1",
Type: "image",
}
th.Store.EXPECT().DuplicateBoard("board_id_1", "user_id_1", "team_id_1", false).Return(
&model.BoardsAndBlocks{
Boards: []*model.Board{
board,
},
Blocks: []*model.Block{
block,
},
},
[]*model.BoardMember{},
nil,
)
th.Store.EXPECT().GetBoard("board_id_1").Return(&model.Board{}, nil)
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
{
Category: model.Category{
ID: "category_id_1",
Name: "Boards",
Type: "system",
},
},
}, nil).Times(3)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "category_id_1", utils.Anything).Return(nil)
// for WS change broadcast
th.Store.EXPECT().GetMembersForBoard(utils.Anything).Return([]*model.BoardMember{}, nil).Times(2)
bab, members, err := th.App.DuplicateBoard("board_id_1", "user_id_1", "team_id_1", false)
assert.NoError(t, err)
assert.NotNil(t, bab)
assert.NotNil(t, members)
})
t.Run("duplicating board as template should not set it's category", func(t *testing.T) {
board := &model.Board{
ID: "board_id_2",
Title: "Duplicated Board",
}
block := &model.Block{
ID: "block_id_1",
Type: "image",
}
th.Store.EXPECT().DuplicateBoard("board_id_1", "user_id_1", "team_id_1", true).Return(
&model.BoardsAndBlocks{
Boards: []*model.Board{
board,
},
Blocks: []*model.Block{
block,
},
},
[]*model.BoardMember{},
nil,
)
th.Store.EXPECT().GetBoard("board_id_1").Return(&model.Board{}, nil)
// for WS change broadcast
th.Store.EXPECT().GetMembersForBoard(utils.Anything).Return([]*model.BoardMember{}, nil).Times(2)
bab, members, err := th.App.DuplicateBoard("board_id_1", "user_id_1", "team_id_1", true)
assert.NoError(t, err)
assert.NotNil(t, bab)
assert.NotNil(t, members)
})
}
func TestGetMembersForBoard(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
const boardID = "board_id_1"
const userID = "user_id_1"
const teamID = "team_id_1"
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{
{
BoardID: boardID,
UserID: userID,
SchemeEditor: true,
},
}, nil).Times(3)
th.Store.EXPECT().GetBoard(boardID).Return(nil, nil).Times(1)
t.Run("-base case", func(t *testing.T) {
members, err := th.App.GetMembersForBoard(boardID)
assert.NoError(t, err)
assert.NotNil(t, members)
assert.False(t, members[0].SchemeAdmin)
})
board := &model.Board{
ID: boardID,
TeamID: teamID,
}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil).Times(2)
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
t.Run("-team check false ", func(t *testing.T) {
members, err := th.App.GetMembersForBoard(boardID)
assert.NoError(t, err)
assert.NotNil(t, members)
assert.False(t, members[0].SchemeAdmin)
})
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
t.Run("-team check true", func(t *testing.T) {
members, err := th.App.GetMembersForBoard(boardID)
assert.NoError(t, err)
assert.NotNil(t, members)
assert.True(t, members[0].SchemeAdmin)
})
}
func TestGetMembersForUser(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
const boardID = "board_id_1"
const userID = "user_id_1"
const teamID = "team_id_1"
th.Store.EXPECT().GetMembersForUser(userID).Return([]*model.BoardMember{
{
BoardID: boardID,
UserID: userID,
SchemeEditor: true,
},
}, nil).Times(3)
th.Store.EXPECT().GetBoard(boardID).Return(nil, nil)
t.Run("-base case", func(t *testing.T) {
members, err := th.App.GetMembersForUser(userID)
assert.NoError(t, err)
assert.NotNil(t, members)
assert.False(t, members[0].SchemeAdmin)
})
board := &model.Board{
ID: boardID,
TeamID: teamID,
}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil).Times(2)
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
t.Run("-team check false ", func(t *testing.T) {
members, err := th.App.GetMembersForUser(userID)
assert.NoError(t, err)
assert.NotNil(t, members)
assert.False(t, members[0].SchemeAdmin)
})
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
t.Run("-team check true", func(t *testing.T) {
members, err := th.App.GetMembersForUser(userID)
assert.NoError(t, err)
assert.NotNil(t, members)
assert.True(t, members[0].SchemeAdmin)
})
}

96
server/boards/app/cards.go Обычный файл
Просмотреть файл

@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
)
func (a *App) CreateCard(card *model.Card, boardID string, userID string, disableNotify bool) (*model.Card, error) {
// Convert the card struct to a block and insert the block.
now := utils.GetMillis()
card.ID = utils.NewID(utils.IDTypeCard)
card.BoardID = boardID
card.CreatedBy = userID
card.ModifiedBy = userID
card.CreateAt = now
card.UpdateAt = now
card.DeleteAt = 0
block := model.Card2Block(card)
newBlocks, err := a.InsertBlocksAndNotify([]*model.Block{block}, userID, disableNotify)
if err != nil {
return nil, fmt.Errorf("cannot create card: %w", err)
}
newCard, err := model.Block2Card(newBlocks[0])
if err != nil {
return nil, err
}
return newCard, nil
}
func (a *App) GetCardsForBoard(boardID string, page int, perPage int) ([]*model.Card, error) {
opts := model.QueryBlocksOptions{
BoardID: boardID,
BlockType: model.TypeCard,
Page: page,
PerPage: perPage,
}
blocks, err := a.store.GetBlocks(opts)
if err != nil {
return nil, err
}
cards := make([]*model.Card, 0, len(blocks))
var card *model.Card
for _, blk := range blocks {
b := blk
if card, err = model.Block2Card(b); err != nil {
return nil, fmt.Errorf("Block2Card fail: %w", err)
}
cards = append(cards, card)
}
return cards, nil
}
func (a *App) PatchCard(cardPatch *model.CardPatch, cardID string, userID string, disableNotify bool) (*model.Card, error) {
blockPatch, err := model.CardPatch2BlockPatch(cardPatch)
if err != nil {
return nil, err
}
newBlock, err := a.PatchBlockAndNotify(cardID, blockPatch, userID, disableNotify)
if err != nil {
return nil, fmt.Errorf("cannot patch card %s: %w", cardID, err)
}
newCard, err := model.Block2Card(newBlock)
if err != nil {
return nil, err
}
return newCard, nil
}
func (a *App) GetCardByID(cardID string) (*model.Card, error) {
cardBlock, err := a.GetBlockByID(cardID)
if err != nil {
return nil, err
}
card, err := model.Block2Card(cardBlock)
if err != nil {
return nil, err
}
return card, nil
}

270
server/boards/app/cards_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,270 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"reflect"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
)
func TestCreateCard(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
board := &model.Board{
ID: utils.NewID(utils.IDTypeBoard),
}
userID := utils.NewID(utils.IDTypeUser)
props := makeProps(3)
card := &model.Card{
BoardID: board.ID,
CreatedBy: userID,
ModifiedBy: userID,
Title: "test card",
ContentOrder: []string{utils.NewID(utils.IDTypeBlock), utils.NewID(utils.IDTypeBlock)},
Properties: props,
}
block := model.Card2Block(card)
t.Run("success scenario", func(t *testing.T) {
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
th.Store.EXPECT().InsertBlock(gomock.AssignableToTypeOf(reflect.TypeOf(block)), userID).Return(nil)
th.Store.EXPECT().GetMembersForBoard(board.ID).Return([]*model.BoardMember{}, nil)
newCard, err := th.App.CreateCard(card, board.ID, userID, false)
require.NoError(t, err)
require.Equal(t, card.BoardID, newCard.BoardID)
require.Equal(t, card.Title, newCard.Title)
require.Equal(t, card.ContentOrder, newCard.ContentOrder)
require.EqualValues(t, card.Properties, newCard.Properties)
})
t.Run("error scenario", func(t *testing.T) {
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
th.Store.EXPECT().InsertBlock(gomock.AssignableToTypeOf(reflect.TypeOf(block)), userID).Return(blockError{"error"})
newCard, err := th.App.CreateCard(card, board.ID, userID, false)
require.Error(t, err, "error")
require.Nil(t, newCard)
})
}
func TestGetCards(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
board := &model.Board{
ID: utils.NewID(utils.IDTypeBoard),
}
const cardCount = 25
// make some cards
blocks := make([]*model.Block, 0, cardCount)
for i := 0; i < cardCount; i++ {
card := &model.Block{
ID: utils.NewID(utils.IDTypeBlock),
ParentID: board.ID,
Schema: 1,
Type: model.TypeCard,
Title: fmt.Sprintf("card %d", i),
BoardID: board.ID,
}
blocks = append(blocks, card)
}
t.Run("success scenario", func(t *testing.T) {
opts := model.QueryBlocksOptions{
BoardID: board.ID,
BlockType: model.TypeCard,
}
th.Store.EXPECT().GetBlocks(opts).Return(blocks, nil)
cards, err := th.App.GetCardsForBoard(board.ID, 0, 0)
require.NoError(t, err)
assert.Len(t, cards, cardCount)
})
t.Run("error scenario", func(t *testing.T) {
opts := model.QueryBlocksOptions{
BoardID: board.ID,
BlockType: model.TypeCard,
}
th.Store.EXPECT().GetBlocks(opts).Return(nil, blockError{"error"})
cards, err := th.App.GetCardsForBoard(board.ID, 0, 0)
require.Error(t, err)
require.Nil(t, cards)
})
}
func TestPatchCard(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
board := &model.Board{
ID: utils.NewID(utils.IDTypeBoard),
}
userID := utils.NewID(utils.IDTypeUser)
props := makeProps(3)
card := &model.Card{
BoardID: board.ID,
CreatedBy: userID,
ModifiedBy: userID,
Title: "test card for patch",
ContentOrder: []string{utils.NewID(utils.IDTypeBlock), utils.NewID(utils.IDTypeBlock)},
Properties: copyProps(props),
}
newTitle := "patched"
newIcon := "😀"
newContentOrder := reverse(card.ContentOrder)
cardPatch := &model.CardPatch{
Title: &newTitle,
ContentOrder: &newContentOrder,
Icon: &newIcon,
UpdatedProperties: modifyProps(props),
}
t.Run("success scenario", func(t *testing.T) {
expectedPatchedCard := cardPatch.Patch(card)
expectedPatchedBlock := model.Card2Block(expectedPatchedCard)
var blockPatch *model.BlockPatch
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
th.Store.EXPECT().PatchBlock(card.ID, gomock.AssignableToTypeOf(reflect.TypeOf(blockPatch)), userID).Return(nil)
th.Store.EXPECT().GetMembersForBoard(board.ID).Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetBlock(card.ID).Return(expectedPatchedBlock, nil).AnyTimes()
patchedCard, err := th.App.PatchCard(cardPatch, card.ID, userID, false)
require.NoError(t, err)
require.Equal(t, board.ID, patchedCard.BoardID)
require.Equal(t, newTitle, patchedCard.Title)
require.Equal(t, newIcon, patchedCard.Icon)
require.Equal(t, newContentOrder, patchedCard.ContentOrder)
require.EqualValues(t, expectedPatchedCard.Properties, patchedCard.Properties)
})
t.Run("error scenario", func(t *testing.T) {
var blockPatch *model.BlockPatch
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
th.Store.EXPECT().PatchBlock(card.ID, gomock.AssignableToTypeOf(reflect.TypeOf(blockPatch)), userID).Return(blockError{"error"})
patchedCard, err := th.App.PatchCard(cardPatch, card.ID, userID, false)
require.Error(t, err, "error")
require.Nil(t, patchedCard)
})
}
func TestGetCard(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
boardID := utils.NewID(utils.IDTypeBoard)
userID := utils.NewID(utils.IDTypeUser)
props := makeProps(5)
contentOrder := []string{utils.NewID(utils.IDTypeUser), utils.NewID(utils.IDTypeUser)}
fields := make(map[string]any)
fields["contentOrder"] = contentOrder
fields["properties"] = props
fields["icon"] = "😀"
fields["isTemplate"] = true
block := &model.Block{
ID: utils.NewID(utils.IDTypeBlock),
ParentID: boardID,
Type: model.TypeCard,
Title: "test card",
BoardID: boardID,
Fields: fields,
CreatedBy: userID,
ModifiedBy: userID,
}
t.Run("success scenario", func(t *testing.T) {
th.Store.EXPECT().GetBlock(block.ID).Return(block, nil)
card, err := th.App.GetCardByID(block.ID)
require.NoError(t, err)
require.Equal(t, boardID, card.BoardID)
require.Equal(t, block.Title, card.Title)
require.Equal(t, "😀", card.Icon)
require.Equal(t, true, card.IsTemplate)
require.Equal(t, contentOrder, card.ContentOrder)
require.EqualValues(t, props, card.Properties)
})
t.Run("not found", func(t *testing.T) {
bogusID := utils.NewID(utils.IDTypeBlock)
th.Store.EXPECT().GetBlock(bogusID).Return(nil, model.NewErrNotFound(bogusID))
card, err := th.App.GetCardByID(bogusID)
require.Error(t, err, "error")
require.True(t, model.IsErrNotFound(err))
require.Nil(t, card)
})
t.Run("error scenario", func(t *testing.T) {
th.Store.EXPECT().GetBlock(block.ID).Return(nil, blockError{"error"})
card, err := th.App.GetCardByID(block.ID)
require.Error(t, err, "error")
require.Nil(t, card)
})
}
// reverse is a helper function to copy and reverse a slice of strings.
func reverse(src []string) []string {
out := make([]string, 0, len(src))
for i := len(src) - 1; i >= 0; i-- {
out = append(out, src[i])
}
return out
}
func makeProps(count int) map[string]any {
props := make(map[string]any)
for i := 0; i < count; i++ {
props[utils.NewID(utils.IDTypeBlock)] = utils.NewID(utils.IDTypeBlock)
}
return props
}
func copyProps(m map[string]any) map[string]any {
out := make(map[string]any)
for k, v := range m {
out[k] = v
}
return out
}
func modifyProps(m map[string]any) map[string]any {
out := make(map[string]any)
for k := range m {
out[k] = utils.NewID(utils.IDTypeBlock)
}
return out
}

248
server/boards/app/category.go Обычный файл
Просмотреть файл

@@ -0,0 +1,248 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"fmt"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
)
var errCategoryNotFound = errors.New("category ID specified in input does not exist for user")
var errCategoriesLengthMismatch = errors.New("cannot update category order, passed list of categories different size than in database")
var ErrCannotDeleteSystemCategory = errors.New("cannot delete a system category")
var ErrCannotUpdateSystemCategory = errors.New("cannot update a system category")
func (a *App) GetCategory(categoryID string) (*model.Category, error) {
return a.store.GetCategory(categoryID)
}
func (a *App) CreateCategory(category *model.Category) (*model.Category, error) {
category.Hydrate()
if err := category.IsValid(); err != nil {
return nil, err
}
if err := a.store.CreateCategory(*category); err != nil {
return nil, err
}
createdCategory, err := a.store.GetCategory(category.ID)
if err != nil {
return nil, err
}
go func() {
a.wsAdapter.BroadcastCategoryChange(*createdCategory)
}()
return createdCategory, nil
}
func (a *App) UpdateCategory(category *model.Category) (*model.Category, error) {
category.Hydrate()
if err := category.IsValid(); err != nil {
return nil, err
}
// verify if category belongs to the user
existingCategory, err := a.store.GetCategory(category.ID)
if err != nil {
return nil, err
}
if existingCategory.DeleteAt != 0 {
return nil, model.ErrCategoryDeleted
}
if existingCategory.UserID != category.UserID {
return nil, model.ErrCategoryPermissionDenied
}
if existingCategory.TeamID != category.TeamID {
return nil, model.ErrCategoryPermissionDenied
}
// in case type was defaulted above, set to existingCategory.Type
category.Type = existingCategory.Type
if existingCategory.Type == model.CategoryTypeSystem {
// You cannot rename or delete a system category,
// So restoring its name and undeleting it if set so.
category.Name = existingCategory.Name
category.DeleteAt = 0
}
category.UpdateAt = utils.GetMillis()
if err = category.IsValid(); err != nil {
return nil, err
}
if err = a.store.UpdateCategory(*category); err != nil {
return nil, err
}
updatedCategory, err := a.store.GetCategory(category.ID)
if err != nil {
return nil, err
}
go func() {
a.wsAdapter.BroadcastCategoryChange(*updatedCategory)
}()
return updatedCategory, nil
}
func (a *App) DeleteCategory(categoryID, userID, teamID string) (*model.Category, error) {
existingCategory, err := a.store.GetCategory(categoryID)
if err != nil {
return nil, err
}
// category is already deleted. This avoids
// overriding the original deleted at timestamp
if existingCategory.DeleteAt != 0 {
return existingCategory, nil
}
// verify if category belongs to the user
if existingCategory.UserID != userID {
return nil, model.ErrCategoryPermissionDenied
}
// verify if category belongs to the team
if existingCategory.TeamID != teamID {
return nil, model.NewErrInvalidCategory("category doesn't belong to the team")
}
if existingCategory.Type == model.CategoryTypeSystem {
return nil, ErrCannotDeleteSystemCategory
}
if err = a.moveBoardsToDefaultCategory(userID, teamID, categoryID); err != nil {
return nil, err
}
if err = a.store.DeleteCategory(categoryID, userID, teamID); err != nil {
return nil, err
}
deletedCategory, err := a.store.GetCategory(categoryID)
if err != nil {
return nil, err
}
go func() {
a.wsAdapter.BroadcastCategoryChange(*deletedCategory)
}()
return deletedCategory, nil
}
func (a *App) moveBoardsToDefaultCategory(userID, teamID, sourceCategoryID string) error {
// we need a list of boards associated to this category
// so we can move them to user's default Boards category
categoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
if err != nil {
return err
}
var sourceCategoryBoards *model.CategoryBoards
defaultCategoryID := ""
// iterate user's categories to find the source category
// and the default category.
// We need source category to get the list of its board
// and the default category to know its ID to
// move source category's boards to.
for i := range categoryBoards {
if categoryBoards[i].ID == sourceCategoryID {
sourceCategoryBoards = &categoryBoards[i]
}
if categoryBoards[i].Name == defaultCategoryBoards {
defaultCategoryID = categoryBoards[i].ID
}
// if both categories are found, no need to iterate furthur.
if sourceCategoryBoards != nil && defaultCategoryID != "" {
break
}
}
if sourceCategoryBoards == nil {
return errCategoryNotFound
}
if defaultCategoryID == "" {
return fmt.Errorf("moveBoardsToDefaultCategory: %w", errNoDefaultCategoryFound)
}
boardIDs := make([]string, len(sourceCategoryBoards.BoardMetadata))
for i := range sourceCategoryBoards.BoardMetadata {
boardIDs[i] = sourceCategoryBoards.BoardMetadata[i].BoardID
}
if err := a.AddUpdateUserCategoryBoard(teamID, userID, defaultCategoryID, boardIDs); err != nil {
return fmt.Errorf("moveBoardsToDefaultCategory: %w", err)
}
return nil
}
func (a *App) ReorderCategories(userID, teamID string, newCategoryOrder []string) ([]string, error) {
if err := a.verifyNewCategoriesMatchExisting(userID, teamID, newCategoryOrder); err != nil {
return nil, err
}
newOrder, err := a.store.ReorderCategories(userID, teamID, newCategoryOrder)
if err != nil {
return nil, err
}
go func() {
a.wsAdapter.BroadcastCategoryReorder(teamID, userID, newOrder)
}()
return newOrder, nil
}
func (a *App) verifyNewCategoriesMatchExisting(userID, teamID string, newCategoryOrder []string) error {
existingCategories, err := a.store.GetUserCategories(userID, teamID)
if err != nil {
return err
}
if len(newCategoryOrder) != len(existingCategories) {
return fmt.Errorf(
"%w length new categories: %d, length existing categories: %d, userID: %s, teamID: %s",
errCategoriesLengthMismatch,
len(newCategoryOrder),
len(existingCategories),
userID,
teamID,
)
}
existingCategoriesMap := map[string]bool{}
for _, category := range existingCategories {
existingCategoriesMap[category.ID] = true
}
for _, newCategoryID := range newCategoryOrder {
if _, found := existingCategoriesMap[newCategoryID]; !found {
return fmt.Errorf(
"%w specified category ID: %s, userID: %s, teamID: %s",
errCategoryNotFound,
newCategoryID,
userID,
teamID,
)
}
}
return nil
}

282
server/boards/app/category_boards.go Обычный файл
Просмотреть файл

@@ -0,0 +1,282 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"fmt"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
const defaultCategoryBoards = "Boards"
var errCategoryBoardsLengthMismatch = errors.New("cannot update category boards order, passed list of categories boards different size than in database")
var errBoardNotFoundInCategory = errors.New("specified board ID not found in specified category ID")
var errBoardMembershipNotFound = errors.New("board membership not found for user's board")
func (a *App) GetUserCategoryBoards(userID, teamID string) ([]model.CategoryBoards, error) {
categoryBoards, err := a.store.GetUserCategoryBoards(userID, teamID)
if err != nil {
return nil, err
}
createdCategoryBoards, err := a.createDefaultCategoriesIfRequired(categoryBoards, userID, teamID)
if err != nil {
return nil, err
}
categoryBoards = append(categoryBoards, createdCategoryBoards...)
return categoryBoards, nil
}
func (a *App) createDefaultCategoriesIfRequired(existingCategoryBoards []model.CategoryBoards, userID, teamID string) ([]model.CategoryBoards, error) {
createdCategories := []model.CategoryBoards{}
boardsCategoryExist := false
for _, categoryBoard := range existingCategoryBoards {
if categoryBoard.Name == defaultCategoryBoards {
boardsCategoryExist = true
}
}
if !boardsCategoryExist {
createdCategoryBoards, err := a.createBoardsCategory(userID, teamID, existingCategoryBoards)
if err != nil {
return nil, err
}
createdCategories = append(createdCategories, *createdCategoryBoards)
}
return createdCategories, nil
}
func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards []model.CategoryBoards) (*model.CategoryBoards, error) {
// create the category
category := model.Category{
Name: defaultCategoryBoards,
UserID: userID,
TeamID: teamID,
Collapsed: false,
Type: model.CategoryTypeSystem,
SortOrder: len(existingCategoryBoards) * model.CategoryBoardsSortOrderGap,
}
createdCategory, err := a.CreateCategory(&category)
if err != nil {
return nil, fmt.Errorf("createBoardsCategory default category creation failed: %w", err)
}
// once the category is created, we need to move all boards which do not
// belong to any category, into this category.
boardMembers, err := a.GetMembersForUser(userID)
if err != nil {
return nil, fmt.Errorf("createBoardsCategory error fetching user's board memberships: %w", err)
}
boardMemberByBoardID := map[string]*model.BoardMember{}
for _, boardMember := range boardMembers {
boardMemberByBoardID[boardMember.BoardID] = boardMember
}
createdCategoryBoards := &model.CategoryBoards{
Category: *createdCategory,
BoardMetadata: []model.CategoryBoardMetadata{},
}
// get user's current team's baords
userTeamBoards, err := a.GetBoardsForUserAndTeam(userID, teamID, false)
if err != nil {
return nil, fmt.Errorf("createBoardsCategory error fetching user's team's boards: %w", err)
}
boardIDsToAdd := []string{}
for _, board := range userTeamBoards {
boardMembership, ok := boardMemberByBoardID[board.ID]
if !ok {
return nil, fmt.Errorf("createBoardsCategory: %w", errBoardMembershipNotFound)
}
// boards with implicit access (aka synthetic membership),
// should show up in LHS only when openign them explicitelly.
// So we don't process any synthetic membership boards
// and only add boards with explicit access to, to the the LHS,
// for example, if a user explicitelly added another user to a board.
if boardMembership.Synthetic {
continue
}
belongsToCategory := false
for _, categoryBoard := range existingCategoryBoards {
for _, metadata := range categoryBoard.BoardMetadata {
if metadata.BoardID == board.ID {
belongsToCategory = true
break
}
}
// stop looking into other categories if
// the board was found in a category
if belongsToCategory {
break
}
}
if !belongsToCategory {
boardIDsToAdd = append(boardIDsToAdd, board.ID)
newBoardMetadata := model.CategoryBoardMetadata{
BoardID: board.ID,
Hidden: false,
}
createdCategoryBoards.BoardMetadata = append(createdCategoryBoards.BoardMetadata, newBoardMetadata)
}
}
if len(boardIDsToAdd) > 0 {
if err := a.AddUpdateUserCategoryBoard(teamID, userID, createdCategory.ID, boardIDsToAdd); err != nil {
return nil, fmt.Errorf("createBoardsCategory failed to add category-less board to the default category, defaultCategoryID: %s, error: %w", createdCategory.ID, err)
}
}
return createdCategoryBoards, nil
}
func (a *App) AddUpdateUserCategoryBoard(teamID, userID, categoryID string, boardIDs []string) error {
if len(boardIDs) == 0 {
return nil
}
err := a.store.AddUpdateCategoryBoard(userID, categoryID, boardIDs)
if err != nil {
return err
}
userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
if err != nil {
return err
}
var updatedCategory *model.CategoryBoards
for i := range userCategoryBoards {
if userCategoryBoards[i].ID == categoryID {
updatedCategory = &userCategoryBoards[i]
break
}
}
if updatedCategory == nil {
return errCategoryNotFound
}
wsPayload := make([]*model.BoardCategoryWebsocketData, len(updatedCategory.BoardMetadata))
i := 0
for _, categoryBoardMetadata := range updatedCategory.BoardMetadata {
wsPayload[i] = &model.BoardCategoryWebsocketData{
BoardID: categoryBoardMetadata.BoardID,
CategoryID: categoryID,
Hidden: categoryBoardMetadata.Hidden,
}
i++
}
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastCategoryBoardChange(
teamID,
userID,
wsPayload,
)
return nil
})
return nil
}
func (a *App) ReorderCategoryBoards(userID, teamID, categoryID string, newBoardsOrder []string) ([]string, error) {
if err := a.verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID, newBoardsOrder); err != nil {
return nil, err
}
newOrder, err := a.store.ReorderCategoryBoards(categoryID, newBoardsOrder)
if err != nil {
return nil, err
}
go func() {
a.wsAdapter.BroadcastCategoryBoardsReorder(teamID, userID, categoryID, newOrder)
}()
return newOrder, nil
}
func (a *App) verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID string, newBoardsOrder []string) error {
// this function is to ensure that we don't miss specifying
// all boards of the category while reordering.
existingCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
if err != nil {
return err
}
var targetCategoryBoards *model.CategoryBoards
for i := range existingCategoryBoards {
if existingCategoryBoards[i].Category.ID == categoryID {
targetCategoryBoards = &existingCategoryBoards[i]
break
}
}
if targetCategoryBoards == nil {
return fmt.Errorf("%w categoryID: %s", errCategoryNotFound, categoryID)
}
if len(targetCategoryBoards.BoardMetadata) != len(newBoardsOrder) {
return fmt.Errorf(
"%w length new category boards: %d, length existing category boards: %d, userID: %s, teamID: %s, categoryID: %s",
errCategoryBoardsLengthMismatch,
len(newBoardsOrder),
len(targetCategoryBoards.BoardMetadata),
userID,
teamID,
categoryID,
)
}
existingBoardMap := map[string]bool{}
for _, metadata := range targetCategoryBoards.BoardMetadata {
existingBoardMap[metadata.BoardID] = true
}
for _, boardID := range newBoardsOrder {
if _, found := existingBoardMap[boardID]; !found {
return fmt.Errorf(
"%w board ID: %s, category ID: %s, userID: %s, teamID: %s",
errBoardNotFoundInCategory,
boardID,
categoryID,
userID,
teamID,
)
}
}
return nil
}
func (a *App) SetBoardVisibility(teamID, userID, categoryID, boardID string, visible bool) error {
if err := a.store.SetBoardVisibility(userID, categoryID, boardID, visible); err != nil {
return fmt.Errorf("SetBoardVisibility: failed to update board visibility: %w", err)
}
a.wsAdapter.BroadcastCategoryBoardChange(teamID, userID, []*model.BoardCategoryWebsocketData{
{
BoardID: boardID,
CategoryID: categoryID,
Hidden: !visible,
},
})
return nil
}

340
server/boards/app/category_boards_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,340 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
func TestGetUserCategoryBoards(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("user had no default category and had boards", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{}, nil).Times(1)
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{
ID: "boards_category_id",
Type: model.CategoryTypeSystem,
Name: "Boards",
},
},
}, nil).Times(1)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category_id",
Name: "Boards",
}, nil)
board1 := &model.Board{
ID: "board_id_1",
}
board2 := &model.Board{
ID: "board_id_2",
}
board3 := &model.Board{
ID: "board_id_3",
}
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
{
BoardID: "board_id_1",
Synthetic: false,
},
{
BoardID: "board_id_2",
Synthetic: false,
},
{
BoardID: "board_id_3",
Synthetic: false,
},
}, nil)
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1", "board_id_2", "board_id_3"}).Return(nil)
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "Boards", categoryBoards[0].Name)
assert.Equal(t, 3, len(categoryBoards[0].BoardMetadata))
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_1", Hidden: false})
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_2", Hidden: false})
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_3", Hidden: false})
})
t.Run("user had no default category BUT had no boards", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{}, nil)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category_id",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "Boards", categoryBoards[0].Name)
assert.Equal(t, 0, len(categoryBoards[0].BoardMetadata))
})
t.Run("user already had a default Boards category with boards in it", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{Name: "Boards"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_1", Hidden: false},
{BoardID: "board_id_2", Hidden: false},
},
},
}, nil)
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "Boards", categoryBoards[0].Name)
assert.Equal(t, 2, len(categoryBoards[0].BoardMetadata))
})
}
func TestCreateBoardsCategory(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("user doesn't have any boards - implicit or explicit", func(t *testing.T) {
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category_id",
Type: "system",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
existingCategoryBoards := []model.CategoryBoards{}
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
assert.NoError(t, err)
assert.NotNil(t, boardsCategory)
assert.Equal(t, "Boards", boardsCategory.Name)
assert.Equal(t, 0, len(boardsCategory.BoardMetadata))
})
t.Run("user has implicit access to some board", func(t *testing.T) {
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category_id",
Type: "system",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
{
BoardID: "board_id_1",
Synthetic: true,
},
{
BoardID: "board_id_2",
Synthetic: true,
},
{
BoardID: "board_id_3",
Synthetic: true,
},
}, nil)
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
existingCategoryBoards := []model.CategoryBoards{}
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
assert.NoError(t, err)
assert.NotNil(t, boardsCategory)
assert.Equal(t, "Boards", boardsCategory.Name)
// there should still be no boards in the default category as
// the user had only implicit access to boards
assert.Equal(t, 0, len(boardsCategory.BoardMetadata))
})
t.Run("user has explicit access to some board", func(t *testing.T) {
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category_id",
Type: "system",
Name: "Boards",
}, nil)
board1 := &model.Board{
ID: "board_id_1",
}
board2 := &model.Board{
ID: "board_id_2",
}
board3 := &model.Board{
ID: "board_id_3",
}
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
{
BoardID: "board_id_1",
Synthetic: false,
},
{
BoardID: "board_id_2",
Synthetic: false,
},
{
BoardID: "board_id_3",
Synthetic: false,
},
}, nil)
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1", "board_id_2", "board_id_3"}).Return(nil)
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{
Type: model.CategoryTypeSystem,
ID: "boards_category_id",
Name: "Boards",
},
},
}, nil)
existingCategoryBoards := []model.CategoryBoards{}
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
assert.NoError(t, err)
assert.NotNil(t, boardsCategory)
assert.Equal(t, "Boards", boardsCategory.Name)
// since user has explicit access to three boards,
// they should all end up in the default category
assert.Equal(t, 3, len(boardsCategory.BoardMetadata))
})
t.Run("user has both implicit and explicit access to some board", func(t *testing.T) {
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category_id",
Type: "system",
Name: "Boards",
}, nil)
board1 := &model.Board{
ID: "board_id_1",
}
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
{
BoardID: "board_id_1",
Synthetic: false,
},
{
BoardID: "board_id_2",
Synthetic: true,
},
{
BoardID: "board_id_3",
Synthetic: true,
},
}, nil)
th.Store.EXPECT().GetBoard(utils.Anything).Return(nil, nil).Times(3)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1"}).Return(nil)
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{
Type: model.CategoryTypeSystem,
ID: "boards_category_id",
Name: "Boards",
},
},
}, nil)
existingCategoryBoards := []model.CategoryBoards{}
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
assert.NoError(t, err)
assert.NotNil(t, boardsCategory)
assert.Equal(t, "Boards", boardsCategory.Name)
// there was only one explicit board access,
// and so only that one should end up in the
// default category
assert.Equal(t, 1, len(boardsCategory.BoardMetadata))
})
}
func TestReorderCategoryBoards(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_1", Hidden: false},
{BoardID: "board_id_2", Hidden: false},
},
},
{
Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_3", Hidden: false},
},
},
{
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
BoardMetadata: []model.CategoryBoardMetadata{},
},
}, nil)
th.Store.EXPECT().ReorderCategoryBoards("category_id_1", []string{"board_id_2", "board_id_1"}).Return([]string{"board_id_2", "board_id_1"}, nil)
newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"})
assert.NoError(t, err)
assert.Equal(t, 2, len(newOrder))
assert.Equal(t, "board_id_2", newOrder[0])
assert.Equal(t, "board_id_1", newOrder[1])
})
t.Run("not specifying all boards", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_1", Hidden: false},
{BoardID: "board_id_2", Hidden: false},
{BoardID: "board_id_3", Hidden: false},
},
},
{
Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_3", Hidden: false},
},
},
{
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
BoardMetadata: []model.CategoryBoardMetadata{},
},
}, nil)
newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"})
assert.Error(t, err)
assert.Nil(t, newOrder)
})
}

497
server/boards/app/category_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,497 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
)
func TestCreateCategory(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "category_id_1",
}, nil)
category := &model.Category{
Name: "Category",
UserID: "user_id",
TeamID: "team_id",
Type: "custom",
}
createdCategory, err := th.App.CreateCategory(category)
assert.NotNil(t, createdCategory)
assert.NoError(t, err)
})
t.Run("creating invalid category", func(t *testing.T) {
category := &model.Category{
Name: "", // empty name shouldn't be allowed
UserID: "user_id",
TeamID: "team_id",
Type: "custom",
}
createdCategory, err := th.App.CreateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.Name = "Name"
category.UserID = "" // empty creator user id shouldn't be allowed
createdCategory, err = th.App.CreateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.UserID = "user_id"
category.TeamID = "" // empty TeamID shouldn't be allowed
createdCategory, err = th.App.CreateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.Type = "invalid" // unknown type shouldn't be allowed
createdCategory, err = th.App.CreateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
})
}
func TestUpdateCategory(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "category_id_1",
Name: "Category",
TeamID: "team_id_1",
UserID: "user_id_1",
Type: "custom",
}, nil)
th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
ID: "category_id_1",
Name: "Category",
}, nil)
category := &model.Category{
ID: "category_id_1",
Name: "Category",
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "custom",
}
updatedCategory, err := th.App.UpdateCategory(category)
assert.NotNil(t, updatedCategory)
assert.NoError(t, err)
})
t.Run("updating invalid category", func(t *testing.T) {
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "category_id_1",
Name: "Category",
TeamID: "team_id_1",
UserID: "user_id_1",
Type: "custom",
}, nil)
category := &model.Category{
ID: "category_id_1",
Name: "Name",
UserID: "user_id",
TeamID: "team_id",
Type: "custom",
}
category.ID = ""
createdCategory, err := th.App.UpdateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.ID = "category_id_1"
category.Name = ""
createdCategory, err = th.App.UpdateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.Name = "Name"
category.UserID = "" // empty creator user id shouldn't be allowed
createdCategory, err = th.App.UpdateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.UserID = "user_id"
category.TeamID = "" // empty TeamID shouldn't be allowed
createdCategory, err = th.App.UpdateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.Type = "invalid" // unknown type shouldn't be allowed
createdCategory, err = th.App.UpdateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
})
t.Run("trying to update someone else's category", func(t *testing.T) {
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "category_id_1",
Name: "Category",
TeamID: "team_id_1",
UserID: "user_id_1",
Type: "custom",
}, nil)
category := &model.Category{
ID: "category_id_1",
Name: "Category",
UserID: "user_id_2",
TeamID: "team_id_1",
Type: "custom",
}
updatedCategory, err := th.App.UpdateCategory(category)
assert.Nil(t, updatedCategory)
assert.Error(t, err)
})
t.Run("trying to update some other team's category", func(t *testing.T) {
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "category_id_1",
Name: "Category",
TeamID: "team_id_1",
UserID: "user_id_1",
Type: "custom",
}, nil)
category := &model.Category{
ID: "category_id_1",
Name: "Category",
UserID: "user_id_1",
TeamID: "team_id_2",
Type: "custom",
}
updatedCategory, err := th.App.UpdateCategory(category)
assert.Nil(t, updatedCategory)
assert.Error(t, err)
})
t.Run("should not be allowed to rename system category", func(t *testing.T) {
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "category_id_1",
Name: "Category",
TeamID: "team_id_1",
UserID: "user_id_1",
Type: "system",
}, nil).Times(1)
th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "category_id_1",
Name: "Category",
TeamID: "team_id_1",
UserID: "user_id_1",
Type: "system",
Collapsed: true,
}, nil).Times(1)
category := &model.Category{
ID: "category_id_1",
Name: "Updated Name",
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "system",
}
updatedCategory, err := th.App.UpdateCategory(category)
assert.NotNil(t, updatedCategory)
assert.NoError(t, err)
assert.Equal(t, "Category", updatedCategory.Name)
})
t.Run("should be allowed to collapse and expand any category type", func(t *testing.T) {
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "category_id_1",
Name: "Category",
TeamID: "team_id_1",
UserID: "user_id_1",
Type: "system",
Collapsed: false,
}, nil).Times(1)
th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "category_id_1",
Name: "Category",
TeamID: "team_id_1",
UserID: "user_id_1",
Type: "system",
Collapsed: true,
}, nil).Times(1)
category := &model.Category{
ID: "category_id_1",
Name: "Updated Name",
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "system",
Collapsed: true,
}
updatedCategory, err := th.App.UpdateCategory(category)
assert.NotNil(t, updatedCategory)
assert.NoError(t, err)
assert.Equal(t, "Category", updatedCategory.Name, "The name should have not been updated")
assert.True(t, updatedCategory.Collapsed)
})
}
func TestDeleteCategory(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
ID: "category_id_1",
DeleteAt: 0,
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "custom",
}, nil)
th.Store.EXPECT().DeleteCategory("category_id_1", "user_id_1", "team_id_1").Return(nil)
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
DeleteAt: 10000,
}, nil)
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
{
Category: model.Category{
ID: "category_id_default",
DeleteAt: 0,
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "default",
Name: "Boards",
},
BoardMetadata: []model.CategoryBoardMetadata{},
},
{
Category: model.Category{
ID: "category_id_1",
DeleteAt: 0,
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "custom",
Name: "Category 1",
},
BoardMetadata: []model.CategoryBoardMetadata{},
},
}, nil)
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
assert.NotNil(t, deletedCategory)
assert.NoError(t, err)
})
t.Run("trying to delete already deleted category", func(t *testing.T) {
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
ID: "category_id_1",
DeleteAt: 1000,
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "custom",
}, nil)
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
assert.NotNil(t, deletedCategory)
assert.NoError(t, err)
})
t.Run("trying to delete system category", func(t *testing.T) {
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
ID: "category_id_1",
DeleteAt: 0,
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "system",
}, nil)
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
assert.Nil(t, deletedCategory)
assert.Error(t, err)
})
}
func TestMoveBoardsToDefaultCategory(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("When default category already exists", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{
ID: "category_id_1",
Name: "Boards",
Type: "system",
},
},
{
Category: model.Category{
ID: "category_id_2",
Name: "Custom Category 1",
Type: "custom",
},
},
}, nil)
err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2")
assert.NoError(t, err)
})
t.Run("When default category doesn't already exists", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{
ID: "category_id_2",
Name: "Custom Category 1",
Type: "custom",
},
},
}, nil)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "default_category_id",
Name: "Boards",
Type: "system",
}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2")
assert.NoError(t, err)
})
}
func TestReorderCategories(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
{
ID: "category_id_1",
Name: "Boards",
Type: "system",
},
{
ID: "category_id_2",
Name: "Category 2",
Type: "custom",
},
{
ID: "category_id_3",
Name: "Category 3",
Type: "custom",
},
}, nil)
th.Store.EXPECT().ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"}).
Return([]string{"category_id_2", "category_id_3", "category_id_1"}, nil)
newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"})
assert.NoError(t, err)
assert.Equal(t, 3, len(newOrder))
})
t.Run("not specifying all categories should fail", func(t *testing.T) {
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
{
ID: "category_id_1",
Name: "Boards",
Type: "system",
},
{
ID: "category_id_2",
Name: "Category 2",
Type: "custom",
},
{
ID: "category_id_3",
Name: "Category 3",
Type: "custom",
},
}, nil)
newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3"})
assert.Error(t, err)
assert.Nil(t, newOrder)
})
}
func TestVerifyNewCategoriesMatchExisting(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
{
ID: "category_id_1",
Name: "Boards",
Type: "system",
},
{
ID: "category_id_2",
Name: "Category 2",
Type: "custom",
},
{
ID: "category_id_3",
Name: "Category 3",
Type: "custom",
},
}, nil)
err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{
"category_id_2",
"category_id_3",
"category_id_1",
})
assert.NoError(t, err)
})
t.Run("different category counts", func(t *testing.T) {
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
{
ID: "category_id_1",
Name: "Boards",
Type: "system",
},
{
ID: "category_id_2",
Name: "Category 2",
Type: "custom",
},
{
ID: "category_id_3",
Name: "Category 3",
Type: "custom",
},
}, nil)
err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{
"category_id_2",
"category_id_3",
})
assert.Error(t, err)
})
}

19
server/boards/app/clientConfig.go Обычный файл
Просмотреть файл

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
func (a *App) GetClientConfig() *model.ClientConfig {
return &model.ClientConfig{
Telemetry: a.config.Telemetry,
TelemetryID: a.config.TelemetryID,
EnablePublicSharedBoards: a.config.EnablePublicSharedBoards,
TeammateNameDisplay: a.config.TeammateNameDisplay,
FeatureFlags: a.config.FeatureFlags,
MaxFileSize: a.config.MaxFileSize,
}
}

36
server/boards/app/clientConfig_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,36 @@
// 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/v6/server/boards/services/config"
)
func TestGetClientConfig(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("Test Get Client Config", func(t *testing.T) {
newConfiguration := config.Configuration{}
newConfiguration.Telemetry = true
newConfiguration.TelemetryID = "abcde"
newConfiguration.EnablePublicSharedBoards = true
newConfiguration.FeatureFlags = make(map[string]string)
newConfiguration.FeatureFlags["BoardsFeature1"] = "true"
newConfiguration.FeatureFlags["BoardsFeature2"] = "true"
newConfiguration.TeammateNameDisplay = "username"
th.App.SetConfig(&newConfiguration)
clientConfig := th.App.GetClientConfig()
require.True(t, clientConfig.EnablePublicSharedBoards)
require.True(t, clientConfig.Telemetry)
require.Equal(t, "abcde", clientConfig.TelemetryID)
require.Equal(t, 2, len(clientConfig.FeatureFlags))
require.Equal(t, "username", clientConfig.TeammateNameDisplay)
})
}

334
server/boards/app/cloud.go Обычный файл
Просмотреть файл

@@ -0,0 +1,334 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"fmt"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
)
var ErrNilPluginAPI = errors.New("server not running in plugin mode")
// GetBoardsCloudLimits returns the limits of the server, and an empty
// limits struct if there are no limits set.
func (a *App) GetBoardsCloudLimits() (*model.BoardsCloudLimits, error) {
// ToDo: Cloud Limits have been disabled by design. We should
// revisit the decision and update the related code accordingly
/*
if !a.IsCloud() {
return &model.BoardsCloudLimits{}, nil
}
productLimits, err := a.store.GetCloudLimits()
if err != nil {
return nil, err
}
usedCards, err := a.store.GetUsedCardsCount()
if err != nil {
return nil, err
}
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
if err != nil {
return nil, err
}
boardsCloudLimits := &model.BoardsCloudLimits{
UsedCards: usedCards,
CardLimitTimestamp: cardLimitTimestamp,
}
if productLimits != nil && productLimits.Boards != nil {
if productLimits.Boards.Cards != nil {
boardsCloudLimits.Cards = *productLimits.Boards.Cards
}
if productLimits.Boards.Views != nil {
boardsCloudLimits.Views = *productLimits.Boards.Views
}
}
return boardsCloudLimits, nil
*/
return &model.BoardsCloudLimits{}, nil
}
func (a *App) GetUsedCardsCount() (int, error) {
return a.store.GetUsedCardsCount()
}
// IsCloud returns true if the server is running as a plugin in a
// cloud licensed server.
func (a *App) IsCloud() bool {
return utils.IsCloudLicense(a.store.GetLicense())
}
// IsCloudLimited returns true if the server is running in cloud mode
// and the card limit has been set.
func (a *App) IsCloudLimited() bool {
// ToDo: Cloud Limits have been disabled by design. We should
// revisit the decision and update the related code accordingly
// return a.CardLimit() != 0 && a.IsCloud()
return false
}
// SetCloudLimits sets the limits of the server.
func (a *App) SetCloudLimits(limits *mm_model.ProductLimits) error {
oldCardLimit := a.CardLimit()
// if the limit object doesn't come complete, we assume limits are
// being disabled
cardLimit := 0
if limits != nil && limits.Boards != nil && limits.Boards.Cards != nil {
cardLimit = *limits.Boards.Cards
}
if oldCardLimit != cardLimit {
a.logger.Info(
"setting new cloud limits",
mlog.Int("oldCardLimit", oldCardLimit),
mlog.Int("cardLimit", cardLimit),
)
a.SetCardLimit(cardLimit)
return a.doUpdateCardLimitTimestamp()
}
a.logger.Info(
"setting new cloud limits, equivalent to the existing ones",
mlog.Int("cardLimit", cardLimit),
)
return nil
}
// doUpdateCardLimitTimestamp performs the update without running any
// checks.
func (a *App) doUpdateCardLimitTimestamp() error {
cardLimitTimestamp, err := a.store.UpdateCardLimitTimestamp(a.CardLimit())
if err != nil {
return err
}
a.wsAdapter.BroadcastCardLimitTimestampChange(cardLimitTimestamp)
return nil
}
// UpdateCardLimitTimestamp checks if the server is a cloud instance
// with limits applied, and if that's true, recalculates the card
// limit timestamp and propagates the new one to the connected
// clients.
func (a *App) UpdateCardLimitTimestamp() error {
if !a.IsCloudLimited() {
return nil
}
return a.doUpdateCardLimitTimestamp()
}
// getTemplateMapForBlocks gets all board ids for the blocks, and
// builds a map with the board IDs as the key and their isTemplate
// field as the value.
func (a *App) getTemplateMapForBlocks(blocks []*model.Block) (map[string]bool, error) {
boardMap := map[string]*model.Board{}
for _, block := range blocks {
if _, ok := boardMap[block.BoardID]; !ok {
board, err := a.store.GetBoard(block.BoardID)
if err != nil {
return nil, err
}
boardMap[block.BoardID] = board
}
}
templateMap := map[string]bool{}
for boardID, board := range boardMap {
templateMap[boardID] = board.IsTemplate
}
return templateMap, nil
}
// ApplyCloudLimits takes a set of blocks and, if the server is cloud
// limited, limits those that are outside of the card limit and don't
// belong to a template.
func (a *App) ApplyCloudLimits(blocks []*model.Block) ([]*model.Block, error) {
// if there is no limit currently being applied, return
if !a.IsCloudLimited() {
return blocks, nil
}
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
if err != nil {
return nil, err
}
templateMap, err := a.getTemplateMapForBlocks(blocks)
if err != nil {
return nil, err
}
limitedBlocks := make([]*model.Block, len(blocks))
for i, block := range blocks {
// if the block belongs to a template, it will never be
// limited
if isTemplate, ok := templateMap[block.BoardID]; ok && isTemplate {
limitedBlocks[i] = block
continue
}
if block.ShouldBeLimited(cardLimitTimestamp) {
limitedBlocks[i] = block.GetLimited()
} else {
limitedBlocks[i] = block
}
}
return limitedBlocks, nil
}
// ContainsLimitedBlocks checks if a list of blocks contain any block
// that references a limited card.
func (a *App) ContainsLimitedBlocks(blocks []*model.Block) (bool, error) {
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
if err != nil {
return false, err
}
if cardLimitTimestamp == 0 {
return false, nil
}
cards := []*model.Block{}
cardIDMap := map[string]bool{}
for _, block := range blocks {
switch block.Type {
case model.TypeCard:
cards = append(cards, block)
default:
cardIDMap[block.ParentID] = true
}
}
cardIDs := []string{}
// if the card is already present on the set, we don't need to
// fetch it from the database
for cardID := range cardIDMap {
alreadyPresent := false
for _, card := range cards {
if card.ID == cardID {
alreadyPresent = true
break
}
}
if !alreadyPresent {
cardIDs = append(cardIDs, cardID)
}
}
if len(cardIDs) > 0 {
fetchedCards, fErr := a.store.GetBlocksByIDs(cardIDs)
if fErr != nil {
return false, fErr
}
cards = append(cards, fetchedCards...)
}
templateMap, err := a.getTemplateMapForBlocks(cards)
if err != nil {
return false, err
}
for _, card := range cards {
isTemplate, ok := templateMap[card.BoardID]
if !ok {
return false, newErrBoardNotFoundInTemplateMap(card.BoardID)
}
// if the block belongs to a template, it will never be
// limited
if isTemplate {
continue
}
if card.ShouldBeLimited(cardLimitTimestamp) {
return true, nil
}
}
return false, nil
}
type errBoardNotFoundInTemplateMap struct {
id string
}
func newErrBoardNotFoundInTemplateMap(id string) *errBoardNotFoundInTemplateMap {
return &errBoardNotFoundInTemplateMap{id}
}
func (eb *errBoardNotFoundInTemplateMap) Error() string {
return fmt.Sprintf("board %q not found in template map", eb.id)
}
func (a *App) NotifyPortalAdminsUpgradeRequest(teamID string) error {
if a.servicesAPI == nil {
return ErrNilPluginAPI
}
team, err := a.store.GetTeam(teamID)
if err != nil {
return err
}
var ofWhat string
if team == nil {
ofWhat = "your organization"
} else {
ofWhat = team.Title
}
message := fmt.Sprintf("A member of %s has notified you to upgrade this workspace before the trial ends.", ofWhat)
page := 0
getUsersOptions := &mm_model.UserGetOptions{
Active: true,
Role: mm_model.SystemAdminRoleId,
PerPage: 50,
Page: page,
}
for ; true; page++ {
getUsersOptions.Page = page
systemAdmins, appErr := a.servicesAPI.GetUsersFromProfiles(getUsersOptions)
if appErr != nil {
a.logger.Error("failed to fetch system admins", mlog.Int("page_size", getUsersOptions.PerPage), mlog.Int("page", page), mlog.Err(appErr))
return appErr
}
if len(systemAdmins) == 0 {
break
}
receiptUserIDs := []string{}
for _, systemAdmin := range systemAdmins {
receiptUserIDs = append(receiptUserIDs, systemAdmin.Id)
}
if err := a.store.SendMessage(message, "custom_cloud_upgrade_nudge", receiptUserIDs); err != nil {
return err
}
}
return nil
}

780
server/boards/app/cloud_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,780 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"database/sql"
"testing"
"github.com/stretchr/testify/assert"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
mockservicesapi "github.com/mattermost/mattermost-server/v6/server/boards/model/mocks"
)
func TestIsCloud(t *testing.T) {
t.Run("if it's not running on plugin mode", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.Store.EXPECT().GetLicense().Return(nil)
require.False(t, th.App.IsCloud())
})
t.Run("if it's running on plugin mode but the license is incomplete", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
fakeLicense := &mm_model.License{}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
require.False(t, th.App.IsCloud())
fakeLicense = &mm_model.License{Features: &mm_model.Features{}}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
require.False(t, th.App.IsCloud())
})
t.Run("if it's running on plugin mode, with a non-cloud license", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
fakeLicense := &mm_model.License{
Features: &mm_model.Features{Cloud: mm_model.NewBool(false)},
}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
require.False(t, th.App.IsCloud())
})
t.Run("if it's running on plugin mode with a cloud license", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
fakeLicense := &mm_model.License{
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
require.True(t, th.App.IsCloud())
})
}
func TestIsCloudLimited(t *testing.T) {
t.Skipf("The Cloud Limits feature has been disabled")
t.Run("if no limit has been set, it should be false", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
require.False(t, th.App.IsCloudLimited())
})
t.Run("if the limit is set, it should be true", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
fakeLicense := &mm_model.License{
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
th.App.SetCardLimit(5)
require.True(t, th.App.IsCloudLimited())
})
}
func TestSetCloudLimits(t *testing.T) {
t.Skipf("The Cloud Limits feature has been disabled")
t.Run("if the limits are empty, it should do nothing", func(t *testing.T) {
t.Run("limits empty", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
require.NoError(t, th.App.SetCloudLimits(nil))
require.Zero(t, th.App.CardLimit())
})
t.Run("limits not empty but board limits empty", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
limits := &mm_model.ProductLimits{}
require.NoError(t, th.App.SetCloudLimits(limits))
require.Zero(t, th.App.CardLimit())
})
t.Run("limits not empty but board limits values empty", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
limits := &mm_model.ProductLimits{
Boards: &mm_model.BoardsLimits{},
}
require.NoError(t, th.App.SetCloudLimits(limits))
require.Zero(t, th.App.CardLimit())
})
})
t.Run("if the limits are not empty, it should update them and calculate the new timestamp", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
newCardLimitTimestamp := int64(27)
th.Store.EXPECT().UpdateCardLimitTimestamp(5).Return(newCardLimitTimestamp, nil)
limits := &mm_model.ProductLimits{
Boards: &mm_model.BoardsLimits{Cards: mm_model.NewInt(5)},
}
require.NoError(t, th.App.SetCloudLimits(limits))
require.Equal(t, 5, th.App.CardLimit())
})
t.Run("if the limits are already set and we unset them, the timestamp will be unset too", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.App.SetCardLimit(20)
th.Store.EXPECT().UpdateCardLimitTimestamp(0)
require.NoError(t, th.App.SetCloudLimits(nil))
require.Zero(t, th.App.CardLimit())
})
t.Run("if the limits are already set and we try to set the same ones again", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.App.SetCardLimit(20)
// the call to update card limit timestamp should not happen
// as the limits didn't change
th.Store.EXPECT().UpdateCardLimitTimestamp(gomock.Any()).Times(0)
limits := &mm_model.ProductLimits{
Boards: &mm_model.BoardsLimits{Cards: mm_model.NewInt(20)},
}
require.NoError(t, th.App.SetCloudLimits(limits))
require.Equal(t, 20, th.App.CardLimit())
})
}
func TestUpdateCardLimitTimestamp(t *testing.T) {
t.Skipf("The Cloud Limits feature has been disabled")
fakeLicense := &mm_model.License{
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
}
t.Run("if the server is a cloud instance but not limited, it should do nothing", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
// the license check will not be done as the limit not being
// set is enough for the method to return
th.Store.EXPECT().GetLicense().Times(0)
// no call to UpdateCardLimitTimestamp should happen as the
// method should shortcircuit if not cloud limited
th.Store.EXPECT().UpdateCardLimitTimestamp(gomock.Any()).Times(0)
require.NoError(t, th.App.UpdateCardLimitTimestamp())
})
t.Run("if the server is a cloud instance and the timestamp is set, it should run the update", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.App.SetCardLimit(5)
th.Store.EXPECT().GetLicense().Return(fakeLicense)
// no call to UpdateCardLimitTimestamp should happen as the
// method should shortcircuit if not cloud limited
th.Store.EXPECT().UpdateCardLimitTimestamp(5)
require.NoError(t, th.App.UpdateCardLimitTimestamp())
})
}
func TestGetTemplateMapForBlocks(t *testing.T) {
t.Skipf("The Cloud Limits feature has been disabled")
t.Run("should fetch the necessary boards from the database", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypeOpen,
IsTemplate: true,
}
board2 := &model.Board{
ID: "board2",
Type: model.BoardTypeOpen,
IsTemplate: false,
}
blocks := []*model.Block{
{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
},
{
ID: "card2",
Type: model.TypeCard,
ParentID: "board2",
BoardID: "board2",
},
{
ID: "text2",
Type: model.TypeText,
ParentID: "card2",
BoardID: "board2",
},
}
th.Store.EXPECT().
GetBoard("board1").
Return(board1, nil).
Times(1)
th.Store.EXPECT().
GetBoard("board2").
Return(board2, nil).
Times(1)
templateMap, err := th.App.getTemplateMapForBlocks(blocks)
require.NoError(t, err)
require.Len(t, templateMap, 2)
require.Contains(t, templateMap, "board1")
require.True(t, templateMap["board1"])
require.Contains(t, templateMap, "board2")
require.False(t, templateMap["board2"])
})
t.Run("should fail if the board is not in the database", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []*model.Block{
{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
},
{
ID: "card2",
Type: model.TypeCard,
ParentID: "board2",
BoardID: "board2",
},
}
th.Store.EXPECT().
GetBoard("board1").
Return(nil, sql.ErrNoRows).
Times(1)
templateMap, err := th.App.getTemplateMapForBlocks(blocks)
require.ErrorIs(t, err, sql.ErrNoRows)
require.Empty(t, templateMap)
})
}
func TestApplyCloudLimits(t *testing.T) {
t.Skipf("The Cloud Limits feature has been disabled")
fakeLicense := &mm_model.License{
Features: &mm_model.Features{Cloud: mm_model.NewBool(true)},
}
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypeOpen,
IsTemplate: false,
}
template := &model.Board{
ID: "template",
Type: model.BoardTypeOpen,
IsTemplate: true,
}
blocks := []*model.Block{
{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 100,
},
{
ID: "text1",
Type: model.TypeText,
ParentID: "card1",
BoardID: "board1",
UpdateAt: 100,
},
{
ID: "card2",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 200,
},
{
ID: "card-from-template",
Type: model.TypeCard,
ParentID: "template",
BoardID: "template",
UpdateAt: 1,
},
}
t.Run("if the server is not limited, it should return the blocks untouched", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
require.Zero(t, th.App.CardLimit())
newBlocks, err := th.App.ApplyCloudLimits(blocks)
require.NoError(t, err)
require.ElementsMatch(t, blocks, newBlocks)
})
t.Run("if the server is limited, it should limit the blocks that are beyond the card limit timestamp", func(t *testing.T) {
findBlock := func(blocks []*model.Block, id string) *model.Block {
for _, block := range blocks {
if block.ID == id {
return block
}
}
require.FailNow(t, "block %s not found", id)
return &model.Block{} // this should never be reached
}
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.App.SetCardLimit(5)
th.Store.EXPECT().GetLicense().Return(fakeLicense)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(150), nil)
th.Store.EXPECT().GetBoard("board1").Return(board1, nil).Times(1)
th.Store.EXPECT().GetBoard("template").Return(template, nil).Times(1)
newBlocks, err := th.App.ApplyCloudLimits(blocks)
require.NoError(t, err)
// should be limited as it's beyond the threshold
require.True(t, findBlock(newBlocks, "card1").Limited)
// only cards are limited
require.False(t, findBlock(newBlocks, "text1").Limited)
// should not be limited as it's not beyond the threshold
require.False(t, findBlock(newBlocks, "card2").Limited)
// cards belonging to templates are never limited
require.False(t, findBlock(newBlocks, "card-from-template").Limited)
})
}
func TestContainsLimitedBlocks(t *testing.T) {
t.Skipf("The Cloud Limits feature has been disabled")
// for all the following tests, the timestamp will be set to 150,
// which means that blocks with an UpdateAt set to 100 will be
// outside the active window and possibly limited, and blocks with
// UpdateAt set to 200 will not
t.Run("should return false if the card limit timestamp is zero", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []*model.Block{
{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 100,
},
}
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(0), nil)
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
require.NoError(t, err)
require.False(t, containsLimitedBlocks)
})
t.Run("should return true if the block set contains a card that is limited", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []*model.Block{
{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 100,
},
}
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypePrivate,
}
th.App.SetCardLimit(500)
cardLimitTimestamp := int64(150)
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
require.NoError(t, err)
require.True(t, containsLimitedBlocks)
})
t.Run("should return false if that same block belongs to a template", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []*model.Block{
{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 100,
},
}
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypeOpen,
IsTemplate: true,
}
th.App.SetCardLimit(500)
cardLimitTimestamp := int64(150)
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
require.NoError(t, err)
require.False(t, containsLimitedBlocks)
})
t.Run("should return true if the block contains a content block that belongs to a card that should be limited", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []*model.Block{
{
ID: "text1",
Type: model.TypeText,
ParentID: "card1",
BoardID: "board1",
UpdateAt: 200,
},
}
card1 := &model.Block{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 100,
}
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypeOpen,
}
th.App.SetCardLimit(500)
cardLimitTimestamp := int64(150)
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
th.Store.EXPECT().GetBlocksByIDs([]string{"card1"}).Return([]*model.Block{card1}, nil)
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
require.NoError(t, err)
require.True(t, containsLimitedBlocks)
})
t.Run("should return false if that same block belongs to a card that is inside the active window", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []*model.Block{
{
ID: "text1",
Type: model.TypeText,
ParentID: "card1",
BoardID: "board1",
UpdateAt: 200,
},
}
card1 := &model.Block{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 200,
}
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypeOpen,
}
th.App.SetCardLimit(500)
cardLimitTimestamp := int64(150)
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
th.Store.EXPECT().GetBlocksByIDs([]string{"card1"}).Return([]*model.Block{card1}, nil)
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
require.NoError(t, err)
require.False(t, containsLimitedBlocks)
})
t.Run("should reach to the database to fetch the necessary information only in an efficient way", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
blocks := []*model.Block{
// a content block that references a card that needs
// fetching
{
ID: "text1",
Type: model.TypeText,
ParentID: "card1",
BoardID: "board1",
UpdateAt: 100,
},
// a board that needs fetching referenced by a card and a content block
{
ID: "card2",
Type: model.TypeCard,
ParentID: "board2",
BoardID: "board2",
// per timestamp should be limited but the board is a
// template
UpdateAt: 100,
},
{
ID: "text2",
Type: model.TypeText,
ParentID: "card2",
BoardID: "board2",
UpdateAt: 200,
},
// a content block that references a card and a board,
// both absent
{
ID: "image3",
Type: model.TypeImage,
ParentID: "card3",
BoardID: "board3",
UpdateAt: 100,
},
}
card1 := &model.Block{
ID: "card1",
Type: model.TypeCard,
ParentID: "board1",
BoardID: "board1",
UpdateAt: 200,
}
card3 := &model.Block{
ID: "card3",
Type: model.TypeCard,
ParentID: "board3",
BoardID: "board3",
UpdateAt: 200,
}
board1 := &model.Board{
ID: "board1",
Type: model.BoardTypeOpen,
}
board2 := &model.Board{
ID: "board2",
Type: model.BoardTypeOpen,
IsTemplate: true,
}
board3 := &model.Board{
ID: "board3",
Type: model.BoardTypePrivate,
}
th.App.SetCardLimit(500)
cardLimitTimestamp := int64(150)
th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil)
th.Store.EXPECT().GetBlocksByIDs(gomock.InAnyOrder([]string{"card1", "card3"})).Return([]*model.Block{card1, card3}, nil)
th.Store.EXPECT().GetBoard("board1").Return(board1, nil)
th.Store.EXPECT().GetBoard("board2").Return(board2, nil)
th.Store.EXPECT().GetBoard("board3").Return(board3, nil)
containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks)
require.NoError(t, err)
require.False(t, containsLimitedBlocks)
})
}
func TestNotifyPortalAdminsUpgradeRequest(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("should send message", func(t *testing.T) {
ctrl := gomock.NewController(t)
servicesAPI := mockservicesapi.NewMockServicesAPI(ctrl)
sysAdmin1 := &mm_model.User{
Id: "michael-scott",
Username: "Michael Scott",
}
sysAdmin2 := &mm_model.User{
Id: "dwight-schrute",
Username: "Dwight Schrute",
}
getUsersOptionsPage0 := &mm_model.UserGetOptions{
Active: true,
Role: mm_model.SystemAdminRoleId,
PerPage: 50,
Page: 0,
}
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage0).Return([]*mm_model.User{sysAdmin1, sysAdmin2}, nil)
getUsersOptionsPage1 := &mm_model.UserGetOptions{
Active: true,
Role: mm_model.SystemAdminRoleId,
PerPage: 50,
Page: 1,
}
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage1).Return([]*mm_model.User{}, nil)
th.App.servicesAPI = servicesAPI
team := &model.Team{
Title: "Dunder Mifflin",
}
th.Store.EXPECT().GetTeam("team-id-1").Return(team, nil)
th.Store.EXPECT().SendMessage(gomock.Any(), "custom_cloud_upgrade_nudge", gomock.Any()).Return(nil).Times(1)
err := th.App.NotifyPortalAdminsUpgradeRequest("team-id-1")
assert.NoError(t, err)
})
t.Run("no sys admins found", func(t *testing.T) {
ctrl := gomock.NewController(t)
servicesAPI := mockservicesapi.NewMockServicesAPI(ctrl)
getUsersOptionsPage0 := &mm_model.UserGetOptions{
Active: true,
Role: mm_model.SystemAdminRoleId,
PerPage: 50,
Page: 0,
}
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage0).Return([]*mm_model.User{}, nil)
th.App.servicesAPI = servicesAPI
team := &model.Team{
Title: "Dunder Mifflin",
}
th.Store.EXPECT().GetTeam("team-id-1").Return(team, nil)
err := th.App.NotifyPortalAdminsUpgradeRequest("team-id-1")
assert.NoError(t, err)
})
t.Run("iterate multiple pages", func(t *testing.T) {
ctrl := gomock.NewController(t)
servicesAPI := mockservicesapi.NewMockServicesAPI(ctrl)
sysAdmin1 := &mm_model.User{
Id: "michael-scott",
Username: "Michael Scott",
}
sysAdmin2 := &mm_model.User{
Id: "dwight-schrute",
Username: "Dwight Schrute",
}
getUsersOptionsPage0 := &mm_model.UserGetOptions{
Active: true,
Role: mm_model.SystemAdminRoleId,
PerPage: 50,
Page: 0,
}
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage0).Return([]*mm_model.User{sysAdmin1}, nil)
getUsersOptionsPage1 := &mm_model.UserGetOptions{
Active: true,
Role: mm_model.SystemAdminRoleId,
PerPage: 50,
Page: 1,
}
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage1).Return([]*mm_model.User{sysAdmin2}, nil)
getUsersOptionsPage2 := &mm_model.UserGetOptions{
Active: true,
Role: mm_model.SystemAdminRoleId,
PerPage: 50,
Page: 2,
}
servicesAPI.EXPECT().GetUsersFromProfiles(getUsersOptionsPage2).Return([]*mm_model.User{}, nil)
th.App.servicesAPI = servicesAPI
team := &model.Team{
Title: "Dunder Mifflin",
}
th.Store.EXPECT().GetTeam("team-id-1").Return(team, nil)
th.Store.EXPECT().SendMessage(gomock.Any(), "custom_cloud_upgrade_nudge", gomock.Any()).Return(nil).Times(2)
err := th.App.NotifyPortalAdminsUpgradeRequest("team-id-1")
assert.NoError(t, err)
})
}

18
server/boards/app/compliance.go Обычный файл
Просмотреть файл

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import "github.com/mattermost/mattermost-server/v6/server/boards/model"
func (a *App) GetBoardsForCompliance(opts model.QueryBoardsForComplianceOptions) ([]*model.Board, bool, error) {
return a.store.GetBoardsForCompliance(opts)
}
func (a *App) GetBoardsComplianceHistory(opts model.QueryBoardsComplianceHistoryOptions) ([]*model.BoardHistory, bool, error) {
return a.store.GetBoardsComplianceHistory(opts)
}
func (a *App) GetBlocksComplianceHistory(opts model.QueryBlocksComplianceHistoryOptions) ([]*model.BlockHistory, bool, error) {
return a.store.GetBlocksComplianceHistory(opts)
}

85
server/boards/app/content_blocks.go Обычный файл
Просмотреть файл

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"github.com/pkg/errors"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
func (a *App) MoveContentBlock(block *model.Block, dstBlock *model.Block, where string, userID string) error {
if block.ParentID != dstBlock.ParentID {
message := fmt.Sprintf("not matching parent %s and %s", block.ParentID, dstBlock.ParentID)
return model.NewErrBadRequest(message)
}
card, err := a.GetBlockByID(block.ParentID)
if err != nil {
return err
}
contentOrderData, ok := card.Fields["contentOrder"]
var contentOrder []interface{}
if ok {
contentOrder = contentOrderData.([]interface{})
}
newContentOrder := []interface{}{}
foundDst := false
foundSrc := false
for _, id := range contentOrder {
stringID, ok := id.(string)
if !ok {
newContentOrder = append(newContentOrder, id)
continue
}
if dstBlock.ID == stringID {
foundDst = true
if where == "after" {
newContentOrder = append(newContentOrder, id)
newContentOrder = append(newContentOrder, block.ID)
} else {
newContentOrder = append(newContentOrder, block.ID)
newContentOrder = append(newContentOrder, id)
}
continue
}
if block.ID == stringID {
foundSrc = true
continue
}
newContentOrder = append(newContentOrder, id)
}
if !foundSrc {
message := fmt.Sprintf("source block %s not found", block.ID)
return model.NewErrBadRequest(message)
}
if !foundDst {
message := fmt.Sprintf("destination block %s not found", dstBlock.ID)
return model.NewErrBadRequest(message)
}
patch := &model.BlockPatch{
UpdatedFields: map[string]interface{}{
"contentOrder": newContentOrder,
},
}
_, err = a.PatchBlock(block.ParentID, patch, userID)
if errors.Is(err, model.ErrPatchUpdatesLimitedCards) {
return err
}
if err != nil {
return err
}
return nil
}

194
server/boards/app/content_blocks_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,194 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"testing"
"github.com/golang/mock/gomock"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
type contentOrderMatcher struct {
contentOrder []string
}
func NewContentOrderMatcher(contentOrder []string) contentOrderMatcher {
return contentOrderMatcher{contentOrder}
}
func (com contentOrderMatcher) Matches(x interface{}) bool {
patch, ok := x.(*model.BlockPatch)
if !ok {
return false
}
contentOrderData, ok := patch.UpdatedFields["contentOrder"]
if !ok {
return false
}
contentOrder, ok := contentOrderData.([]interface{})
if !ok {
return false
}
if len(contentOrder) != len(com.contentOrder) {
return false
}
for i := range contentOrder {
if contentOrder[i] != com.contentOrder[i] {
return false
}
}
return true
}
func (com contentOrderMatcher) String() string {
return fmt.Sprint(&model.BlockPatch{UpdatedFields: map[string]interface{}{"contentOrder": com.contentOrder}})
}
func TestMoveContentBlock(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
ttCases := []struct {
name string
srcBlock model.Block
dstBlock model.Block
parentBlock *model.Block
where string
userID string
mockPatch bool
mockPatchError error
errorMessage string
expectedContentOrder []string
}{
{
name: "not matching parents",
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "other-test-card"},
parentBlock: nil,
where: "after",
userID: "user-id",
errorMessage: "not matching parent test-card and other-test-card",
},
{
name: "parent not found",
srcBlock: model.Block{ID: "test-1", ParentID: "invalid-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "invalid-card"},
parentBlock: &model.Block{ID: "invalid-card"},
where: "after",
userID: "user-id",
errorMessage: "{test} not found",
},
{
name: "valid parent without content order",
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card"},
where: "after",
userID: "user-id",
errorMessage: "source block test-1 not found",
},
{
name: "valid parent with content order but without test-1 in it",
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-2"}}},
where: "after",
userID: "user-id",
errorMessage: "source block test-1 not found",
},
{
name: "valid parent with content order but without test-2 in it",
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1"}}},
where: "after",
userID: "user-id",
errorMessage: "destination block test-2 not found",
},
{
name: "valid request but fail on patchparent with content order",
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2"}}},
where: "after",
userID: "user-id",
mockPatch: true,
mockPatchError: errors.New("test error"),
errorMessage: "test error",
},
{
name: "valid request with not real change",
srcBlock: model.Block{ID: "test-2", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-1", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"},
where: "after",
userID: "user-id",
mockPatch: true,
errorMessage: "",
expectedContentOrder: []string{"test-1", "test-2", "test-3"},
},
{
name: "valid request changing order with before",
srcBlock: model.Block{ID: "test-2", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-1", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"},
where: "before",
userID: "user-id",
mockPatch: true,
errorMessage: "",
expectedContentOrder: []string{"test-2", "test-1", "test-3"},
},
{
name: "valid request changing order with after",
srcBlock: model.Block{ID: "test-1", ParentID: "test-card"},
dstBlock: model.Block{ID: "test-2", ParentID: "test-card"},
parentBlock: &model.Block{ID: "test-card", Fields: map[string]interface{}{"contentOrder": []interface{}{"test-1", "test-2", "test-3"}}, BoardID: "test-board"},
where: "after",
userID: "user-id",
mockPatch: true,
errorMessage: "",
expectedContentOrder: []string{"test-2", "test-1", "test-3"},
},
}
for _, tc := range ttCases {
t.Run(tc.name, func(t *testing.T) {
if tc.parentBlock != nil {
if tc.parentBlock.ID == "invalid-card" {
th.Store.EXPECT().GetBlock(tc.srcBlock.ParentID).Return(nil, model.NewErrNotFound("test"))
} else {
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil)
if tc.mockPatch {
if tc.mockPatchError != nil {
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(nil, tc.mockPatchError)
} else {
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil)
th.Store.EXPECT().PatchBlock(tc.parentBlock.ID, NewContentOrderMatcher(tc.expectedContentOrder), gomock.Eq("user-id")).Return(nil)
th.Store.EXPECT().GetBlock(tc.parentBlock.ID).Return(tc.parentBlock, nil)
th.Store.EXPECT().GetBoard(tc.parentBlock.BoardID).Return(&model.Board{ID: "test-board"}, nil)
// this call comes from the WS server notification
th.Store.EXPECT().GetMembersForBoard(gomock.Any()).Times(1)
}
}
}
}
err := th.App.MoveContentBlock(&tc.srcBlock, &tc.dstBlock, tc.where, tc.userID)
if tc.errorMessage == "" {
require.NoError(t, err)
} else {
require.EqualError(t, err, tc.errorMessage)
}
})
}
}

253
server/boards/app/export.go Обычный файл
Просмотреть файл

@@ -0,0 +1,253 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"github.com/wiggin77/merror"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
var (
newline = []byte{'\n'}
)
func (a *App) ExportArchive(w io.Writer, opt model.ExportArchiveOptions) (errs error) {
boards, err := a.getBoardsForArchive(opt.BoardIDs)
if err != nil {
return err
}
merr := merror.New()
defer func() {
errs = merr.ErrorOrNil()
}()
// wrap the writer in a zip.
zw := zip.NewWriter(w)
defer func() {
merr.Append(zw.Close())
}()
if err := a.writeArchiveVersion(zw); err != nil {
merr.Append(err)
return
}
for _, board := range boards {
if err := a.writeArchiveBoard(zw, board, opt); err != nil {
merr.Append(fmt.Errorf("cannot export board %s: %w", board.ID, err))
return
}
}
return nil
}
// writeArchiveVersion writes a version file to the zip.
func (a *App) writeArchiveVersion(zw *zip.Writer) error {
archiveHeader := model.ArchiveHeader{
Version: archiveVersion,
Date: model.GetMillis(),
}
b, _ := json.Marshal(&archiveHeader)
w, err := zw.Create("version.json")
if err != nil {
return fmt.Errorf("cannot write archive header: %w", err)
}
if _, err := w.Write(b); err != nil {
return fmt.Errorf("cannot write archive header: %w", err)
}
return nil
}
// writeArchiveBoard writes a single board to the archive in a zip directory.
func (a *App) writeArchiveBoard(zw *zip.Writer, board model.Board, opt model.ExportArchiveOptions) error {
// create a directory per board
w, err := zw.Create(board.ID + "/board.jsonl")
if err != nil {
return err
}
// write the board block first
if err = a.writeArchiveBoardLine(w, board); err != nil {
return err
}
var files []string
// write the board's blocks
// TODO: paginate this
blocks, err := a.GetBlocksForBoard(board.ID)
if err != nil {
return err
}
for _, block := range blocks {
if err = a.writeArchiveBlockLine(w, block); err != nil {
return err
}
if block.Type == model.TypeImage {
filename, err2 := extractImageFilename(block)
if err2 != nil {
return err
}
files = append(files, filename)
}
}
boardMembers, err := a.GetMembersForBoard(board.ID)
if err != nil {
return err
}
for _, boardMember := range boardMembers {
if err = a.writeArchiveBoardMemberLine(w, boardMember); err != nil {
return err
}
}
// write the files
for _, filename := range files {
if err := a.writeArchiveFile(zw, filename, board.ID, opt); err != nil {
return fmt.Errorf("cannot write file %s to archive: %w", filename, err)
}
}
return nil
}
// writeArchiveBoardMemberLine writes a single boardMember to the archive.
func (a *App) writeArchiveBoardMemberLine(w io.Writer, boardMember *model.BoardMember) error {
bm, err := json.Marshal(&boardMember)
if err != nil {
return err
}
line := model.ArchiveLine{
Type: "boardMember",
Data: bm,
}
bm, err = json.Marshal(&line)
if err != nil {
return err
}
_, err = w.Write(bm)
if err != nil {
return err
}
_, err = w.Write(newline)
return err
}
// writeArchiveBlockLine writes a single block to the archive.
func (a *App) writeArchiveBlockLine(w io.Writer, block *model.Block) error {
b, err := json.Marshal(&block)
if err != nil {
return err
}
line := model.ArchiveLine{
Type: "block",
Data: b,
}
b, err = json.Marshal(&line)
if err != nil {
return err
}
_, err = w.Write(b)
if err != nil {
return err
}
// jsonl files need a newline
_, err = w.Write(newline)
return err
}
// writeArchiveBlockLine writes a single block to the archive.
func (a *App) writeArchiveBoardLine(w io.Writer, board model.Board) error {
b, err := json.Marshal(&board)
if err != nil {
return err
}
line := model.ArchiveLine{
Type: "board",
Data: b,
}
b, err = json.Marshal(&line)
if err != nil {
return err
}
_, err = w.Write(b)
if err != nil {
return err
}
// jsonl files need a newline
_, err = w.Write(newline)
return err
}
// writeArchiveFile writes a single file to the archive.
func (a *App) writeArchiveFile(zw *zip.Writer, filename string, boardID string, opt model.ExportArchiveOptions) error {
dest, err := zw.Create(boardID + "/" + filename)
if err != nil {
return err
}
src, err := a.GetFileReader(opt.TeamID, boardID, filename)
if err != nil {
// just log this; image file is missing but we'll still export an equivalent board
a.logger.Error("image file missing for export",
mlog.String("filename", filename),
mlog.String("team_id", opt.TeamID),
mlog.String("board_id", boardID),
)
return nil
}
defer src.Close()
_, err = io.Copy(dest, src)
return err
}
// getBoardsForArchive fetches all the specified boards.
func (a *App) getBoardsForArchive(boardIDs []string) ([]model.Board, error) {
boards := make([]model.Board, 0, len(boardIDs))
for _, id := range boardIDs {
b, err := a.GetBoard(id)
if err != nil {
return nil, fmt.Errorf("could not fetch board %s: %w", id, err)
}
boards = append(boards, *b)
}
return boards, nil
}
func extractImageFilename(imageBlock *model.Block) (string, error) {
f, ok := imageBlock.Fields["fileId"]
if !ok {
return "", model.ErrInvalidImageBlock
}
filename, ok := f.(string)
if !ok {
return "", model.ErrInvalidImageBlock
}
return filename, nil
}

177
server/boards/app/files.go Обычный файл
Просмотреть файл

@@ -0,0 +1,177 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"fmt"
"io"
"path/filepath"
"strings"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
const emptyString = "empty"
var errEmptyFilename = errors.New("IsFileArchived: empty filename not allowed")
var ErrFileNotFound = errors.New("file not found")
func (a *App) SaveFile(reader io.Reader, teamID, rootID, filename string) (string, error) {
// NOTE: File extension includes the dot
fileExtension := strings.ToLower(filepath.Ext(filename))
if fileExtension == ".jpeg" {
fileExtension = ".jpg"
}
createdFilename := utils.NewID(utils.IDTypeNone)
fullFilename := fmt.Sprintf(`%s%s`, createdFilename, fileExtension)
filePath := filepath.Join(utils.GetBaseFilePath(), fullFilename)
fileSize, appErr := a.filesBackend.WriteFile(reader, filePath)
if appErr != nil {
return "", fmt.Errorf("unable to store the file in the files storage: %w", appErr)
}
now := utils.GetMillis()
fileInfo := &mm_model.FileInfo{
Id: createdFilename[1:],
CreatorId: "boards",
PostId: emptyString,
ChannelId: emptyString,
CreateAt: now,
UpdateAt: now,
DeleteAt: 0,
Path: filePath,
ThumbnailPath: emptyString,
PreviewPath: emptyString,
Name: filename,
Extension: fileExtension,
Size: fileSize,
MimeType: emptyString,
Width: 0,
Height: 0,
HasPreviewImage: false,
MiniPreview: nil,
Content: "",
RemoteId: nil,
}
err := a.store.SaveFileInfo(fileInfo)
if err != nil {
return "", err
}
return fullFilename, nil
}
func (a *App) GetFileInfo(filename string) (*mm_model.FileInfo, error) {
if filename == "" {
return nil, errEmptyFilename
}
// filename is in the format 7<some-alphanumeric-string>.<extension>
// we want to extract the <some-alphanumeric-string> part of this as this
// will be the fileinfo id.
parts := strings.Split(filename, ".")
fileInfoID := parts[0][1:]
fileInfo, err := a.store.GetFileInfo(fileInfoID)
if err != nil {
return nil, err
}
return fileInfo, nil
}
func (a *App) GetFile(teamID, rootID, fileName string) (*mm_model.FileInfo, filestore.ReadCloseSeeker, error) {
fileInfo, err := a.GetFileInfo(fileName)
if err != nil && !model.IsErrNotFound(err) {
a.logger.Error("111")
return nil, nil, err
}
var filePath string
if fileInfo != nil && fileInfo.Path != "" {
filePath = fileInfo.Path
} else {
filePath = filepath.Join(teamID, rootID, fileName)
}
exists, err := a.filesBackend.FileExists(filePath)
if err != nil {
a.logger.Error(fmt.Sprintf("GetFile: Failed to check if file exists as path. Path: %s, error: %e", filePath, err))
return nil, nil, err
}
if !exists {
return nil, nil, ErrFileNotFound
}
reader, err := a.filesBackend.Reader(filePath)
if err != nil {
a.logger.Error(fmt.Sprintf("GetFile: Failed to get file reader of existing file at path: %s, error: %e", filePath, err))
return nil, nil, err
}
return fileInfo, reader, nil
}
func (a *App) GetFileReader(teamID, rootID, filename string) (filestore.ReadCloseSeeker, error) {
filePath := filepath.Join(teamID, rootID, filename)
exists, err := a.filesBackend.FileExists(filePath)
if err != nil {
return nil, err
}
// FIXUP: Check the deprecated old location
if teamID == "0" && !exists {
oldExists, err2 := a.filesBackend.FileExists(filename)
if err2 != nil {
return nil, err2
}
if oldExists {
err2 := a.filesBackend.MoveFile(filename, filePath)
if err2 != nil {
a.logger.Error("ERROR moving file",
mlog.String("old", filename),
mlog.String("new", filePath),
mlog.Err(err2),
)
} else {
a.logger.Debug("Moved file",
mlog.String("old", filename),
mlog.String("new", filePath),
)
}
}
} else if !exists {
return nil, ErrFileNotFound
}
reader, err := a.filesBackend.Reader(filePath)
if err != nil {
return nil, err
}
return reader, nil
}
func (a *App) MoveFile(channelID, teamID, boardID, filename string) error {
oldPath := filepath.Join(channelID, boardID, filename)
newPath := filepath.Join(teamID, boardID, filename)
err := a.filesBackend.MoveFile(oldPath, newPath)
if err != nil {
a.logger.Error("ERROR moving file",
mlog.String("old", oldPath),
mlog.String("new", newPath),
mlog.Err(err),
)
return err
}
return nil
}

387
server/boards/app/files_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,387 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore/mocks"
)
const (
testFileName = "temp-file-name"
testBoardID = "test-board-id"
)
var errDummy = errors.New("hello")
type TestError struct{}
func (err *TestError) Error() string { return "Mocked File backend error" }
func TestGetFileReader(t *testing.T) {
testFilePath := filepath.Join("1", "test-board-id", "temp-file-name")
th, _ := SetupTestHelper(t)
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
t.Run("should get file reader from filestore successfully", func(t *testing.T) {
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
readerFunc := func(path string) filestore.ReadCloseSeeker {
return mockedReadCloseSeek
}
readerErrorFunc := func(path string) error {
return nil
}
fileExistsFunc := func(path string) bool {
return true
}
fileExistsErrorFunc := func(path string) error {
return nil
}
mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc)
mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc)
actual, _ := th.App.GetFileReader("1", testBoardID, testFileName)
assert.Equal(t, mockedReadCloseSeek, actual)
})
t.Run("should get error from filestore when file exists return error", func(t *testing.T) {
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedError := &TestError{}
readerFunc := func(path string) filestore.ReadCloseSeeker {
return mockedReadCloseSeek
}
readerErrorFunc := func(path string) error {
return nil
}
fileExistsFunc := func(path string) bool {
return false
}
fileExistsErrorFunc := func(path string) error {
return mockedError
}
mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc)
mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc)
actual, err := th.App.GetFileReader("1", testBoardID, testFileName)
assert.Error(t, err, mockedError)
assert.Nil(t, actual)
})
t.Run("should return error, if get reader from file backend returns error", func(t *testing.T) {
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedError := &TestError{}
readerFunc := func(path string) filestore.ReadCloseSeeker {
return nil
}
readerErrorFunc := func(path string) error {
return mockedError
}
fileExistsFunc := func(path string) bool {
return false
}
fileExistsErrorFunc := func(path string) error {
return nil
}
mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc)
mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc)
actual, err := th.App.GetFileReader("1", testBoardID, testFileName)
assert.Error(t, err, mockedError)
assert.Nil(t, actual)
})
t.Run("should move file from old filepath to new filepath, if file doesnot exists in new filepath and workspace id is 0", func(t *testing.T) {
filePath := filepath.Join("0", "test-board-id", "temp-file-name")
workspaceid := "0"
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
readerFunc := func(path string) filestore.ReadCloseSeeker {
return mockedReadCloseSeek
}
readerErrorFunc := func(path string) error {
return nil
}
fileExistsFunc := func(path string) bool {
// return true for old path
return path == testFileName
}
fileExistsErrorFunc := func(path string) error {
return nil
}
moveFileFunc := func(oldFileName, newFileName string) error {
return nil
}
mockedFileBackend.On("FileExists", filePath).Return(fileExistsFunc, fileExistsErrorFunc)
mockedFileBackend.On("FileExists", testFileName).Return(fileExistsFunc, fileExistsErrorFunc)
mockedFileBackend.On("MoveFile", testFileName, filePath).Return(moveFileFunc)
mockedFileBackend.On("Reader", filePath).Return(readerFunc, readerErrorFunc)
actual, _ := th.App.GetFileReader(workspaceid, testBoardID, testFileName)
assert.Equal(t, mockedReadCloseSeek, actual)
})
t.Run("should return file reader, if file doesnot exists in new filepath and old file path", func(t *testing.T) {
filePath := filepath.Join("0", "test-board-id", "temp-file-name")
fileName := testFileName
workspaceid := "0"
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
readerFunc := func(path string) filestore.ReadCloseSeeker {
return mockedReadCloseSeek
}
readerErrorFunc := func(path string) error {
return nil
}
fileExistsFunc := func(path string) bool {
// return true for old path
return false
}
fileExistsErrorFunc := func(path string) error {
return nil
}
moveFileFunc := func(oldFileName, newFileName string) error {
return nil
}
mockedFileBackend.On("FileExists", filePath).Return(fileExistsFunc, fileExistsErrorFunc)
mockedFileBackend.On("FileExists", testFileName).Return(fileExistsFunc, fileExistsErrorFunc)
mockedFileBackend.On("MoveFile", fileName, filePath).Return(moveFileFunc)
mockedFileBackend.On("Reader", filePath).Return(readerFunc, readerErrorFunc)
actual, _ := th.App.GetFileReader(workspaceid, testBoardID, testFileName)
assert.Equal(t, mockedReadCloseSeek, actual)
})
}
func TestSaveFile(t *testing.T) {
th, _ := SetupTestHelper(t)
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
t.Run("should save file to file store using file backend", func(t *testing.T) {
fileName := "temp-file-name.txt"
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil)
writeFileFunc := func(reader io.Reader, path string) int64 {
paths := strings.Split(path, string(os.PathSeparator))
assert.Equal(t, "boards", paths[0])
assert.Equal(t, time.Now().Format("20060102"), paths[1])
fileName = paths[2]
return int64(10)
}
writeFileErrorFunc := func(reader io.Reader, filePath string) error {
return nil
}
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", testBoardID, fileName)
assert.Equal(t, fileName, actual)
assert.NoError(t, err)
})
t.Run("should save .jpeg file as jpg file to file store using file backend", func(t *testing.T) {
fileName := "temp-file-name.jpeg"
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil)
writeFileFunc := func(reader io.Reader, path string) int64 {
paths := strings.Split(path, string(os.PathSeparator))
assert.Equal(t, "boards", paths[0])
assert.Equal(t, time.Now().Format("20060102"), paths[1])
assert.Equal(t, "jpg", strings.Split(paths[2], ".")[1])
return int64(10)
}
writeFileErrorFunc := func(reader io.Reader, filePath string) error {
return nil
}
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName)
assert.NoError(t, err)
assert.NotNil(t, actual)
})
t.Run("should return error when fileBackend.WriteFile returns error", func(t *testing.T) {
fileName := "temp-file-name.jpeg"
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedError := &TestError{}
writeFileFunc := func(reader io.Reader, path string) int64 {
paths := strings.Split(path, string(os.PathSeparator))
assert.Equal(t, "boards", paths[0])
assert.Equal(t, time.Now().Format("20060102"), paths[1])
assert.Equal(t, "jpg", strings.Split(paths[2], ".")[1])
return int64(10)
}
writeFileErrorFunc := func(reader io.Reader, filePath string) error {
return mockedError
}
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-board-id", fileName)
assert.Equal(t, "", actual)
assert.Equal(t, "unable to store the file in the files storage: Mocked File backend error", err.Error())
})
}
func TestGetFileInfo(t *testing.T) {
th, _ := SetupTestHelper(t)
t.Run("should return file info", func(t *testing.T) {
fileInfo := &mm_model.FileInfo{
Id: "file_info_id",
Archived: false,
}
th.Store.EXPECT().GetFileInfo("filename").Return(fileInfo, nil).Times(2)
fetchedFileInfo, err := th.App.GetFileInfo("Afilename")
assert.NoError(t, err)
assert.Equal(t, "file_info_id", fetchedFileInfo.Id)
assert.False(t, fetchedFileInfo.Archived)
fetchedFileInfo, err = th.App.GetFileInfo("Afilename.txt")
assert.NoError(t, err)
assert.Equal(t, "file_info_id", fetchedFileInfo.Id)
assert.False(t, fetchedFileInfo.Archived)
})
t.Run("should return archived file info", func(t *testing.T) {
fileInfo := &mm_model.FileInfo{
Id: "file_info_id",
Archived: true,
}
th.Store.EXPECT().GetFileInfo("filename").Return(fileInfo, nil)
fetchedFileInfo, err := th.App.GetFileInfo("Afilename")
assert.NoError(t, err)
assert.Equal(t, "file_info_id", fetchedFileInfo.Id)
assert.True(t, fetchedFileInfo.Archived)
})
t.Run("should return archived file infoerror", func(t *testing.T) {
th.Store.EXPECT().GetFileInfo("filename").Return(nil, errDummy)
fetchedFileInfo, err := th.App.GetFileInfo("Afilename")
assert.Error(t, err)
assert.Nil(t, fetchedFileInfo)
})
}
func TestGetFile(t *testing.T) {
th, _ := SetupTestHelper(t)
t.Run("when FileInfo exists", func(t *testing.T) {
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{
Id: "fileInfoID",
Path: "/path/to/file/fileName.txt",
}, nil)
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
readerFunc := func(path string) filestore.ReadCloseSeeker {
return mockedReadCloseSeek
}
readerErrorFunc := func(path string) error {
return nil
}
mockedFileBackend.On("Reader", "/path/to/file/fileName.txt").Return(readerFunc, readerErrorFunc)
mockedFileBackend.On("FileExists", "/path/to/file/fileName.txt").Return(true, nil)
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
assert.NoError(t, err)
assert.NotNil(t, fileInfo)
assert.NotNil(t, seeker)
})
t.Run("when FileInfo doesn't exist", func(t *testing.T) {
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(nil, nil)
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
readerFunc := func(path string) filestore.ReadCloseSeeker {
return mockedReadCloseSeek
}
readerErrorFunc := func(path string) error {
return nil
}
mockedFileBackend.On("Reader", "teamID/boardID/7fileInfoID.txt").Return(readerFunc, readerErrorFunc)
mockedFileBackend.On("FileExists", "teamID/boardID/7fileInfoID.txt").Return(true, nil)
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
assert.NoError(t, err)
assert.Nil(t, fileInfo)
assert.NotNil(t, seeker)
})
t.Run("when FileInfo exists but FileInfo.Path is not set", func(t *testing.T) {
th.Store.EXPECT().GetFileInfo("fileInfoID").Return(&mm_model.FileInfo{
Id: "fileInfoID",
Path: "",
}, nil)
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
readerFunc := func(path string) filestore.ReadCloseSeeker {
return mockedReadCloseSeek
}
readerErrorFunc := func(path string) error {
return nil
}
mockedFileBackend.On("Reader", "teamID/boardID/7fileInfoID.txt").Return(readerFunc, readerErrorFunc)
mockedFileBackend.On("FileExists", "teamID/boardID/7fileInfoID.txt").Return(true, nil)
fileInfo, seeker, err := th.App.GetFile("teamID", "boardID", "7fileInfoID.txt")
assert.NoError(t, err)
assert.NotNil(t, fileInfo)
assert.NotNil(t, seeker)
})
}

74
server/boards/app/helper_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/golang/mock/gomock"
"github.com/mattermost/mattermost-server/v6/server/boards/auth"
"github.com/mattermost/mattermost-server/v6/server/boards/services/config"
"github.com/mattermost/mattermost-server/v6/server/boards/services/metrics"
"github.com/mattermost/mattermost-server/v6/server/boards/services/permissions/mmpermissions"
mmpermissionsMocks "github.com/mattermost/mattermost-server/v6/server/boards/services/permissions/mmpermissions/mocks"
permissionsMocks "github.com/mattermost/mattermost-server/v6/server/boards/services/permissions/mocks"
"github.com/mattermost/mattermost-server/v6/server/boards/services/store/mockstore"
"github.com/mattermost/mattermost-server/v6/server/boards/services/webhook"
"github.com/mattermost/mattermost-server/v6/server/boards/ws"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/filestore/mocks"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
type TestHelper struct {
App *App
Store *mockstore.MockStore
FilesBackend *mocks.FileBackend
logger mlog.LoggerIFace
API *mmpermissionsMocks.MockAPI
}
func SetupTestHelper(t *testing.T) (*TestHelper, func()) {
ctrl := gomock.NewController(t)
cfg := config.Configuration{}
store := mockstore.NewMockStore(ctrl)
filesBackend := &mocks.FileBackend{}
auth := auth.New(&cfg, store, nil)
logger := mlog.CreateConsoleTestLogger(false, mlog.LvlDebug)
sessionToken := "TESTTOKEN"
wsserver := ws.NewServer(auth, sessionToken, false, logger, store)
webhook := webhook.NewClient(&cfg, logger)
metricsService := metrics.NewMetrics(metrics.InstanceInfo{})
mockStore := permissionsMocks.NewMockStore(ctrl)
mockAPI := mmpermissionsMocks.NewMockAPI(ctrl)
permissions := mmpermissions.New(mockStore, mockAPI, mlog.CreateConsoleTestLogger(true, mlog.LvlError))
appServices := Services{
Auth: auth,
Store: store,
FilesBackend: filesBackend,
Webhook: webhook,
Metrics: metricsService,
Logger: logger,
SkipTemplateInit: true,
Permissions: permissions,
}
app2 := New(&cfg, wsserver, appServices)
tearDown := func() {
app2.Shutdown()
if logger != nil {
_ = logger.Shutdown()
}
}
return &TestHelper{
App: app2,
Store: store,
FilesBackend: filesBackend,
logger: logger,
API: mockAPI,
}, tearDown
}

449
server/boards/app/import.go Обычный файл
Просмотреть файл

@@ -0,0 +1,449 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"path/filepath"
"strings"
"github.com/krolaw/zipstream"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
const (
archiveVersion = 2
legacyFileBegin = "{\"version\":1"
)
var (
errBlockIsNotABoard = errors.New("block is not a board")
)
// ImportArchive imports an archive containing zero or more boards, plus all
// associated content, including cards, content blocks, views, and images.
//
// Archives are ZIP files containing a `version.json` file and zero or more
// directories, each containing a `board.jsonl` and zero or more image files.
func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error {
// peek at the first bytes to see if this is a legacy archive format
br := bufio.NewReader(r)
peek, err := br.Peek(len(legacyFileBegin))
if err == nil && string(peek) == legacyFileBegin {
a.logger.Debug("importing legacy archive")
_, errImport := a.ImportBoardJSONL(br, opt)
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after importing a legacy file",
mlog.Err(err),
)
}
}()
return errImport
}
a.logger.Debug("importing archive")
zr := zipstream.NewReader(br)
boardMap := make(map[string]string) // maps old board ids to new
for {
hdr, err := zr.Next()
if err != nil {
if errors.Is(err, io.EOF) {
a.logger.Debug("import archive - done", mlog.Int("boards_imported", len(boardMap)))
return nil
}
return err
}
dir, filename := filepath.Split(hdr.Name)
dir = path.Clean(dir)
switch filename {
case "version.json":
ver, errVer := parseVersionFile(zr)
if errVer != nil {
return errVer
}
if ver != archiveVersion {
return model.NewErrUnsupportedArchiveVersion(ver, archiveVersion)
}
case "board.jsonl":
boardID, err := a.ImportBoardJSONL(zr, opt)
if err != nil {
return fmt.Errorf("cannot import board %s: %w", dir, err)
}
boardMap[dir] = boardID
default:
// import file/image; dir is the old board id
boardID, ok := boardMap[dir]
if !ok {
a.logger.Warn("skipping orphan image in archive",
mlog.String("dir", dir),
mlog.String("filename", filename),
)
continue
}
// save file with original filename so it matches name in image block.
filePath := filepath.Join(opt.TeamID, boardID, filename)
_, err := a.filesBackend.WriteFile(zr, filePath)
if err != nil {
return fmt.Errorf("cannot import file %s for board %s: %w", filename, dir, err)
}
}
a.logger.Trace("import archive file",
mlog.String("dir", dir),
mlog.String("filename", filename),
)
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after importing an archive",
mlog.Err(err),
)
}
}()
}
}
// ImportBoardJSONL imports a JSONL file containing blocks for one board. The resulting
// board id is returned.
func (a *App) ImportBoardJSONL(r io.Reader, opt model.ImportArchiveOptions) (string, error) {
// TODO: Stream this once `model.GenerateBlockIDs` can take a stream of blocks.
// We don't want to load the whole file in memory, even though it's a single board.
boardsAndBlocks := &model.BoardsAndBlocks{
Blocks: make([]*model.Block, 0, 10),
Boards: make([]*model.Board, 0, 10),
}
lineReader := bufio.NewReader(r)
userID := opt.ModifiedBy
if userID == model.SingleUser {
userID = ""
}
now := utils.GetMillis()
var boardID string
var boardMembers []*model.BoardMember
lineNum := 1
firstLine := true
for {
line, errRead := readLine(lineReader)
if len(line) != 0 {
var skip bool
if firstLine {
// first line might be a header tag (old archive format)
if strings.HasPrefix(string(line), legacyFileBegin) {
skip = true
}
}
if !skip {
var archiveLine model.ArchiveLine
if err := json.Unmarshal(line, &archiveLine); err != nil {
return "", fmt.Errorf("error parsing archive line %d: %w", lineNum, err)
}
// first line must be a board
if firstLine && archiveLine.Type == "block" {
archiveLine.Type = "board_block"
}
switch archiveLine.Type {
case "board":
var board model.Board
if err2 := json.Unmarshal(archiveLine.Data, &board); err2 != nil {
return "", fmt.Errorf("invalid board in archive line %d: %w", lineNum, err2)
}
board.ModifiedBy = userID
board.UpdateAt = now
board.TeamID = opt.TeamID
boardsAndBlocks.Boards = append(boardsAndBlocks.Boards, &board)
boardID = board.ID
case "board_block":
// legacy archives encoded boards as blocks; we need to convert them to real boards.
var block *model.Block
if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil {
return "", fmt.Errorf("invalid board block in archive line %d: %w", lineNum, err2)
}
block.ModifiedBy = userID
block.UpdateAt = now
board, err := a.blockToBoard(block, opt)
if err != nil {
return "", fmt.Errorf("cannot convert archive line %d to block: %w", lineNum, err)
}
boardsAndBlocks.Boards = append(boardsAndBlocks.Boards, board)
boardID = board.ID
case "block":
var block *model.Block
if err2 := json.Unmarshal(archiveLine.Data, &block); err2 != nil {
return "", fmt.Errorf("invalid block in archive line %d: %w", lineNum, err2)
}
block.ModifiedBy = userID
block.UpdateAt = now
block.BoardID = boardID
boardsAndBlocks.Blocks = append(boardsAndBlocks.Blocks, block)
case "boardMember":
var boardMember *model.BoardMember
if err2 := json.Unmarshal(archiveLine.Data, &boardMember); err2 != nil {
return "", fmt.Errorf("invalid board Member in archive line %d: %w", lineNum, err2)
}
boardMembers = append(boardMembers, boardMember)
default:
return "", model.NewErrUnsupportedArchiveLineType(lineNum, archiveLine.Type)
}
firstLine = false
}
}
if errRead != nil {
if errors.Is(errRead, io.EOF) {
break
}
return "", fmt.Errorf("error reading archive line %d: %w", lineNum, errRead)
}
lineNum++
}
// loop to remove the people how are not part of the team and system
for i := len(boardMembers) - 1; i >= 0; i-- {
if _, err := a.GetUser(boardMembers[i].UserID); err != nil {
boardMembers = append(boardMembers[:i], boardMembers[i+1:]...)
}
}
a.fixBoardsandBlocks(boardsAndBlocks, opt)
var err error
boardsAndBlocks, err = model.GenerateBoardsAndBlocksIDs(boardsAndBlocks, a.logger)
if err != nil {
return "", fmt.Errorf("error generating archive block IDs: %w", err)
}
boardsAndBlocks, err = a.CreateBoardsAndBlocks(boardsAndBlocks, opt.ModifiedBy, false)
if err != nil {
return "", fmt.Errorf("error inserting archive blocks: %w", err)
}
// add users to all the new boards (if not the fake system user).
for _, board := range boardsAndBlocks.Boards {
// make sure an admin user gets added
adminMember := &model.BoardMember{
BoardID: board.ID,
UserID: opt.ModifiedBy,
SchemeAdmin: true,
}
if _, err2 := a.AddMemberToBoard(adminMember); err2 != nil {
return "", fmt.Errorf("cannot add adminMember to board: %w", err2)
}
for _, boardMember := range boardMembers {
bm := &model.BoardMember{
BoardID: board.ID,
UserID: boardMember.UserID,
Roles: boardMember.Roles,
MinimumRole: boardMember.MinimumRole,
SchemeAdmin: boardMember.SchemeAdmin,
SchemeEditor: boardMember.SchemeEditor,
SchemeCommenter: boardMember.SchemeCommenter,
SchemeViewer: boardMember.SchemeViewer,
Synthetic: boardMember.Synthetic,
}
if _, err2 := a.AddMemberToBoard(bm); err2 != nil {
return "", fmt.Errorf("cannot add member to board: %w", err2)
}
}
}
// find new board id
for _, board := range boardsAndBlocks.Boards {
return board.ID, nil
}
return "", fmt.Errorf("missing board in archive: %w", model.ErrInvalidBoardBlock)
}
// fixBoardsandBlocks allows the caller of `ImportArchive` to modify or filters boards and blocks being
// imported via callbacks.
func (a *App) fixBoardsandBlocks(boardsAndBlocks *model.BoardsAndBlocks, opt model.ImportArchiveOptions) {
if opt.BlockModifier == nil && opt.BoardModifier == nil {
return
}
modInfoCache := make(map[string]interface{})
modBoards := make([]*model.Board, 0, len(boardsAndBlocks.Boards))
modBlocks := make([]*model.Block, 0, len(boardsAndBlocks.Blocks))
for _, board := range boardsAndBlocks.Boards {
b := *board
if opt.BoardModifier != nil && !opt.BoardModifier(&b, modInfoCache) {
a.logger.Debug("skipping insert board per board modifier",
mlog.String("boardID", board.ID),
)
continue
}
modBoards = append(modBoards, &b)
}
for _, block := range boardsAndBlocks.Blocks {
b := block
if opt.BlockModifier != nil && !opt.BlockModifier(b, modInfoCache) {
a.logger.Debug("skipping insert block per block modifier",
mlog.String("blockID", block.ID),
)
continue
}
modBlocks = append(modBlocks, b)
}
boardsAndBlocks.Boards = modBoards
boardsAndBlocks.Blocks = modBlocks
}
// blockToBoard converts a `model.Block` to `model.Board`. Legacy archive formats encode boards as blocks
// and need conversion during import.
func (a *App) blockToBoard(block *model.Block, opt model.ImportArchiveOptions) (*model.Board, error) {
if block.Type != model.TypeBoard {
return nil, errBlockIsNotABoard
}
board := &model.Board{
ID: block.ID,
TeamID: opt.TeamID,
CreatedBy: block.CreatedBy,
ModifiedBy: block.ModifiedBy,
Type: model.BoardTypePrivate,
Title: block.Title,
CreateAt: block.CreateAt,
UpdateAt: block.UpdateAt,
DeleteAt: block.DeleteAt,
Properties: make(map[string]interface{}),
CardProperties: make([]map[string]interface{}, 0),
}
if icon, ok := stringValue(block.Fields, "icon"); ok {
board.Icon = icon
}
if description, ok := stringValue(block.Fields, "description"); ok {
board.Description = description
}
if showDescription, ok := boolValue(block.Fields, "showDescription"); ok {
board.ShowDescription = showDescription
}
if isTemplate, ok := boolValue(block.Fields, "isTemplate"); ok {
board.IsTemplate = isTemplate
}
if templateVer, ok := intValue(block.Fields, "templateVer"); ok {
board.TemplateVersion = templateVer
}
if properties, ok := mapValue(block.Fields, "properties"); ok {
board.Properties = properties
}
if cardProperties, ok := arrayMapsValue(block.Fields, "cardProperties"); ok {
board.CardProperties = cardProperties
}
return board, nil
}
func stringValue(m map[string]interface{}, key string) (string, bool) {
v, ok := m[key]
if !ok {
return "", false
}
s, ok := v.(string)
if !ok {
return "", false
}
return s, true
}
func boolValue(m map[string]interface{}, key string) (bool, bool) {
v, ok := m[key]
if !ok {
return false, false
}
b, ok := v.(bool)
if !ok {
return false, false
}
return b, true
}
func intValue(m map[string]interface{}, key string) (int, bool) {
v, ok := m[key]
if !ok {
return 0, false
}
i, ok := v.(int)
if !ok {
return 0, false
}
return i, true
}
func mapValue(m map[string]interface{}, key string) (map[string]interface{}, bool) {
v, ok := m[key]
if !ok {
return nil, false
}
mm, ok := v.(map[string]interface{})
if !ok {
return nil, false
}
return mm, true
}
func arrayMapsValue(m map[string]interface{}, key string) ([]map[string]interface{}, bool) {
v, ok := m[key]
if !ok {
return nil, false
}
ai, ok := v.([]interface{})
if !ok {
return nil, false
}
arr := make([]map[string]interface{}, 0, len(ai))
for _, mi := range ai {
mm, ok := mi.(map[string]interface{})
if !ok {
return nil, false
}
arr = append(arr, mm)
}
return arr, true
}
func parseVersionFile(r io.Reader) (int, error) {
file, err := io.ReadAll(r)
if err != nil {
return 0, fmt.Errorf("cannot read version.json: %w", err)
}
var header model.ArchiveHeader
if err := json.Unmarshal(file, &header); err != nil {
return 0, fmt.Errorf("cannot parse version.json: %w", err)
}
return header.Version, nil
}
func readLine(r *bufio.Reader) ([]byte, error) {
line, err := r.ReadBytes('\n')
line = bytes.TrimSpace(line)
return line, err
}

169
server/boards/app/import_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,169 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"bytes"
"testing"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
func TestApp_ImportArchive(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
board := &model.Board{
ID: "d14b9df9-1f31-4732-8a64-92bc7162cd28",
TeamID: "test-team",
Title: "Cross-Functional Project Plan",
}
block := &model.Block{
ID: "2c1873e0-1484-407d-8b2c-3c3b5a2a9f9e",
ParentID: board.ID,
Type: model.TypeView,
BoardID: board.ID,
}
babs := &model.BoardsAndBlocks{
Boards: []*model.Board{board},
Blocks: []*model.Block{block},
}
boardMember := &model.BoardMember{
BoardID: board.ID,
UserID: "user",
}
t.Run("import asana archive", func(t *testing.T) {
r := bytes.NewReader([]byte(asana))
opts := model.ImportArchiveOptions{
TeamID: "test-team",
ModifiedBy: "user",
}
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.AssignableToTypeOf(&model.BoardsAndBlocks{}), "user").Return(babs, nil)
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{boardMember}, nil)
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
th.Store.EXPECT().GetMemberForBoard(board.ID, "user").Return(boardMember, nil)
th.Store.EXPECT().GetUserCategoryBoards("user", "test-team").Return([]model.CategoryBoards{
{
Category: model.Category{
Type: "default",
Name: "Boards",
ID: "boards_category_id",
},
},
}, nil)
th.Store.EXPECT().GetUserCategoryBoards("user", "test-team")
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category_id",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user", "test-team", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().GetMembersForUser("user").Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user", utils.Anything, utils.Anything).Return(nil)
err := th.App.ImportArchive(r, opts)
require.NoError(t, err, "import archive should not fail")
})
t.Run("import board archive", func(t *testing.T) {
r := bytes.NewReader([]byte(boardArchive))
opts := model.ImportArchiveOptions{
TeamID: "test-team",
ModifiedBy: "f1tydgc697fcbp8ampr6881jea",
}
bm1 := &model.BoardMember{
BoardID: board.ID,
UserID: "f1tydgc697fcbp8ampr6881jea",
}
bm2 := &model.BoardMember{
BoardID: board.ID,
UserID: "hxxzooc3ff8cubsgtcmpn8733e",
}
bm3 := &model.BoardMember{
BoardID: board.ID,
UserID: "nto73edn5ir6ifimo5a53y1dwa",
}
user1 := &model.User{
ID: "f1tydgc697fcbp8ampr6881jea",
}
user2 := &model.User{
ID: "hxxzooc3ff8cubsgtcmpn8733e",
}
user3 := &model.User{
ID: "nto73edn5ir6ifimo5a53y1dwa",
}
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.AssignableToTypeOf(&model.BoardsAndBlocks{}), "f1tydgc697fcbp8ampr6881jea").Return(babs, nil)
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{bm1, bm2, bm3}, nil)
th.Store.EXPECT().GetUserCategoryBoards("f1tydgc697fcbp8ampr6881jea", "test-team").Return([]model.CategoryBoards{}, nil)
th.Store.EXPECT().GetUserCategoryBoards("f1tydgc697fcbp8ampr6881jea", "test-team").Return([]model.CategoryBoards{
{
Category: model.Category{
ID: "boards_category_id",
Name: "Boards",
Type: model.CategoryTypeSystem,
},
},
}, nil)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category_id",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetMembersForUser("f1tydgc697fcbp8ampr6881jea").Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("f1tydgc697fcbp8ampr6881jea", "test-team", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("f1tydgc697fcbp8ampr6881jea", utils.Anything, utils.Anything).Return(nil)
th.Store.EXPECT().GetBoard(board.ID).AnyTimes().Return(board, nil)
th.Store.EXPECT().GetMemberForBoard(board.ID, "f1tydgc697fcbp8ampr6881jea").AnyTimes().Return(bm1, nil)
th.Store.EXPECT().GetMemberForBoard(board.ID, "hxxzooc3ff8cubsgtcmpn8733e").AnyTimes().Return(bm2, nil)
th.Store.EXPECT().GetMemberForBoard(board.ID, "nto73edn5ir6ifimo5a53y1dwa").AnyTimes().Return(bm3, nil)
th.Store.EXPECT().GetUserByID("f1tydgc697fcbp8ampr6881jea").AnyTimes().Return(user1, nil)
th.Store.EXPECT().GetUserByID("hxxzooc3ff8cubsgtcmpn8733e").AnyTimes().Return(user2, nil)
th.Store.EXPECT().GetUserByID("nto73edn5ir6ifimo5a53y1dwa").AnyTimes().Return(user3, nil)
boardID, err := th.App.ImportBoardJSONL(r, opts)
require.Equal(t, board.ID, boardID, "Board ID should be same")
require.NoError(t, err, "import archive should not fail")
})
}
//nolint:lll
const asana = `{"version":1,"date":1614714686842}
{"type":"block","data":{"id":"d14b9df9-1f31-4732-8a64-92bc7162cd28","fields":{"icon":"","description":"","cardProperties":[{"id":"3bdcbaeb-bc78-4884-8531-a0323b74676a","name":"Section","type":"select","options":[{"id":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732","value":"Planning","color":"propColorGray"},{"id":"454559bb-b788-4ff6-873e-04def8491d2c","value":"Milestones","color":"propColorBrown"},{"id":"deaab476-c690-48df-828f-725b064dc476","value":"Next steps","color":"propColorOrange"},{"id":"2138305a-3157-461c-8bbe-f19ebb55846d","value":"Comms Plan","color":"propColorYellow"}]}]},"createAt":1614714686836,"updateAt":1614714686836,"deleteAt":0,"schema":1,"parentId":"","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"board","title":"Cross-Functional Project Plan"}}
{"type":"block","data":{"id":"2c1873e0-1484-407d-8b2c-3c3b5a2a9f9e","fields":{"sortOptions":[],"visiblePropertyIds":[],"visibleOptionIds":[],"hiddenOptionIds":[],"filter":{"operation":"and","filters":[]},"cardOrder":[],"columnWidths":{},"viewType":"board"},"createAt":1614714686840,"updateAt":1614714686840,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"view","title":"Board View"}}
{"type":"block","data":{"id":"520c332b-adf5-4a32-88ab-43655c8b6aa2","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732"},"contentOrder":["deb3966c-6d56-43b1-8e95-36806877ce81"]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[READ ME] - Instructions for using this template"}}
{"type":"block","data":{"id":"deb3966c-6d56-43b1-8e95-36806877ce81","fields":{},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"520c332b-adf5-4a32-88ab-43655c8b6aa2","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"text","title":"This project template is set up in List View with sections and Asana-created Custom Fields to help you track your team's work. We've provided some example content in this template to get you started, but you should add tasks, change task names, add more Custom Fields, and change any other info to make this project your own.\n\nSend feedback about this template: https://asa.na/templatesfeedback"}}
{"type":"block","data":{"id":"be791f66-a5e5-4408-82f6-cb1280f5bc45","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"d8d94ef1-5e74-40bb-8be5-fc0eb3f47732"},"contentOrder":["2688b31f-e7ff-4de1-87ae-d4b5570f8712"]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"Redesign the landing page of our website"}}
{"type":"block","data":{"id":"2688b31f-e7ff-4de1-87ae-d4b5570f8712","fields":{},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"be791f66-a5e5-4408-82f6-cb1280f5bc45","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"text","title":"Redesign the landing page to focus on the main persona."}}
{"type":"block","data":{"id":"98f74948-1700-4a3c-8cc2-8bb632499def","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"454559bb-b788-4ff6-873e-04def8491d2c"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Consider trying a new email marketing service"}}
{"type":"block","data":{"id":"142fba5d-05e6-4865-83d9-b3f54d9de96e","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"454559bb-b788-4ff6-873e-04def8491d2c"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Budget finalization"}}
{"type":"block","data":{"id":"ca6670b1-b034-4e42-8971-c659b478b9e0","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"deaab476-c690-48df-828f-725b064dc476"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Find a venue for the holiday party"}}
{"type":"block","data":{"id":"db1dd596-0999-4741-8b05-72ca8e438e31","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"deaab476-c690-48df-828f-725b064dc476"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Approve campaign copy"}}
{"type":"block","data":{"id":"16861c05-f31f-46af-8429-80a87b5aa93a","fields":{"icon":"","properties":{"3bdcbaeb-bc78-4884-8531-a0323b74676a":"2138305a-3157-461c-8bbe-f19ebb55846d"},"contentOrder":[]},"createAt":1614714686841,"updateAt":1614714686841,"deleteAt":0,"schema":1,"parentId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","rootId":"d14b9df9-1f31-4732-8a64-92bc7162cd28","modifiedBy":"","type":"card","title":"[EXAMPLE TASK] Send out updated attendee list"}}
`
//nolint:lll
const boardArchive = `{"type":"board","data":{"id":"bfoi6yy6pa3yzika53spj7pq9ee","teamId":"wsmqbtwb5jb35jb3mtp85c8a9h","channelId":"","createdBy":"nto73edn5ir6ifimo5a53y1dwa","modifiedBy":"nto73edn5ir6ifimo5a53y1dwa","type":"P","minimumRole":"","title":"Custom","description":"","icon":"","showDescription":false,"isTemplate":false,"templateVersion":0,"properties":{},"cardProperties":[{"id":"aonihehbifijmx56aqzu3cc7w1r","name":"Status","options":[],"type":"select"},{"id":"aohjkzt769rxhtcz1o9xcoce5to","name":"Person","options":[],"type":"person"}],"createAt":1672750481591,"updateAt":1672750481591,"deleteAt":0}}
{"type":"block","data":{"id":"ckpc3b1dp3pbw7bqntfryy9jbzo","parentId":"bjaqxtbyqz3bu7pgyddpgpms74a","createdBy":"nto73edn5ir6ifimo5a53y1dwa","modifiedBy":"nto73edn5ir6ifimo5a53y1dwa","schema":1,"type":"card","title":"Test","fields":{"contentOrder":[],"icon":"","isTemplate":false,"properties":{"aohjkzt769rxhtcz1o9xcoce5to":"hxxzooc3ff8cubsgtcmpn8733e"}},"createAt":1672750481612,"updateAt":1672845003530,"deleteAt":0,"boardId":"bfoi6yy6pa3yzika53spj7pq9ee"}}
{"type":"block","data":{"id":"v7tdajwpm47r3u8duedk89bhxar","parentId":"bpypang3a3errqstj1agx9kuqay","createdBy":"nto73edn5ir6ifimo5a53y1dwa","modifiedBy":"nto73edn5ir6ifimo5a53y1dwa","schema":1,"type":"view","title":"Board view","fields":{"cardOrder":["crsyw7tbr3pnjznok6ppngmmyya","c5titiemp4pgaxbs4jksgybbj4y"],"collapsedOptionIds":[],"columnCalculations":{},"columnWidths":{},"defaultTemplateId":"","filter":{"filters":[],"operation":"and"},"hiddenOptionIds":[],"kanbanCalculations":{},"sortOptions":[],"viewType":"board","visibleOptionIds":[],"visiblePropertyIds":["aohjkzt769rxhtcz1o9xcoce5to"]},"createAt":1672750481626,"updateAt":1672750481626,"deleteAt":0,"boardId":"bfoi6yy6pa3yzika53spj7pq9ee"}}
{"type":"boardMember","data":{"boardId":"bfoi6yy6pa3yzika53spj7pq9ee","userId":"f1tydgc697fcbp8ampr6881jea","roles":"","minimumRole":"","schemeAdmin":false,"schemeEditor":false,"schemeCommenter":false,"schemeViewer":true,"synthetic":false}}
{"type":"boardMember","data":{"boardId":"bfoi6yy6pa3yzika53spj7pq9ee","userId":"hxxzooc3ff8cubsgtcmpn8733e","roles":"","minimumRole":"","schemeAdmin":false,"schemeEditor":false,"schemeCommenter":false,"schemeViewer":true,"synthetic":false}}
{"type":"boardMember","data":{"boardId":"bfoi6yy6pa3yzika53spj7pq9ee","userId":"nto73edn5ir6ifimo5a53y1dwa","roles":"","minimumRole":"","schemeAdmin":true,"schemeEditor":false,"schemeCommenter":false,"schemeViewer":false,"synthetic":false}}
`

29
server/boards/app/initialize.go Обычный файл
Просмотреть файл

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"context"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
// initialize is called when the App is first created.
func (a *App) initialize(skipTemplateInit bool) {
if !skipTemplateInit {
if err := a.InitTemplates(); err != nil {
a.logger.Error(`InitializeTemplates failed`, mlog.Err(err))
}
}
}
func (a *App) Shutdown() {
if a.blockChangeNotifier != nil {
ctx, cancel := context.WithTimeout(context.Background(), blockChangeNotifierShutdownTimeout)
defer cancel()
if !a.blockChangeNotifier.Shutdown(ctx) {
a.logger.Warn("blockChangeNotifier shutdown timed out")
}
}
}

87
server/boards/app/insights.go Обычный файл
Просмотреть файл

@@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/pkg/errors"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
func (a *App) GetTeamBoardsInsights(userID string, teamID string, opts *mm_model.InsightsOpts) (*model.BoardInsightsList, error) {
// check if server is properly licensed, and user is not a guest
userPermitted, err := insightPermissionGate(a, userID, false)
if err != nil {
return nil, err
}
if !userPermitted {
return nil, errors.New("User isn't authorized to access insights.")
}
boardIDs, err := getUserBoards(userID, teamID, a)
if err != nil {
return nil, err
}
return a.store.GetTeamBoardsInsights(teamID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage, boardIDs)
}
func (a *App) GetUserBoardsInsights(userID string, teamID string, opts *mm_model.InsightsOpts) (*model.BoardInsightsList, error) {
// check if server is properly licensed, and user is not a guest
userPermitted, err := insightPermissionGate(a, userID, true)
if err != nil {
return nil, err
}
if !userPermitted {
return nil, errors.New("User isn't authorized to access insights.")
}
boardIDs, err := getUserBoards(userID, teamID, a)
if err != nil {
return nil, err
}
return a.store.GetUserBoardsInsights(teamID, userID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage, boardIDs)
}
func insightPermissionGate(a *App, userID string, isMyInsights bool) (bool, error) {
licenseError := errors.New("invalid license/authorization to use insights API")
guestError := errors.New("guests aren't authorized to use insights API")
lic := a.store.GetLicense()
user, err := a.store.GetUserByID(userID)
if err != nil {
return false, err
}
if user.IsGuest {
return false, guestError
}
if lic == nil && !isMyInsights {
a.logger.Debug("Deployment doesn't have a license")
return false, licenseError
}
if !isMyInsights && (lic.SkuShortName != mm_model.LicenseShortSkuProfessional && lic.SkuShortName != mm_model.LicenseShortSkuEnterprise) {
return false, licenseError
}
return true, nil
}
func (a *App) GetUserTimezone(userID string) (string, error) {
return a.store.GetUserTimezone(userID)
}
func getUserBoards(userID string, teamID string, a *App) ([]string, error) {
// get boards accessible by user and filter boardIDs
boards, err := a.store.GetBoardsForUserAndTeam(userID, teamID, true)
if err != nil {
return nil, errors.New("error getting boards for user")
}
boardIDs := make([]string, 0, len(boards))
for _, board := range boards {
boardIDs = append(boardIDs, board.ID)
}
return boardIDs, nil
}

93
server/boards/app/insights_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/require"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
var mockInsightsBoards = []*model.Board{
{
ID: "mock-user-workspace-id",
Title: "MockUserWorkspace",
},
}
var mockTeamInsights = []*model.BoardInsight{
{
BoardID: "board-id-1",
},
{
BoardID: "board-id-2",
},
}
var mockTeamInsightsList = &model.BoardInsightsList{
InsightsListData: mm_model.InsightsListData{HasNext: false},
Items: mockTeamInsights,
}
type insightError struct {
msg string
}
func (ie insightError) Error() string {
return ie.msg
}
func TestGetTeamAndUserBoardsInsights(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("success query", func(t *testing.T) {
fakeLicense := &mm_model.License{Features: &mm_model.Features{}, SkuShortName: mm_model.LicenseShortSkuEnterprise}
th.Store.EXPECT().GetLicense().Return(fakeLicense).AnyTimes()
fakeUser := &model.User{
ID: "user-id",
IsGuest: false,
}
th.Store.EXPECT().GetUserByID("user-id").Return(fakeUser, nil).AnyTimes()
th.Store.EXPECT().GetBoardsForUserAndTeam("user-id", "team-id", true).Return(mockInsightsBoards, nil).AnyTimes()
th.Store.EXPECT().
GetTeamBoardsInsights("team-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
Return(mockTeamInsightsList, nil)
results, err := th.App.GetTeamBoardsInsights("user-id", "team-id", &mm_model.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
require.NoError(t, err)
require.Len(t, results.Items, 2)
th.Store.EXPECT().
GetUserBoardsInsights("team-id", "user-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
Return(mockTeamInsightsList, nil)
results, err = th.App.GetUserBoardsInsights("user-id", "team-id", &mm_model.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
require.NoError(t, err)
require.Len(t, results.Items, 2)
})
t.Run("fail query", func(t *testing.T) {
fakeLicense := &mm_model.License{Features: &mm_model.Features{}, SkuShortName: mm_model.LicenseShortSkuEnterprise}
th.Store.EXPECT().GetLicense().Return(fakeLicense).AnyTimes()
fakeUser := &model.User{
ID: "user-id",
IsGuest: false,
}
th.Store.EXPECT().GetUserByID("user-id").Return(fakeUser, nil).AnyTimes()
th.Store.EXPECT().GetBoardsForUserAndTeam("user-id", "team-id", true).Return(mockInsightsBoards, nil).AnyTimes()
th.Store.EXPECT().
GetTeamBoardsInsights("team-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
Return(nil, insightError{"board-insight-error"})
_, err := th.App.GetTeamBoardsInsights("user-id", "team-id", &mm_model.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
require.Error(t, err)
require.ErrorIs(t, err, insightError{"board-insight-error"})
th.Store.EXPECT().
GetUserBoardsInsights("team-id", "user-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}).
Return(nil, insightError{"board-insight-error"})
_, err = th.App.GetUserBoardsInsights("user-id", "team-id", &mm_model.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
require.Error(t, err)
require.ErrorIs(t, err, insightError{"board-insight-error"})
})
}

99
server/boards/app/onboarding.go Обычный файл
Просмотреть файл

@@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
const (
KeyOnboardingTourStarted = "onboardingTourStarted"
KeyOnboardingTourCategory = "tourCategory"
KeyOnboardingTourStep = "onboardingTourStep"
ValueOnboardingFirstStep = "0"
ValueTourCategoryOnboarding = "onboarding"
WelcomeBoardTitle = "Welcome to Boards!"
)
var (
errUnableToFindWelcomeBoard = errors.New("unable to find welcome board in newly created blocks")
errCannotCreateBoard = errors.New("new board wasn't created")
)
func (a *App) PrepareOnboardingTour(userID string, teamID string) (string, string, error) {
// copy the welcome board into this workspace
boardID, err := a.createWelcomeBoard(userID, teamID)
if err != nil {
return "", "", err
}
// set user's tour state to initial state
userPreferencesPatch := model.UserPreferencesPatch{
UpdatedFields: map[string]string{
KeyOnboardingTourStarted: "1",
KeyOnboardingTourStep: ValueOnboardingFirstStep,
KeyOnboardingTourCategory: ValueTourCategoryOnboarding,
},
}
if _, err := a.store.PatchUserPreferences(userID, userPreferencesPatch); err != nil {
return "", "", err
}
return teamID, boardID, nil
}
func (a *App) getOnboardingBoardID() (string, error) {
boards, err := a.store.GetTemplateBoards(model.GlobalTeamID, "")
if err != nil {
return "", err
}
var onboardingBoardID string
for _, block := range boards {
if block.Title == WelcomeBoardTitle && block.TeamID == model.GlobalTeamID {
onboardingBoardID = block.ID
break
}
}
if onboardingBoardID == "" {
return "", errUnableToFindWelcomeBoard
}
return onboardingBoardID, nil
}
func (a *App) createWelcomeBoard(userID, teamID string) (string, error) {
onboardingBoardID, err := a.getOnboardingBoardID()
if err != nil {
return "", err
}
bab, _, err := a.DuplicateBoard(onboardingBoardID, userID, teamID, false)
if err != nil {
return "", err
}
if len(bab.Boards) != 1 {
return "", errCannotCreateBoard
}
// need variable for this to
// get reference for board patch
newType := model.BoardTypePrivate
patch := &model.BoardPatch{
Type: &newType,
}
if _, err := a.PatchBoard(patch, bab.Boards[0].ID, userID); err != nil {
return "", err
}
return bab.Boards[0].ID, nil
}

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

@@ -0,0 +1,197 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
const (
testTeamID = "team_id"
)
func TestPrepareOnboardingTour(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
teamID := testTeamID
userID := "user_id_1"
welcomeBoard := model.Board{
ID: "board_id_1",
Title: "Welcome to Boards!",
TeamID: "0",
IsTemplate: true,
}
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).Return(&model.BoardsAndBlocks{Boards: []*model.Board{
{
ID: "board_id_2",
Title: "Welcome to Boards!",
TeamID: "0",
IsTemplate: true,
},
}},
nil, nil)
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(2)
th.Store.EXPECT().GetMembersForBoard("board_id_2").Return([]*model.BoardMember{}, nil).Times(1)
th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).Times(2)
th.Store.EXPECT().GetBoard("board_id_2").Return(&welcomeBoard, nil).Times(1)
th.Store.EXPECT().GetUsersByTeam("0", "", false, false).Return([]*model.User{}, nil)
privateWelcomeBoard := model.Board{
ID: "board_id_1",
Title: "Welcome to Boards!",
TeamID: "0",
IsTemplate: true,
Type: model.BoardTypePrivate,
}
newType := model.BoardTypePrivate
th.Store.EXPECT().PatchBoard("board_id_2", &model.BoardPatch{Type: &newType}, "user_id_1").Return(&privateWelcomeBoard, nil)
th.Store.EXPECT().GetMembersForUser("user_id_1").Return([]*model.BoardMember{}, nil)
userPreferencesPatch := model.UserPreferencesPatch{
UpdatedFields: map[string]string{
KeyOnboardingTourStarted: "1",
KeyOnboardingTourStep: ValueOnboardingFirstStep,
KeyOnboardingTourCategory: ValueTourCategoryOnboarding,
},
}
th.Store.EXPECT().PatchUserPreferences(userID, userPreferencesPatch).Return(nil, nil)
th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{}, nil).Times(1)
// when this is called the second time, the default category is created so we need to include that in the response list
th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "boards_category_id", Name: "Boards"},
},
}, nil).Times(2)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil).Times(1)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id_1", teamID, false).Return([]*model.Board{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", []string{"board_id_2"}).Return(nil)
teamID, boardID, err := th.App.PrepareOnboardingTour(userID, teamID)
assert.NoError(t, err)
assert.Equal(t, testTeamID, teamID)
assert.NotEmpty(t, boardID)
})
}
func TestCreateWelcomeBoard(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
teamID := testTeamID
userID := "user_id_1"
welcomeBoard := model.Board{
ID: "board_id_1",
Title: "Welcome to Boards!",
TeamID: "0",
IsTemplate: true,
}
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).
Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}}, nil, nil)
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(3)
th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).AnyTimes()
th.Store.EXPECT().GetUsersByTeam("0", "", false, false).Return([]*model.User{}, nil)
privateWelcomeBoard := model.Board{
ID: "board_id_1",
Title: "Welcome to Boards!",
TeamID: "0",
IsTemplate: true,
Type: model.BoardTypePrivate,
}
newType := model.BoardTypePrivate
th.Store.EXPECT().PatchBoard("board_id_1", &model.BoardPatch{Type: &newType}, "user_id_1").Return(&privateWelcomeBoard, nil)
th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "boards_category_id", Name: "Boards"},
},
}, nil).Times(3)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", []string{"board_id_1"}).Return(nil)
boardID, err := th.App.createWelcomeBoard(userID, teamID)
assert.NoError(t, err)
assert.NotEmpty(t, boardID)
})
t.Run("template doesn't contain a board", func(t *testing.T) {
teamID := testTeamID
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{}, nil)
boardID, err := th.App.createWelcomeBoard("user_id_1", teamID)
assert.Error(t, err)
assert.Empty(t, boardID)
})
t.Run("template doesn't contain the welcome board", func(t *testing.T) {
teamID := testTeamID
welcomeBoard := model.Board{
ID: "board_id_1",
Title: "Other template",
TeamID: teamID,
IsTemplate: true,
}
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
boardID, err := th.App.createWelcomeBoard("user_id_1", "workspace_id_1")
assert.Error(t, err)
assert.Empty(t, boardID)
})
}
func TestGetOnboardingBoardID(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
welcomeBoard := model.Board{
ID: "board_id_1",
Title: "Welcome to Boards!",
TeamID: "0",
IsTemplate: true,
}
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
onboardingBoardID, err := th.App.getOnboardingBoardID()
assert.NoError(t, err)
assert.Equal(t, "board_id_1", onboardingBoardID)
})
t.Run("no blocks found", func(t *testing.T) {
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{}, nil)
onboardingBoardID, err := th.App.getOnboardingBoardID()
assert.Error(t, err)
assert.Empty(t, onboardingBoardID)
})
t.Run("onboarding board doesn't exists", func(t *testing.T) {
welcomeBoard := model.Board{
ID: "board_id_1",
Title: "Other template",
TeamID: "0",
IsTemplate: true,
}
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
onboardingBoardID, err := th.App.getOnboardingBoardID()
assert.Error(t, err)
assert.Empty(t, onboardingBoardID)
})
}

12
server/boards/app/permissions.go Обычный файл
Просмотреть файл

@@ -0,0 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
mm_model "github.com/mattermost/mattermost-server/v6/model"
)
func (a *App) HasPermissionToBoard(userID, boardID string, permission *mm_model.Permission) bool {
return a.permissions.HasPermissionToBoard(userID, boardID, permission)
}

45
server/boards/app/server_metadata.go Обычный файл
Просмотреть файл

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"runtime"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
type ServerMetadata struct {
Version string `json:"version"`
BuildNumber string `json:"build_number"`
BuildDate string `json:"build_date"`
Commit string `json:"commit"`
Edition string `json:"edition"`
DBType string `json:"db_type"`
DBVersion string `json:"db_version"`
OSType string `json:"os_type"`
OSArch string `json:"os_arch"`
SKU string `json:"sku"`
}
func (a *App) GetServerMetadata() *ServerMetadata {
var dbType string
var dbVersion string
if a != nil && a.store != nil {
dbType = a.store.DBType()
dbVersion = a.store.DBVersion()
}
return &ServerMetadata{
Version: model.CurrentVersion,
BuildNumber: model.BuildNumber,
BuildDate: model.BuildDate,
Commit: model.BuildHash,
Edition: model.Edition,
DBType: dbType,
DBVersion: dbVersion,
OSType: runtime.GOOS,
OSArch: runtime.GOARCH,
SKU: "personal_server",
}
}

40
server/boards/app/server_metadata_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"reflect"
"runtime"
"testing"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
func TestGetServerMetadata(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.Store.EXPECT().DBType().Return("TEST_DB_TYPE")
th.Store.EXPECT().DBVersion().Return("TEST_DB_VERSION")
t.Run("Get Server Metadata", func(t *testing.T) {
got := th.App.GetServerMetadata()
want := &ServerMetadata{
Version: model.CurrentVersion,
BuildNumber: model.BuildNumber,
BuildDate: model.BuildDate,
Commit: model.BuildHash,
Edition: model.Edition,
DBType: "TEST_DB_TYPE",
DBVersion: "TEST_DB_VERSION",
OSType: runtime.GOOS,
OSArch: runtime.GOARCH,
SKU: "personal_server",
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got: %q, want: %q", got, want)
}
})
}

20
server/boards/app/sharing.go Обычный файл
Просмотреть файл

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
func (a *App) GetSharing(boardID string) (*model.Sharing, error) {
sharing, err := a.store.GetSharing(boardID)
if err != nil {
return nil, err
}
return sharing, nil
}
func (a *App) UpsertSharing(sharing model.Sharing) error {
return a.store.UpsertSharing(sharing)
}

88
server/boards/app/sharing_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"database/sql"
"testing"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
)
func TestGetSharing(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("should get a sharing successfully", func(t *testing.T) {
want := &model.Sharing{
ID: utils.NewID(utils.IDTypeBlock),
Enabled: true,
Token: "token",
ModifiedBy: "otherid",
UpdateAt: utils.GetMillis(),
}
th.Store.EXPECT().GetSharing("test-id").Return(want, nil)
result, err := th.App.GetSharing("test-id")
require.NoError(t, err)
require.Equal(t, result, want)
require.NotNil(t, th.App)
})
t.Run("should fail to get a sharing", func(t *testing.T) {
th.Store.EXPECT().GetSharing("test-id").Return(
nil,
errors.New("sharing not found"),
)
result, err := th.App.GetSharing("test-id")
require.Nil(t, result)
require.Error(t, err)
require.Equal(t, "sharing not found", err.Error())
})
t.Run("should return a not found error", func(t *testing.T) {
th.Store.EXPECT().GetSharing("test-id").Return(
nil,
sql.ErrNoRows,
)
result, err := th.App.GetSharing("test-id")
require.Error(t, err)
require.True(t, model.IsErrNotFound(err))
require.Nil(t, result)
})
}
func TestUpsertSharing(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
sharing := model.Sharing{
ID: utils.NewID(utils.IDTypeBlock),
Enabled: true,
Token: "token",
ModifiedBy: "otherid",
UpdateAt: utils.GetMillis(),
}
t.Run("should success to upsert sharing", func(t *testing.T) {
th.Store.EXPECT().UpsertSharing(sharing).Return(nil)
err := th.App.UpsertSharing(sharing)
require.NoError(t, err)
})
t.Run("should fail to upsert a sharing", func(t *testing.T) {
th.Store.EXPECT().UpsertSharing(sharing).Return(errors.New("sharing not found"))
err := th.App.UpsertSharing(sharing)
require.Error(t, err)
require.Equal(t, "sharing not found", err.Error())
})
}

55
server/boards/app/subscriptions.go Обычный файл
Просмотреть файл

@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
func (a *App) CreateSubscription(sub *model.Subscription) (*model.Subscription, error) {
sub, err := a.store.CreateSubscription(sub)
if err != nil {
return nil, err
}
a.notifySubscriptionChanged(sub)
return sub, nil
}
func (a *App) DeleteSubscription(blockID string, subscriberID string) (*model.Subscription, error) {
sub, err := a.store.GetSubscription(blockID, subscriberID)
if err != nil {
return nil, err
}
if err := a.store.DeleteSubscription(blockID, subscriberID); err != nil {
return nil, err
}
sub.DeleteAt = utils.GetMillis()
a.notifySubscriptionChanged(sub)
return sub, nil
}
func (a *App) GetSubscriptions(subscriberID string) ([]*model.Subscription, error) {
return a.store.GetSubscriptions(subscriberID)
}
func (a *App) notifySubscriptionChanged(subscription *model.Subscription) {
if a.notifications == nil {
return
}
board, err := a.getBoardForBlock(subscription.BlockID)
if err != nil {
a.logger.Error("Error notifying subscription change",
mlog.String("subscriber_id", subscription.SubscriberID),
mlog.String("block_id", subscription.BlockID),
mlog.Err(err),
)
}
a.wsAdapter.BroadcastSubscriptionChange(board.TeamID, subscription)
}

68
server/boards/app/teams.go Обычный файл
Просмотреть файл

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
func (a *App) GetRootTeam() (*model.Team, error) {
teamID := "0"
team, _ := a.store.GetTeam(teamID)
if team == nil {
team = &model.Team{
ID: teamID,
SignupToken: utils.NewID(utils.IDTypeToken),
}
err := a.store.UpsertTeamSignupToken(*team)
if err != nil {
a.logger.Error("Unable to initialize team", mlog.Err(err))
return nil, err
}
team, err = a.store.GetTeam(teamID)
if err != nil {
a.logger.Error("Unable to get initialized team", mlog.Err(err))
return nil, err
}
a.logger.Info("initialized team")
}
return team, nil
}
func (a *App) GetTeam(id string) (*model.Team, error) {
team, err := a.store.GetTeam(id)
if model.IsErrNotFound(err) {
return nil, nil
}
if err != nil {
return nil, err
}
return team, nil
}
func (a *App) GetTeamsForUser(userID string) ([]*model.Team, error) {
return a.store.GetTeamsForUser(userID)
}
func (a *App) DoesUserHaveTeamAccess(userID string, teamID string) bool {
return a.auth.DoesUserHaveTeamAccess(userID, teamID)
}
func (a *App) UpsertTeamSettings(team model.Team) error {
return a.store.UpsertTeamSettings(team)
}
func (a *App) UpsertTeamSignupToken(team model.Team) error {
return a.store.UpsertTeamSignupToken(team)
}
func (a *App) GetTeamCount() (int64, error) {
return a.store.GetTeamCount()
}

158
server/boards/app/teams_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,158 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"database/sql"
"errors"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
var errInvalidTeam = errors.New("invalid team id")
var mockTeam = &model.Team{
ID: "mock-team-id",
Title: "MockTeam",
}
var errUpsertSignupToken = errors.New("upsert error")
func TestGetRootTeam(t *testing.T) {
var newRootTeam = &model.Team{
ID: "0",
Title: "NewRootTeam",
}
testCases := []struct {
title string
teamToReturnBeforeUpsert *model.Team
teamToReturnAfterUpsert *model.Team
isError bool
}{
{
"Success, Return new root team, when root team returned by mockstore is nil",
nil,
newRootTeam,
false,
},
{
"Success, Return existing root team, when root team returned by mockstore is notnil",
newRootTeam,
nil,
false,
},
{
"Fail, Return nil, when root team returned by mockstore is nil, and upsert new root team fails",
nil,
nil,
true,
},
}
for _, tc := range testCases {
t.Run(tc.title, func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.Store.EXPECT().GetTeam("0").Return(tc.teamToReturnBeforeUpsert, nil)
if tc.teamToReturnBeforeUpsert == nil {
th.Store.EXPECT().UpsertTeamSignupToken(gomock.Any()).DoAndReturn(
func(arg0 model.Team) error {
if tc.isError {
return errUpsertSignupToken
}
th.Store.EXPECT().GetTeam("0").Return(tc.teamToReturnAfterUpsert, nil)
return nil
})
}
rootTeam, err := th.App.GetRootTeam()
if tc.isError {
require.Error(t, err)
} else {
assert.NotNil(t, rootTeam.ID)
assert.NotNil(t, rootTeam.SignupToken)
assert.Equal(t, "", rootTeam.ModifiedBy)
assert.Equal(t, int64(0), rootTeam.UpdateAt)
assert.Equal(t, "NewRootTeam", rootTeam.Title)
require.NoError(t, err)
require.NotNil(t, rootTeam)
}
})
}
}
func TestGetTeam(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
testCases := []struct {
title string
teamID string
isError bool
}{
{
"Success, Return new root team, when team returned by mockstore is not nil",
"mock-team-id",
false,
},
{
"Success, Return nil, when get team returns an sql error",
"team-not-available-id",
false,
},
{
"Fail, Return nil, when get team by mockstore returns an error",
"invalid-team-id",
true,
},
}
th.Store.EXPECT().GetTeam("mock-team-id").Return(mockTeam, nil)
th.Store.EXPECT().GetTeam("invalid-team-id").Return(nil, errInvalidTeam)
th.Store.EXPECT().GetTeam("team-not-available-id").Return(nil, sql.ErrNoRows)
for _, tc := range testCases {
t.Run(tc.title, func(t *testing.T) {
t.Log(tc.title)
team, err := th.App.GetTeam(tc.teamID)
if tc.isError {
require.Error(t, err)
} else if tc.teamID != "team-not-available-id" {
assert.NotNil(t, team.ID)
assert.NotNil(t, team.SignupToken)
assert.Equal(t, "mock-team-id", team.ID)
assert.Equal(t, "", team.ModifiedBy)
assert.Equal(t, int64(0), team.UpdateAt)
assert.Equal(t, "MockTeam", team.Title)
require.NoError(t, err)
require.NotNil(t, team)
}
})
}
}
func TestTeamOperations(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.Store.EXPECT().UpsertTeamSettings(*mockTeam).Return(nil)
th.Store.EXPECT().UpsertTeamSignupToken(*mockTeam).Return(nil)
th.Store.EXPECT().GetTeamCount().Return(int64(10), nil)
errUpsertTeamSettings := th.App.UpsertTeamSettings(*mockTeam)
assert.NoError(t, errUpsertTeamSettings)
errUpsertTeamSignupToken := th.App.UpsertTeamSignupToken(*mockTeam)
assert.NoError(t, errUpsertTeamSignupToken)
count, errGetTeamCount := th.App.GetTeamCount()
assert.NoError(t, errGetTeamCount)
assert.Equal(t, int64(10), count)
}

Двоичные данные
server/boards/app/templates.boardarchive Обычный файл

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

115
server/boards/app/templates.go Обычный файл
Просмотреть файл

@@ -0,0 +1,115 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"bytes"
"fmt"
"strings"
"github.com/mattermost/mattermost-server/v6/server/boards/assets"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
const (
defaultTemplateVersion = 6 // bump this number to force default templates to be re-imported
)
func (a *App) InitTemplates() error {
_, err := a.initializeTemplates()
return err
}
// initializeTemplates imports default templates if the boards table is empty.
func (a *App) initializeTemplates() (bool, error) {
boards, err := a.store.GetTemplateBoards(model.GlobalTeamID, "")
if err != nil {
return false, fmt.Errorf("cannot initialize templates: %w", err)
}
a.logger.Debug("Fetched template boards", mlog.Int("count", len(boards)))
isNeeded, reason := a.isInitializationNeeded(boards)
if !isNeeded {
a.logger.Debug("Template import not needed, skipping")
return false, nil
}
a.logger.Debug("Importing new default templates",
mlog.String("reason", reason),
mlog.Int("size", len(assets.DefaultTemplatesArchive)),
)
// Remove in case of newer Templates
if err = a.store.RemoveDefaultTemplates(boards); err != nil {
return false, fmt.Errorf("cannot remove old template boards: %w", err)
}
r := bytes.NewReader(assets.DefaultTemplatesArchive)
opt := model.ImportArchiveOptions{
TeamID: model.GlobalTeamID,
ModifiedBy: model.SystemUserID,
BlockModifier: fixTemplateBlock,
BoardModifier: fixTemplateBoard,
}
if err = a.ImportArchive(r, opt); err != nil {
return false, fmt.Errorf("cannot initialize global templates for team %s: %w", model.GlobalTeamID, err)
}
return true, nil
}
// isInitializationNeeded returns true if the blocks table contains no default templates,
// or contains at least one default template with an old version number.
func (a *App) isInitializationNeeded(boards []*model.Board) (bool, string) {
if len(boards) == 0 {
return true, "no default templates found"
}
// look for any built-in template boards with the wrong version number (or no version #).
for _, board := range boards {
// if not built-in board...skip
if board.CreatedBy != model.SystemUserID {
continue
}
if board.TemplateVersion < defaultTemplateVersion {
return true, "template_version too old"
}
}
return false, ""
}
// fixTemplateBlock fixes a block to be inserted as part of a template.
func fixTemplateBlock(block *model.Block, cache map[string]interface{}) bool {
// cache contains ids of skipped boards. Ensure their children are skipped as well.
if _, ok := cache[block.BoardID]; ok {
cache[block.ID] = struct{}{}
return false
}
if _, ok := cache[block.ParentID]; ok {
cache[block.ID] = struct{}{}
return false
}
return true
}
// fixTemplateBoard fixes a board to be inserted as part of a template.
func fixTemplateBoard(board *model.Board, cache map[string]interface{}) bool {
// filter out template blocks; we only want the non-template
// blocks which we will turn into default template blocks.
if board.IsTemplate {
cache[board.ID] = struct{}{}
return false
}
// remove '(NEW)' from title & force template flag
board.Title = strings.ReplaceAll(board.Title, "(NEW)", "")
board.IsTemplate = true
board.TemplateVersion = defaultTemplateVersion
board.Type = model.BoardTypeOpen
return true
}

74
server/boards/app/templates_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
"github.com/mattermost/mattermost-server/v6/server/boards/utils"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
)
func TestApp_initializeTemplates(t *testing.T) {
board := &model.Board{
ID: utils.NewID(utils.IDTypeBoard),
TeamID: model.GlobalTeamID,
Type: model.BoardTypeOpen,
Title: "test board",
IsTemplate: true,
TemplateVersion: defaultTemplateVersion,
}
block := &model.Block{
ID: utils.NewID(utils.IDTypeBlock),
ParentID: board.ID,
BoardID: board.ID,
Type: model.TypeText,
Title: "test text",
}
boardsAndBlocks := &model.BoardsAndBlocks{
Boards: []*model.Board{board},
Blocks: []*model.Block{block},
}
boardMember := &model.BoardMember{
BoardID: board.ID,
UserID: "test-user",
}
t.Run("Needs template init", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.Store.EXPECT().GetTemplateBoards(model.GlobalTeamID, "").Return([]*model.Board{}, nil)
th.Store.EXPECT().RemoveDefaultTemplates([]*model.Board{}).Return(nil)
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.Any(), gomock.Any()).AnyTimes().Return(boardsAndBlocks, nil)
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetBoard(board.ID).AnyTimes().Return(board, nil)
th.Store.EXPECT().GetMemberForBoard(gomock.Any(), gomock.Any()).AnyTimes().Return(boardMember, nil)
th.FilesBackend.On("WriteFile", mock.Anything, mock.Anything).Return(int64(1), nil)
done, err := th.App.initializeTemplates()
require.NoError(t, err, "initializeTemplates should not error")
require.True(t, done, "initialization was needed")
})
t.Run("Skip template init", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.Store.EXPECT().GetTemplateBoards(model.GlobalTeamID, "").Return([]*model.Board{board}, nil)
done, err := th.App.initializeTemplates()
require.NoError(t, err, "initializeTemplates should not error")
require.False(t, done, "initialization was not needed")
})
}

85
server/boards/app/user.go Обычный файл
Просмотреть файл

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
func (a *App) GetTeamUsers(teamID string, asGuestID string) ([]*model.User, error) {
return a.store.GetUsersByTeam(teamID, asGuestID, a.config.ShowEmailAddress, a.config.ShowFullName)
}
func (a *App) SearchTeamUsers(teamID string, searchQuery string, asGuestID string, excludeBots bool) ([]*model.User, error) {
users, err := a.store.SearchUsersByTeam(teamID, searchQuery, asGuestID, excludeBots, a.config.ShowEmailAddress, a.config.ShowFullName)
if err != nil {
return nil, err
}
for i, u := range users {
if a.permissions.HasPermissionToTeam(u.ID, teamID, model.PermissionManageTeam) {
users[i].Permissions = append(users[i].Permissions, model.PermissionManageTeam.Id)
}
if a.permissions.HasPermissionTo(u.ID, model.PermissionManageSystem) {
users[i].Permissions = append(users[i].Permissions, model.PermissionManageSystem.Id)
}
}
return users, nil
}
func (a *App) UpdateUserConfig(userID string, patch model.UserPreferencesPatch) ([]mm_model.Preference, error) {
updatedPreferences, err := a.store.PatchUserPreferences(userID, patch)
if err != nil {
return nil, err
}
return updatedPreferences, nil
}
func (a *App) GetUserPreferences(userID string) ([]mm_model.Preference, error) {
return a.store.GetUserPreferences(userID)
}
func (a *App) UserIsGuest(userID string) (bool, error) {
user, err := a.store.GetUserByID(userID)
if err != nil {
return false, err
}
return user.IsGuest, nil
}
func (a *App) CanSeeUser(seerUser string, seenUser string) (bool, error) {
isGuest, err := a.UserIsGuest(seerUser)
if err != nil {
return false, err
}
if isGuest {
hasSharedChannels, err := a.store.CanSeeUser(seerUser, seenUser)
if err != nil {
return false, err
}
return hasSharedChannels, nil
}
return true, nil
}
func (a *App) SearchUserChannels(teamID string, userID string, query string) ([]*mm_model.Channel, error) {
channels, err := a.store.SearchUserChannels(teamID, userID, query)
if err != nil {
return nil, err
}
var writeableChannels []*mm_model.Channel
for _, channel := range channels {
if a.permissions.HasPermissionToChannel(userID, channel.Id, model.PermissionCreatePost) {
writeableChannels = append(writeableChannels, channel)
}
}
return writeableChannels, nil
}
func (a *App) GetChannel(teamID string, channelID string) (*mm_model.Channel, error) {
return a.store.GetChannel(teamID, channelID)
}

89
server/boards/app/user_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/assert"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/boards/model"
)
func TestSearchUsers(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.App.config.ShowEmailAddress = false
th.App.config.ShowFullName = false
teamID := "team-id-1"
userID := "user-id-1"
t.Run("return empty users", func(t *testing.T) {
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{}, nil)
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
assert.NoError(t, err)
assert.Equal(t, 0, len(users))
})
t.Run("return user", func(t *testing.T) {
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil)
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(false).Times(1)
th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(false).Times(1)
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
assert.NoError(t, err)
assert.Equal(t, 1, len(users))
assert.Equal(t, 0, len(users[0].Permissions))
})
t.Run("return team admin", func(t *testing.T) {
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil)
th.App.config.ShowEmailAddress = false
th.App.config.ShowFullName = false
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(false).Times(1)
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
assert.NoError(t, err)
assert.Equal(t, 1, len(users))
assert.Equal(t, users[0].Permissions[0], model.PermissionManageTeam.Id)
})
t.Run("return system admin", func(t *testing.T) {
th.Store.EXPECT().SearchUsersByTeam(teamID, "", "", true, false, false).Return([]*model.User{{ID: userID}}, nil)
th.App.config.ShowEmailAddress = false
th.App.config.ShowFullName = false
th.API.EXPECT().HasPermissionToTeam(userID, teamID, model.PermissionManageTeam).Return(true).Times(1)
th.API.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(true).Times(1)
users, err := th.App.SearchTeamUsers(teamID, "", "", true)
assert.NoError(t, err)
assert.Equal(t, 1, len(users))
assert.Equal(t, users[0].Permissions[0], model.PermissionManageTeam.Id)
assert.Equal(t, users[0].Permissions[1], model.PermissionManageSystem.Id)
})
t.Run("test user channels", func(t *testing.T) {
channelID := "Channel1"
th.Store.EXPECT().SearchUserChannels(teamID, userID, "").Return([]*mm_model.Channel{{Id: channelID}}, nil)
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(true).Times(1)
channels, err := th.App.SearchUserChannels(teamID, userID, "")
assert.NoError(t, err)
assert.Equal(t, 1, len(channels))
})
t.Run("test user channels- no permissions", func(t *testing.T) {
channelID := "Channel1"
th.Store.EXPECT().SearchUserChannels(teamID, userID, "").Return([]*mm_model.Channel{{Id: channelID}}, nil)
th.API.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionCreatePost).Return(false).Times(1)
channels, err := th.App.SearchUserChannels(teamID, userID, "")
assert.NoError(t, err)
assert.Equal(t, 0, len(channels))
})
}