Initial team service implementation (#18279)

* initial service implementation

* reflect review comments
Этот коммит содержится в:
Ibrahim Serdar Acikgoz
2021-10-13 15:20:40 +03:00
коммит произвёл GitHub
родитель d074c3d20d
Коммит be203d9bf3
17 изменённых файлов: 907 добавлений и 405 удалений

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

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package teams
import "errors"
var (
AcceptedDomainError = errors.New("the user cannot be added as the domain associated with the account is not permitted")
MemberCountError = errors.New("unable to count the team members")
MaxMemberCountError = errors.New("reached to the maximum number of allowed accounts")
)
type DomainError struct {
Domain string
}
func (DomainError) Error() string {
return "restricting team to the domain, it is not allowed by the system config"
}

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

@@ -0,0 +1,143 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package teams
import (
"bytes"
"io/ioutil"
"os"
"path/filepath"
"testing"
"github.com/mattermost/mattermost-server/v6/app/request"
"github.com/mattermost/mattermost-server/v6/config"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/store"
)
type TestHelper struct {
service *TeamService
configStore *config.Store
dbStore store.Store
workspace string
Context *request.Context
LogBuffer *bytes.Buffer
}
type mockWebHub struct{}
func (mockWebHub) Publish(*model.WebSocketEvent) {}
func Setup(tb testing.TB) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
dbStore := mainHelper.GetStore()
dbStore.DropAllTables()
dbStore.MarkSystemRanUnitTests()
mainHelper.PreloadMigrations()
return setupTestHelper(dbStore, false, tb)
}
func setupTestHelper(s store.Store, includeCacheLayer bool, tb testing.TB) *TestHelper {
tempWorkspace, err := ioutil.TempDir("", "teamservicetest")
if err != nil {
panic(err)
}
configStore := config.NewTestMemoryStore()
config := configStore.Get()
*config.PluginSettings.Directory = filepath.Join(tempWorkspace, "plugins")
*config.PluginSettings.ClientDirectory = filepath.Join(tempWorkspace, "webapp")
*config.PluginSettings.AutomaticPrepackagedPlugins = false
*config.LogSettings.EnableSentry = false // disable error reporting during tests
*config.AnnouncementSettings.AdminNoticesEnabled = false
*config.AnnouncementSettings.UserNoticesEnabled = false
*config.TeamSettings.MaxUsersPerTeam = 50
*config.RateLimitSettings.Enable = false
*config.TeamSettings.EnableOpenServer = true
// Disable strict password requirements for test
*config.PasswordSettings.MinimumLength = 5
*config.PasswordSettings.Lowercase = false
*config.PasswordSettings.Uppercase = false
*config.PasswordSettings.Symbol = false
*config.PasswordSettings.Number = false
configStore.Set(config)
buffer := &bytes.Buffer{}
return &TestHelper{
service: &TeamService{
store: s.Team(),
channelStore: s.Channel(),
groupStore: s.Group(),
config: configStore.Get,
license: func() *model.License {
return model.NewTestLicense()
},
wh: &mockWebHub{},
},
Context: &request.Context{},
configStore: configStore,
dbStore: s,
LogBuffer: buffer,
workspace: tempWorkspace,
}
}
func (th *TestHelper) TearDown() {
th.configStore.Close()
th.dbStore.Close()
if th.workspace != "" {
os.RemoveAll(th.workspace)
}
}
func (th *TestHelper) UpdateConfig(f func(*model.Config)) {
if th.configStore.IsReadOnly() {
return
}
old := th.configStore.Get()
updated := old.Clone()
f(updated)
if _, _, err := th.configStore.Set(updated); err != nil {
panic(err)
}
}
func (th *TestHelper) CreateUser(u *model.User) *model.User {
u.EmailVerified = true
user, err := th.dbStore.User().Save(u)
if err != nil {
panic(err)
}
return user
}
func (th *TestHelper) DeleteUser(u *model.User) {
err := th.dbStore.User().PermanentDelete(u.Id)
if err != nil {
panic(err)
}
}
func (th *TestHelper) DeleteTeam(t *model.Team) {
if err := th.dbStore.Channel().PermanentDeleteByTeam(t.Id); err != nil {
panic(err)
}
if err := th.dbStore.Team().RemoveAllMembersByTeam(t.Id); err != nil {
panic(err)
}
if err := th.dbStore.Team().PermanentDelete(t.Id); err != nil {
panic(err)
}
}

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

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package teams
import (
"flag"
"testing"
"github.com/mattermost/mattermost-server/v6/testlib"
)
var mainHelper *testlib.MainHelper
var replicaFlag bool
func TestMain(m *testing.M) {
if f := flag.Lookup("mysql-replica"); f == nil {
flag.BoolVar(&replicaFlag, "mysql-replica", false, "")
flag.Parse()
}
var options = testlib.HelperOptions{
EnableStore: true,
EnableResources: true,
WithReadReplica: replicaFlag,
}
mainHelper = testlib.NewMainHelperWithOptions(&options)
defer mainHelper.Close()
mainHelper.Main(m)
}

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

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package teams
import (
"errors"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/store"
)
type TeamService struct {
store store.TeamStore
groupStore store.GroupStore
channelStore store.ChannelStore // TODO: replace this with ChannelService in the future
users Users
wh WebHub
config func() *model.Config
license func() *model.License
}
// ServiceConfig is used to initialize the TeamService.
type ServiceConfig struct {
// Mandatory fields
TeamStore store.TeamStore
GroupStore store.GroupStore
ChannelStore store.ChannelStore
Users Users
WebHub WebHub
ConfigFn func() *model.Config
LicenseFn func() *model.License
}
// Users is a subset of UserService interface
type Users interface {
GetUser(userID string) (*model.User, error)
}
// WebHub is used to publish events, the name should be given appropriately
// while developing the websocket or clustering service
type WebHub interface {
Publish(message *model.WebSocketEvent)
}
func New(c ServiceConfig) (*TeamService, error) {
if err := c.validate(); err != nil {
return nil, err
}
return &TeamService{
store: c.TeamStore,
groupStore: c.GroupStore,
channelStore: c.ChannelStore,
users: c.Users,
config: c.ConfigFn,
license: c.LicenseFn,
wh: c.WebHub,
}, nil
}
func (c *ServiceConfig) validate() error {
if c.ConfigFn == nil || c.TeamStore == nil || c.LicenseFn == nil || c.Users == nil || c.ChannelStore == nil || c.GroupStore == nil || c.WebHub == nil {
return errors.New("required parameters are not provided")
}
return nil
}

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

@@ -0,0 +1,198 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package teams
import (
"context"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/i18n"
)
func (ts *TeamService) CreateTeam(team *model.Team) (*model.Team, error) {
team.InviteId = ""
rteam, err := ts.store.Save(team)
if err != nil {
return nil, err
}
if _, err := ts.createDefaultChannels(rteam.Id); err != nil {
return nil, err
}
return rteam, nil
}
func (ts *TeamService) GetTeam(teamID string) (*model.Team, error) {
team, err := ts.store.Get(teamID)
if err != nil {
return nil, err
}
return team, nil
}
// CreateDefaultChannels creates channels in the given team for each channel returned by (*App).DefaultChannelNames.
//
func (ts *TeamService) createDefaultChannels(teamID string) ([]*model.Channel, error) {
displayNames := map[string]string{
"town-square": i18n.T("api.channel.create_default_channels.town_square"),
"off-topic": i18n.T("api.channel.create_default_channels.off_topic"),
}
channels := []*model.Channel{}
defaultChannelNames := ts.DefaultChannelNames()
for _, name := range defaultChannelNames {
displayName := i18n.TDefault(displayNames[name], name)
channel := &model.Channel{DisplayName: displayName, Name: name, Type: model.ChannelTypeOpen, TeamId: teamID}
// We should use the channel service here (coming soon). Ideally, we should just emit an event
// and let the subscribers do the job, in this case it would be the channels service.
// Currently we are adding services to the server and because of that we are using
// the channel store here. This should be replaced in the future.
if _, err := ts.channelStore.Save(channel, *ts.config().TeamSettings.MaxChannelsPerTeam); err != nil {
return nil, err
}
channels = append(channels, channel)
}
return channels, nil
}
type UpdateOptions struct {
Sanitized bool
Imported bool
}
func (ts *TeamService) UpdateTeam(team *model.Team, opts UpdateOptions) (*model.Team, error) {
oldTeam := team
var err error
if !opts.Imported {
oldTeam, err = ts.store.Get(team.Id)
if err != nil {
return nil, err
}
if err = ts.checkValidDomains(team); err != nil {
return nil, err
}
}
if opts.Sanitized {
oldTeam.DisplayName = team.DisplayName
oldTeam.Description = team.Description
oldTeam.AllowOpenInvite = team.AllowOpenInvite
oldTeam.CompanyName = team.CompanyName
oldTeam.AllowedDomains = team.AllowedDomains
oldTeam.LastTeamIconUpdate = team.LastTeamIconUpdate
oldTeam.GroupConstrained = team.GroupConstrained
}
oldTeam, err = ts.store.Update(oldTeam)
if err != nil {
return team, err
}
return oldTeam, nil
}
func (ts *TeamService) PatchTeam(teamID string, patch *model.TeamPatch) (*model.Team, error) {
team, err := ts.store.Get(teamID)
if err != nil {
return nil, err
}
team.Patch(patch)
if patch.AllowOpenInvite != nil && !*patch.AllowOpenInvite {
team.InviteId = model.NewId()
}
if err = ts.checkValidDomains(team); err != nil {
return nil, err
}
team, err = ts.store.Update(team)
if err != nil {
return team, err
}
return team, nil
}
// JoinUserToTeam adds a user to the team and it returns three values:
// 1. a pointer to the team member, if successful
// 2. a boolean: true if the user has a non-deleted team member for that team already, otherwise false.
// 3. a pointer to an AppError if something went wrong.
func (ts *TeamService) JoinUserToTeam(team *model.Team, user *model.User) (*model.TeamMember, bool, error) {
if !ts.IsTeamEmailAllowed(user, team) {
return nil, false, AcceptedDomainError
}
tm := &model.TeamMember{
TeamId: team.Id,
UserId: user.Id,
SchemeGuest: user.IsGuest(),
SchemeUser: !user.IsGuest(),
}
if !user.IsGuest() {
userShouldBeAdmin, err := ts.userIsInAdminRoleGroup(user.Id, team.Id, model.GroupSyncableTypeTeam)
if err != nil {
return nil, false, err
}
tm.SchemeAdmin = userShouldBeAdmin
}
if team.Email == user.Email {
tm.SchemeAdmin = true
}
rtm, err := ts.store.GetMember(context.Background(), team.Id, user.Id)
if err != nil {
// Membership appears to be missing. Lets try to add.
tmr, nErr := ts.store.SaveMember(tm, *ts.config().TeamSettings.MaxUsersPerTeam)
if nErr != nil {
return nil, false, nErr
}
return tmr, false, nil
}
// Membership already exists. Check if deleted and update, otherwise do nothing
// Do nothing if already added
if rtm.DeleteAt == 0 {
return rtm, true, nil
}
membersCount, err := ts.store.GetActiveMemberCount(tm.TeamId, nil)
if err != nil {
return nil, false, MemberCountError
}
if membersCount >= int64(*ts.config().TeamSettings.MaxUsersPerTeam) {
return nil, false, MaxMemberCountError
}
member, nErr := ts.store.UpdateMember(tm)
if nErr != nil {
return nil, false, nErr
}
return member, false, nil
}
// RemoveTeamMember removes the team member from the team. This method sends
// the websocket message before actually removing so the user being removed gets it.
func (ts *TeamService) RemoveTeamMember(teamMember *model.TeamMember) error {
message := model.NewWebSocketEvent(model.WebsocketEventLeaveTeam, teamMember.TeamId, "", "", nil)
message.Add("user_id", teamMember.UserId)
message.Add("team_id", teamMember.TeamId)
ts.wh.Publish(message)
teamMember.Roles = ""
teamMember.DeleteAt = model.GetMillis()
if _, nErr := ts.store.UpdateMember(teamMember); nErr != nil {
return nErr
}
return nil
}

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

@@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package teams
import (
"strings"
"testing"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/require"
)
func TestCreateTeam(t *testing.T) {
th := Setup(t)
defer th.TearDown()
id := model.NewId()
team := &model.Team{
DisplayName: "dn_" + id,
Name: "name" + id,
Email: "success+" + id + "@simulator.amazonses.com",
Type: model.TeamOpen,
}
_, err := th.service.CreateTeam(team)
require.NoError(t, err, "Should create a new team")
_, err = th.service.CreateTeam(team)
require.Error(t, err, "Should not create a new team - team already exist")
}
func TestJoinUserToTeam(t *testing.T) {
th := Setup(t)
defer th.TearDown()
id := model.NewId()
team := &model.Team{
DisplayName: "dn_" + id,
Name: "name" + id,
Email: "success+" + id + "@simulator.amazonses.com",
Type: model.TeamOpen,
}
_, err := th.service.CreateTeam(team)
require.NoError(t, err, "Should create a new team")
maxUsersPerTeam := th.service.config().TeamSettings.MaxUsersPerTeam
defer func() {
th.UpdateConfig(func(cfg *model.Config) { cfg.TeamSettings.MaxUsersPerTeam = maxUsersPerTeam })
th.DeleteTeam(team)
}()
one := 1
th.UpdateConfig(func(cfg *model.Config) { cfg.TeamSettings.MaxUsersPerTeam = &one })
t.Run("new join", func(t *testing.T) {
user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""}
ruser := th.CreateUser(&user)
defer th.DeleteUser(&user)
_, alreadyAdded, err := th.service.JoinUserToTeam(team, ruser)
require.False(t, alreadyAdded, "Should return already added equal to false")
require.NoError(t, err)
})
t.Run("join when you are a member", func(t *testing.T) {
user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""}
ruser := th.CreateUser(&user)
defer th.DeleteUser(&user)
_, _, err := th.service.JoinUserToTeam(team, ruser)
require.NoError(t, err)
_, alreadyAdded, err := th.service.JoinUserToTeam(team, ruser)
require.True(t, alreadyAdded, "Should return already added")
require.NoError(t, err)
})
t.Run("re-join after leaving", func(t *testing.T) {
user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""}
ruser := th.CreateUser(&user)
defer th.DeleteUser(&user)
member, _, err := th.service.JoinUserToTeam(team, ruser)
require.NoError(t, err)
err = th.service.RemoveTeamMember(member)
require.NoError(t, err)
_, alreadyAdded, err := th.service.JoinUserToTeam(team, ruser)
require.False(t, alreadyAdded, "Should return already added equal to false")
require.NoError(t, err)
})
t.Run("new join with limit problem", func(t *testing.T) {
user1 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""}
ruser1 := th.CreateUser(&user1)
user2 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""}
ruser2 := th.CreateUser(&user2)
defer th.DeleteUser(&user1)
defer th.DeleteUser(&user2)
_, _, err := th.service.JoinUserToTeam(team, ruser1)
require.NoError(t, err)
_, _, err = th.service.JoinUserToTeam(team, ruser2)
require.Error(t, err, "Should fail")
})
t.Run("re-join alfter leaving with limit problem", func(t *testing.T) {
user1 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""}
ruser1 := th.CreateUser(&user1)
user2 := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Darth Vader", Username: "vader" + model.NewId(), Password: "passwd1", AuthService: ""}
ruser2 := th.CreateUser(&user2)
defer th.DeleteUser(&user1)
defer th.DeleteUser(&user2)
member, _, err := th.service.JoinUserToTeam(team, ruser1)
require.NoError(t, err)
err = th.service.RemoveTeamMember(member)
require.NoError(t, err)
_, _, err = th.service.JoinUserToTeam(team, ruser2)
require.NoError(t, err)
_, _, err = th.service.JoinUserToTeam(team, ruser1)
require.Error(t, err, "Should fail")
})
}

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

@@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package teams
import (
"strings"
"github.com/mattermost/mattermost-server/v6/model"
)
// By default the list will be (not necessarily in this order):
// ['town-square', 'off-topic']
// However, if TeamSettings.ExperimentalDefaultChannels contains a list of channels then that list will replace
// 'off-topic' and be included in the return results in addition to 'town-square'. For example:
// ['town-square', 'game-of-thrones', 'wow']
//
func (ts *TeamService) DefaultChannelNames() []string {
names := []string{"town-square"}
if len(ts.config().TeamSettings.ExperimentalDefaultChannels) == 0 {
names = append(names, "off-topic")
} else {
seenChannels := map[string]bool{"town-square": true}
for _, channelName := range ts.config().TeamSettings.ExperimentalDefaultChannels {
if !seenChannels[channelName] {
names = append(names, channelName)
seenChannels[channelName] = true
}
}
}
return names
}
func IsEmailAddressAllowed(email string, allowedDomains []string) bool {
for _, restriction := range allowedDomains {
domains := normalizeDomains(restriction)
if len(domains) <= 0 {
continue
}
matched := false
for _, d := range domains {
if strings.HasSuffix(email, "@"+d) {
matched = true
break
}
}
if !matched {
return false
}
}
return true
}
func (ts *TeamService) IsTeamEmailAllowed(user *model.User, team *model.Team) bool {
if user.IsBot {
return true
}
email := strings.ToLower(user.Email)
allowedDomains := ts.GetAllowedDomains(user, team)
return IsEmailAddressAllowed(email, allowedDomains)
}
func (ts *TeamService) GetAllowedDomains(user *model.User, team *model.Team) []string {
if user.IsGuest() {
return []string{*ts.config().GuestAccountsSettings.RestrictCreationToDomains}
}
// First check per team allowedDomains, then app wide restrictions
return []string{team.AllowedDomains, *ts.config().TeamSettings.RestrictCreationToDomains}
}
func (ts *TeamService) checkValidDomains(team *model.Team) error {
validDomains := normalizeDomains(*ts.config().TeamSettings.RestrictCreationToDomains)
if len(validDomains) > 0 {
for _, domain := range normalizeDomains(team.AllowedDomains) {
matched := false
for _, d := range validDomains {
if domain == d {
matched = true
break
}
}
if !matched {
return &DomainError{Domain: domain}
}
}
}
return nil
}
func normalizeDomains(domains string) []string {
// commas and @ signs are optional
// can be in the form of "@corp.mattermost.com, mattermost.com mattermost.org" -> corp.mattermost.com mattermost.com mattermost.org
return strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(strings.Replace(domains, "@", " ", -1), ",", " ", -1))))
}
// UserIsInAdminRoleGroup returns true at least one of the user's groups are configured to set the members as
// admins in the given syncable.
func (ts *TeamService) userIsInAdminRoleGroup(userID, syncableID string, syncableType model.GroupSyncableType) (bool, error) {
groupIDs, err := ts.groupStore.AdminRoleGroupsForSyncableMember(userID, syncableID, syncableType)
if err != nil {
return false, err
}
if len(groupIDs) == 0 {
return false, nil
}
return true, nil
}