From 6d30b21dd2815ce65b79b064e49a4d7297df9953 Mon Sep 17 00:00:00 2001 From: Martin Kraft Date: Thu, 10 Jan 2019 15:17:31 -0500 Subject: [PATCH] Ldap groups phase1 (#9752) * Initial models, API, app, and persistence of groups and group syncing. * Consistent letter casing in ldif. * Moves group-specific migrations into func. * Adds API endpoint to retrieve LDAP groups (and associated MM groups) one tree level at a time. * Adds mattermost group id to SCIMGroup (if available). * Splits user and group creation so that memberOf works. Returns users from ldap interface. * Updates method name. * Returns users IDs instead of User. * Removes non-essential group data. * MM-11807: Add GroupFilter to LDAP config. (#9513) * MM-11807: Add GroupFilter to LDAP config. * Add diagnostic. * Adds new config option for using 'memberOf' overlay. * Adds API endpoint to link a group. * Removes debug statements. * Adds unlink group API endpoint. * Fix to LDAP API. Adds API method to client4 and app. * Adds some missing app methods. Renames API unexported func. * Fixes link/unlink API path to accept valid DNs. * Allow any character for DN portion of path. * Switches from DN to objectGUID or entryUUID as the remote identifier linking LDAP groups to MM groups. * Formatting. * Formatting. * Setting group name field to an ID for phase 1. * Adds an LDAP config field to Setting up configuration for local LDAP. * Changes to LDAP and GroupStore interfaces. * Draft of nesting groups in API response. * Removes unnecessary tree models. * Updates group membershipt create store method to also restore. * Adds new config to test config. * Accept AD format length. * Switches to SetUniqueTogether method. * Updates revert. * Tweaks to syncing queries . * Updates query for pending team and channel memberships. * Removes old GroupSyncableScanner usage. Some formatting and renaming. * Fixes bug setting syncable type in selecting paged. * Adds tests for syncables populator. * Only add users to teams and channels that are not deleted. * Renames method. * Updates test LDAP setup. * Removes memberof config stuff. * Renames. * Updates test data. * Fix for gofmt. * Adds missing license. * Adds missing teardowns. * Test fix. * Adds a cycle to the groups test data. * Changes API to return flat list. * Removes some unused interface and app methods. * Returns empty braces if results are empty. * Adds more LDAP test data. * Fix for test data error. * Adds error. * Moves test groups. * Adds OU for load test data. * Moves load test ou creation to load data. * Adds a new bool flag to SCIMGroups. * Removes SCIMGroup completely. * Removes FULL JOIN because it is not supported in MySQL. * Adds tests for sync queries; renames constant. * Bad merge fix. * Vet fix. * Returning OK on delete ldap group link * Removes foreign key constraints. * Adding total to the ldap getAllGroups api endpoint * Adds get group members page. * Removes pagination from groups syncables list API. * Adding syncable check now that foreign key constraint is removes. * Joins teams and channels to group syncables. * Adds group member count. * Adding GetAllChannels and SearchAllChannels for system admins only * Fix. * Test fix from pagination removal. * Orders groupmembers by createat. * Fixing search of all channels * Test fix after removing pagination. * JSON syntax error fix. * Changing tests (for now) pending investigation. * Adding GetAllChannels and SearchAllChannels tests for the store * Adding GetAllChannels and SearchAllChannels API tests * Omit empty JSON values of group syncables. * Fixing GetAllChannels and SearchAllChannels tests * Fixing GetAllChannels and SearchAllChannels store tests * Fixing GetAllChannels api tests * Adds 'LDAP groups' feature flag. (#9861) * Migrate new client functions to idiomatic error handling * Test fixes. * Simplification of groups api (#9860) * Simplification of groups api * Fixing RequireSyncableType * Test fix. * Update api4/group.go Co-Authored-By: mkraft * Update api4/group.go Co-Authored-By: mkraft * Update api4/group.go Co-Authored-By: mkraft * Update api4/group.go Co-Authored-By: mkraft * Update api4/group.go Co-Authored-By: mkraft * Update api4/group.go Co-Authored-By: mkraft * Update api4/group.go Co-Authored-By: mkraft * Update api4/group.go Co-Authored-By: mkraft * Update api4/group.go Co-Authored-By: mkraft * Update api4/group.go Co-Authored-By: mkraft * Fix copy/paste error. * Fix copy/paste error. * Adds missing return, changes to correct HTTP status code. * Adds missing return, changes status codes. * Check for license. * Renames variable for new signature. * Adds client method to get a group. * Adds client method and tests for PatchGroup. * Adds more API tests. * Adds groups API tests. * Adds client method and tests for getting group syncables. * Adds tests for patching group teams and channels. * Update to translations. * Removes test. * Fix incorrect conditional. * Removes unnecessary nil check. * Removes unnecessary return. * Updates comment, removes unused variable. * Uses consistent JSON unmarshal pattern. * Uses consistent JSON unmarshal pattern. * Moves const block. * Switches 'already linked' from error to success response. * Removes commented-out code. * Switched to status ok. * Add parens for readability. * Fix copy/paste error. * Unexport some structs. * Removes repeated validity check. * Return without attempting commit if there's a rollback. * Fix incorrect HTTP status code. * Update store/sqlstore/group_supplier.go Co-Authored-By: mkraft * Adds utility methods for going from groupsyncable to groupteam and groupchannel. * Fixing george suggestions (#9911) * Test fix. * Adds QA data to VC with visualization. * Fixes typo in graph image. * Update display name when re-linking in case it has changed in LDAP. * Adds ability to configure group display name and unique identifier. (#9923) * Adds ability to configure group display name and unique identifier. * Adds some configs to confi-ldap make command. * Fix for move of session. * Exposes method for use by SAML package. * Switches GroupSyncableType from int to string. * Update Jenkins build files. * Removes unused variable assignment. * Removes old unnecessary early return. * Removes unnecessary variable. * Moves param parsing before license and permissions checks. * Removes old code. * Compares agains underlying error rather than error id. * Switches tests to assertions. * Adds more assertions. * Adds missing return. * Adds space after comma for added legibility. * Moves a view model to the api package. * Unexports method. * Uses id validator function. * Fix docker-compose flag. * Typo fix. * Moves index creation to supplier. * Removes bad merge. * Renames parameter. * Re-adds space. * Removes unnecessary transaction. * Escapes the Groups table name with backticks because it is a reserved keyword. * Fix roles cache bug * Removing unnecesiary deserializing function * Switches table name rather than custom SQL everywhere for Postgres without backticks. * Removes redundant check for sql.ErrNoRows. * Removes redundant check for sql.ErrNoRows. * Removes data integrity check and redundant nil conditional. * Removes redundant check for sql.ErrNoRows. * Removes unnecessary query. * Removes ID length validation from persistence tier. * Makes some supplier methods idempotent. * Removes some empty switch defaults. * Renames Group Type field to Source. * Fix for mistaken field name change. * Uses IsValidId function. * Removes comment. * Changes json key name. * Removes test because no longer validating user. * Moves model state validation to app layer. * Don't create Groups.CanLeave column until phase 2. * Removes state validation until properties are used in phase 2. * Removes duplicated check. * Removes state validation until properties are used in phase 2. * Removes some tests until phase 2. * Comment-out a bunch of test related to CanLeave. * Extra unmarshal validation check. Removes more code for CanLeave. * Removes tests for CanLeave. * Explict error msg. * Rewrite queries. * Changes index name. Adds index. * Removes assertion. * Adds experimental feature flag. --- Makefile | 20 +- api4/api.go | 3 + api4/channel.go | 42 + api4/channel_test.go | 62 +- api4/group.go | 433 ++++++ api4/group_test.go | 622 ++++++++ api4/ldap.go | 174 +++ api4/ldap_test.go | 41 +- api4/scheme.go | 4 - api4/team_test.go | 1 - app/channel.go | 16 + app/diagnostics.go | 42 +- app/group.go | 150 ++ app/group_test.go | 223 +++ app/helper_test.go | 22 + app/ldap.go | 40 + app/syncables.go | 63 + app/syncables_test.go | 346 +++++ build/Jenkinsfile.pr | 9 +- build/docker-compose.yml | 5 +- config/default.json | 8 +- einterfaces/ldap.go | 3 + i18n/en.json | 146 +- model/channel.go | 7 + model/channel_list.go | 42 + model/client4.go | 144 ++ model/cluster_message.go | 1 + model/config.go | 42 +- model/group.go | 135 ++ model/group_member.go | 23 + model/group_syncable.go | 169 +++ model/license.go | 6 + model/license_test.go | 5 + store/layered_store.go | 118 ++ store/layered_store_supplier.go | 23 + store/local_cache_supplier.go | 6 + store/local_cache_supplier_groups.go | 102 ++ store/local_cache_supplier_roles.go | 2 +- store/redis_supplier_groups.go | 100 ++ store/sqlstore/channel_store.go | 51 + store/sqlstore/group_store_test.go | 14 + store/sqlstore/group_supplier.go | 771 ++++++++++ store/sqlstore/supplier.go | 8 + store/sqlstore/upgrade.go | 1 + store/store.go | 27 + store/storetest/channel_store.go | 266 ++++ store/storetest/group_supplier.go | 1318 +++++++++++++++++ store/storetest/mocks/ChannelStore.go | 32 + store/storetest/mocks/GroupStore.go | 302 ++++ .../mocks/LayeredStoreDatabaseLayer.go | 430 ++++++ store/storetest/mocks/LayeredStoreSupplier.go | 414 ++++++ store/storetest/mocks/Store.go | 16 + store/storetest/store.go | 2 + tests/add-groups.ldif | 81 + tests/add-users.ldif | 130 ++ tests/qa-data-graph.png | Bin 0 -> 40731 bytes tests/qa-data.ldif | 103 ++ tests/test-config.json | 402 +++++ tests/test-data-graph.png | Bin 0 -> 38602 bytes utils/license.go | 1 + web/context.go | 44 + web/params.go | 25 + 62 files changed, 7781 insertions(+), 57 deletions(-) create mode 100644 api4/group.go create mode 100644 api4/group_test.go create mode 100644 app/group.go create mode 100644 app/group_test.go create mode 100644 app/syncables.go create mode 100644 app/syncables_test.go create mode 100644 model/group.go create mode 100644 model/group_member.go create mode 100644 model/group_syncable.go create mode 100644 store/local_cache_supplier_groups.go create mode 100644 store/redis_supplier_groups.go create mode 100644 store/sqlstore/group_store_test.go create mode 100644 store/sqlstore/group_supplier.go create mode 100644 store/storetest/group_supplier.go create mode 100644 store/storetest/mocks/GroupStore.go create mode 100644 tests/add-groups.ldif create mode 100644 tests/add-users.ldif create mode 100644 tests/qa-data-graph.png create mode 100644 tests/qa-data.ldif create mode 100644 tests/test-config.json create mode 100644 tests/test-data-graph.png diff --git a/Makefile b/Makefile index 343ed785dd..6da506db01 100644 --- a/Makefile +++ b/Makefile @@ -156,15 +156,14 @@ ifeq ($(BUILD_ENTERPRISE_READY),true) -e LDAP_ORGANISATION="Mattermost Test" \ -e LDAP_DOMAIN="mm.test.com" \ -e LDAP_ADMIN_PASSWORD="mostest" \ - -d osixia/openldap:1.1.6 > /dev/null;\ + -d osixia/openldap:1.2.2 > /dev/null;\ sleep 10; \ - docker exec -ti mattermost-openldap bash -c 'echo -e "dn: ou=testusers,dc=mm,dc=test,dc=com\nobjectclass: organizationalunit" | ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest';\ - docker exec -ti mattermost-openldap bash -c 'echo -e "dn: uid=test.one,ou=testusers,dc=mm,dc=test,dc=com\nobjectclass: iNetOrgPerson\nsn: User\ncn: Test1\nmail: success+testone@simulator.amazonses.com" | ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest';\ - docker exec -ti mattermost-openldap bash -c 'ldappasswd -s Password1 -D "cn=admin,dc=mm,dc=test,dc=com" -x "uid=test.one,ou=testusers,dc=mm,dc=test,dc=com" -w mostest';\ - docker exec -ti mattermost-openldap bash -c 'echo -e "dn: uid=test.two,ou=testusers,dc=mm,dc=test,dc=com\nobjectclass: iNetOrgPerson\nsn: User\ncn: Test2\nmail: success+testtwo@simulator.amazonses.com" | ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest';\ - docker exec -ti mattermost-openldap bash -c 'ldappasswd -s Password1 -D "cn=admin,dc=mm,dc=test,dc=com" -x "uid=test.two,ou=testusers,dc=mm,dc=test,dc=com" -w mostest';\ - docker exec -ti mattermost-openldap bash -c 'echo -e "dn: cn=tgroup,ou=testusers,dc=mm,dc=test,dc=com\nobjectclass: groupOfUniqueNames\nuniqueMember: uid=test.one,ou=testusers,dc=mm,dc=test,dc=com" | ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest';\ - elif [ $(shell docker ps --no-trunc --quiet --filter name=^/mattermost-openldap$$ | wc -l) -eq 0 ]; then \ + docker cp tests/add-users.ldif mattermost-openldap:/add-users.ldif;\ + docker cp tests/add-groups.ldif mattermost-openldap:/add-groups.ldif;\ + docker cp tests/qa-data.ldif mattermost-openldap:/qa-data.ldif;\ + docker exec -ti mattermost-openldap bash -c 'ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest -f /add-users.ldif';\ + docker exec -ti mattermost-openldap bash -c 'ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest -f /add-groups.ldif';\ + elif [ $(shell docker ps | grep -ci mattermost-openldap) -eq 0 ]; then \ echo restarting mattermost-openldap; \ docker start mattermost-openldap > /dev/null; \ sleep 10; \ @@ -510,7 +509,7 @@ config-ldap: ## Configures LDAP. @echo Setting up configuration for local LDAP @sed -i'' -e 's|"LdapServer": ".*"|"LdapServer": "dockerhost"|g' config/config.json - @sed -i'' -e 's|"BaseDN": ".*"|"BaseDN": "ou=testusers,dc=mm,dc=test,dc=com"|g' config/config.json + @sed -i'' -e 's|"BaseDN": ".*"|"BaseDN": "dc=mm,dc=test,dc=com"|g' config/config.json @sed -i'' -e 's|"BindUsername": ".*"|"BindUsername": "cn=admin,dc=mm,dc=test,dc=com"|g' config/config.json @sed -i'' -e 's|"BindPassword": ".*"|"BindPassword": "mostest"|g' config/config.json @sed -i'' -e 's|"FirstNameAttribute": ".*"|"FirstNameAttribute": "cn"|g' config/config.json @@ -519,6 +518,9 @@ config-ldap: ## Configures LDAP. @sed -i'' -e 's|"EmailAttribute": ".*"|"EmailAttribute": "mail"|g' config/config.json @sed -i'' -e 's|"UsernameAttribute": ".*"|"UsernameAttribute": "uid"|g' config/config.json @sed -i'' -e 's|"IdAttribute": ".*"|"IdAttribute": "uid"|g' config/config.json + @sed -i'' -e 's|"LoginIdAttribute": ".*"|"LoginIdAttribute": "uid"|g' config/config.json + @sed -i'' -e 's|"GroupDisplayNameAttribute": ".*"|"GroupDisplayNameAttribute": "cn"|g' config/config.json + @sed -i'' -e 's|"GroupIdAttribute": ".*"|"GroupIdAttribute": "entryUUID"|g' config/config.json config-reset: ## Resets the config/config.json file to the default. @echo Resetting configuration to default diff --git a/api4/api.go b/api4/api.go index abaadf879f..b062d6141b 100644 --- a/api4/api.go +++ b/api4/api.go @@ -108,6 +108,7 @@ type Routes struct { ReactionByNameForPostForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/posts/{post_id:[A-Za-z0-9]+}/reactions/{emoji_name:[A-Za-z0-9_-+]+}' TermsOfService *mux.Router // 'api/v4/terms_of_service + Groups *mux.Router // 'api/v4/groups' } type API struct { @@ -205,6 +206,7 @@ func Init(configservice configservice.ConfigService, globalOptionsFunc app.AppOp api.BaseRoutes.Image = api.BaseRoutes.ApiRoot.PathPrefix("/image").Subrouter() api.BaseRoutes.TermsOfService = api.BaseRoutes.ApiRoot.PathPrefix("/terms_of_service").Subrouter() + api.BaseRoutes.Groups = api.BaseRoutes.ApiRoot.PathPrefix("/groups").Subrouter() api.InitUser() api.InitTeam() @@ -234,6 +236,7 @@ func Init(configservice configservice.ConfigService, globalOptionsFunc app.AppOp api.InitScheme() api.InitImage() api.InitTermsOfService() + api.InitGroup() api.InitAction() root.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) diff --git a/api4/channel.go b/api4/channel.go index 9dafab4a10..3435537548 100644 --- a/api4/channel.go +++ b/api4/channel.go @@ -11,8 +11,10 @@ import ( ) func (api *API) InitChannel() { + api.BaseRoutes.Channels.Handle("", api.ApiSessionRequired(getAllChannels)).Methods("GET") api.BaseRoutes.Channels.Handle("", api.ApiSessionRequired(createChannel)).Methods("POST") api.BaseRoutes.Channels.Handle("/direct", api.ApiSessionRequired(createDirectChannel)).Methods("POST") + api.BaseRoutes.Channels.Handle("/search", api.ApiSessionRequired(searchAllChannels)).Methods("POST") api.BaseRoutes.Channels.Handle("/group", api.ApiSessionRequired(createGroupChannel)).Methods("POST") api.BaseRoutes.Channels.Handle("/members/{user_id:[A-Za-z0-9]+}/view", api.ApiSessionRequired(viewChannel)).Methods("POST") api.BaseRoutes.Channels.Handle("/{channel_id:[A-Za-z0-9]+}/scheme", api.ApiSessionRequired(updateChannelScheme)).Methods("PUT") @@ -490,6 +492,21 @@ func getPinnedPosts(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(clientPostList.ToJson())) } +func getAllChannels(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + channels, err := c.App.GetAllChannels(c.Params.Page, c.Params.PerPage, false) + if err != nil { + c.Err = err + return + } + + w.Write([]byte(channels.ToJson())) +} + func getPublicChannelsForTeam(c *Context, w http.ResponseWriter, r *http.Request) { c.RequireTeamId() if c.Err != nil { @@ -693,6 +710,31 @@ func searchChannelsForTeam(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(channels.ToJson())) } +func searchAllChannels(c *Context, w http.ResponseWriter, r *http.Request) { + props := model.ChannelSearchFromJson(r.Body) + if props == nil { + c.SetInvalidParam("channel_search") + return + } + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + includeDeleted := r.URL.Query().Get("include_deleted") == "true" + + channels, err := c.App.SearchAllChannels(props.Term, includeDeleted) + if err != nil { + c.Err = err + return + } + + // Don't fill in channels props, since unused by client and potentially expensive. + + w.Write([]byte(channels.ToJson())) +} + func deleteChannel(c *Context, w http.ResponseWriter, r *http.Request) { c.RequireChannelId() if c.Err != nil { diff --git a/api4/channel_test.go b/api4/channel_test.go index 3e3a24ffb7..82543ea3a3 100644 --- a/api4/channel_test.go +++ b/api4/channel_test.go @@ -772,6 +772,36 @@ func TestGetChannelsForTeamForUser(t *testing.T) { CheckNoError(t, resp) } +func TestGetAllChannels(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + channels, resp := th.SystemAdminClient.GetAllChannels(0, 20, "") + CheckNoError(t, resp) + + // At least, all the not-deleted channels created during the InitBasic + require.True(t, len(*channels) >= 3) + for _, c := range *channels { + require.NotEqual(t, c.TeamId, "") + } + + channels, resp = th.SystemAdminClient.GetAllChannels(0, 10, "") + CheckNoError(t, resp) + require.True(t, len(*channels) >= 3) + + channels, resp = th.SystemAdminClient.GetAllChannels(1, 1, "") + CheckNoError(t, resp) + require.Len(t, *channels, 1) + + channels, resp = th.SystemAdminClient.GetAllChannels(10000, 10000, "") + CheckNoError(t, resp) + require.Len(t, *channels, 0) + + _, resp = Client.GetAllChannels(0, 20, "") + CheckForbiddenStatus(t, resp) +} + func TestSearchChannels(t *testing.T) { th := Setup().InitBasic() defer th.TearDown() @@ -827,6 +857,37 @@ func TestSearchChannels(t *testing.T) { CheckNoError(t, resp) } +func TestSearchAllChannels(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + search := &model.ChannelSearch{Term: th.BasicChannel.Name} + + channels, resp := th.SystemAdminClient.SearchAllChannels(search) + CheckNoError(t, resp) + + assert.Len(t, *channels, 1) + assert.Equal(t, (*channels)[0].Id, th.BasicChannel.Id) + + search.Term = th.BasicPrivateChannel.Name + channels, resp = th.SystemAdminClient.SearchAllChannels(search) + CheckNoError(t, resp) + + assert.Len(t, *channels, 1) + assert.Equal(t, (*channels)[0].Id, th.BasicPrivateChannel.Id) + + search.Term = "" + channels, resp = th.SystemAdminClient.SearchAllChannels(search) + CheckNoError(t, resp) + // At least, all the not-deleted channels created during the InitBasic + assert.True(t, len(*channels) >= 3) + + search.Term = th.BasicChannel.Name + _, resp = Client.SearchAllChannels(search) + CheckForbiddenStatus(t, resp) +} + func TestDeleteChannel(t *testing.T) { th := Setup().InitBasic() defer th.TearDown() @@ -2345,7 +2406,6 @@ func TestUpdateChannelScheme(t *testing.T) { // Test an invalid scheme scope. _, resp = th.SystemAdminClient.UpdateChannelScheme(channel.Id, teamScheme.Id) - fmt.Printf("resp: %+v\n", resp) CheckBadRequestStatus(t, resp) // Test that an unauthenticated user gets rejected. diff --git a/api4/group.go b/api4/group.go new file mode 100644 index 0000000000..d90082f10c --- /dev/null +++ b/api4/group.go @@ -0,0 +1,433 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "database/sql" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "github.com/mattermost/mattermost-server/model" +) + +const ( + groupMemberActionCreate = iota + groupMemberActionDelete +) + +func (api *API) InitGroup() { + // GET /api/v4/groups/:group_id + api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}", + api.ApiSessionRequired(getGroup)).Methods("GET") + + // PUT /api/v4/groups/:group_id/patch + api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/patch", + api.ApiSessionRequired(patchGroup)).Methods("PUT") + + // POST /api/v4/groups/:group_id/teams/:team_id/link + // POST /api/v4/groups/:group_id/channels/:channel_id/link + api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}/{syncable_id:[A-Za-z0-9]+}/link", + api.ApiSessionRequired(linkGroupSyncable)).Methods("POST") + + // DELETE /api/v4/groups/:group_id/teams/:team_id/link + // DELETE /api/v4/groups/:group_id/channels/:channel_id/link + api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}/{syncable_id:[A-Za-z0-9]+}/link", + api.ApiSessionRequired(unlinkGroupSyncable)).Methods("DELETE") + + // GET /api/v4/groups/:group_id/teams/:team_id + // GET /api/v4/groups/:group_id/channels/:channel_id + api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}/{syncable_id:[A-Za-z0-9]+}", + api.ApiSessionRequired(getGroupSyncable)).Methods("GET") + + // GET /api/v4/groups/:group_id/teams + // GET /api/v4/groups/:group_id/channels + api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}", + api.ApiSessionRequired(getGroupSyncables)).Methods("GET") + + // PUT /api/v4/groups/:group_id/teams/:team_id/patch + // PUT /api/v4/groups/:group_id/channels/:channel_id/patch + api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}/{syncable_id:[A-Za-z0-9]+}/patch", + api.ApiSessionRequired(patchGroupSyncable)).Methods("PUT") + + // GET /api/v4/groups/:group_id/members?page=0&per_page=100 + api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/members", + api.ApiSessionRequired(getGroupMembers)).Methods("GET") +} + +func getGroup(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireGroupId() + if c.Err != nil { + return + } + + if c.App.License() == nil || !*c.App.License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.getGroup", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + group, err := c.App.GetGroup(c.Params.GroupId) + if err != nil { + c.Err = err + return + } + + b, marshalErr := json.Marshal(group) + if marshalErr != nil { + c.Err = model.NewAppError("Api4.getGroup", "api.marshal_error", nil, marshalErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} + +func patchGroup(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireGroupId() + if c.Err != nil { + return + } + + groupPatch := model.GroupPatchFromJson(r.Body) + if groupPatch == nil { + c.SetInvalidParam("group") + return + } + + if c.App.License() == nil || !*c.App.License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.patchGroup", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + group, err := c.App.GetGroup(c.Params.GroupId) + if err != nil { + c.Err = err + return + } + + group.Patch(groupPatch) + + group, err = c.App.UpdateGroup(group) + if err != nil { + c.Err = err + return + } + + b, marshalErr := json.Marshal(group) + if marshalErr != nil { + c.Err = model.NewAppError("Api4.patchGroup", "api.marshal_error", nil, marshalErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} + +func linkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireGroupId() + if c.Err != nil { + return + } + + c.RequireSyncableId() + if c.Err != nil { + return + } + syncableID := c.Params.SyncableId + + c.RequireSyncableType() + if c.Err != nil { + return + } + syncableType := c.Params.SyncableType + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + c.Err = model.NewAppError("Api4.createGroupSyncable", "api.io_error", nil, err.Error(), http.StatusBadRequest) + return + } + + var patch *model.GroupSyncablePatch + err = json.Unmarshal(body, &patch) + if err != nil || patch == nil { + c.SetInvalidParam(fmt.Sprintf("Group%s", syncableType.String())) + return + } + + if c.App.License() == nil || !*c.App.License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.createGroupSyncable", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + groupSyncable, appErr := c.App.GetGroupSyncable(c.Params.GroupId, syncableID, syncableType) + if appErr != nil && appErr.DetailedError != sql.ErrNoRows.Error() { + c.Err = appErr + return + } + + if groupSyncable == nil { + groupSyncable = &model.GroupSyncable{ + GroupId: c.Params.GroupId, + SyncableId: syncableID, + Type: syncableType, + } + groupSyncable.Patch(patch) + groupSyncable, appErr = c.App.CreateGroupSyncable(groupSyncable) + if appErr != nil { + c.Err = appErr + return + } + } else { + groupSyncable.DeleteAt = 0 + groupSyncable.Patch(patch) + groupSyncable, appErr = c.App.UpdateGroupSyncable(groupSyncable) + if appErr != nil { + c.Err = appErr + return + } + } + + w.WriteHeader(http.StatusCreated) + + b, marshalErr := json.Marshal(groupSyncable) + if marshalErr != nil { + c.Err = model.NewAppError("Api4.createGroupSyncable", "api.marshal_error", nil, marshalErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} + +func getGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireGroupId() + if c.Err != nil { + return + } + + c.RequireSyncableId() + if c.Err != nil { + return + } + syncableID := c.Params.SyncableId + + c.RequireSyncableType() + if c.Err != nil { + return + } + syncableType := c.Params.SyncableType + + if c.App.License() == nil || !*c.App.License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.getGroupSyncable", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + groupSyncable, err := c.App.GetGroupSyncable(c.Params.GroupId, syncableID, syncableType) + if err != nil { + c.Err = err + return + } + + b, marshalErr := json.Marshal(groupSyncable) + if marshalErr != nil { + c.Err = model.NewAppError("Api4.getGroupSyncable", "api.marshal_error", nil, marshalErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} + +func getGroupSyncables(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireGroupId() + if c.Err != nil { + return + } + + c.RequireSyncableType() + if c.Err != nil { + return + } + syncableType := c.Params.SyncableType + + if c.App.License() == nil || !*c.App.License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.getGroupSyncables", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + groupSyncables, err := c.App.GetGroupSyncables(c.Params.GroupId, syncableType) + if err != nil { + c.Err = err + return + } + + b, marshalErr := json.Marshal(groupSyncables) + if marshalErr != nil { + c.Err = model.NewAppError("Api4.getGroupSyncables", "api.marshal_error", nil, marshalErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} + +func patchGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireGroupId() + if c.Err != nil { + return + } + + c.RequireSyncableId() + if c.Err != nil { + return + } + syncableID := c.Params.SyncableId + + c.RequireSyncableType() + if c.Err != nil { + return + } + syncableType := c.Params.SyncableType + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + c.Err = model.NewAppError("Api4.patchGroupSyncable", "api.io_error", nil, err.Error(), http.StatusBadRequest) + return + } + + var patch *model.GroupSyncablePatch + err = json.Unmarshal(body, &patch) + if err != nil || patch == nil { + c.SetInvalidParam(fmt.Sprintf("Group[%s]Patch", syncableType.String())) + return + } + + if c.App.License() == nil || !*c.App.License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.patchGroupSyncable", "api.ldap_groups.license_error", nil, "", + http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + groupSyncable, appErr := c.App.GetGroupSyncable(c.Params.GroupId, syncableID, syncableType) + if appErr != nil { + c.Err = appErr + return + } + + groupSyncable.Patch(patch) + + groupSyncable, appErr = c.App.UpdateGroupSyncable(groupSyncable) + if appErr != nil { + c.Err = appErr + return + } + + b, marshalErr := json.Marshal(groupSyncable) + if marshalErr != nil { + c.Err = model.NewAppError("Api4.patchGroupSyncable", "api.marshal_error", nil, marshalErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} + +func unlinkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireGroupId() + if c.Err != nil { + return + } + + c.RequireSyncableId() + if c.Err != nil { + return + } + syncableID := c.Params.SyncableId + + c.RequireSyncableType() + if c.Err != nil { + return + } + syncableType := c.Params.SyncableType + + if c.App.License() == nil || !*c.App.License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.unlinkGroupSyncable", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + _, err := c.App.DeleteGroupSyncable(c.Params.GroupId, syncableID, syncableType) + if err != nil { + c.Err = err + return + } + + ReturnStatusOK(w) +} + +func getGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireGroupId() + if c.Err != nil { + return + } + + if c.App.License() == nil || !*c.App.License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.getGroupMembers", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + members, count, err := c.App.GetGroupMemberUsersPage(c.Params.GroupId, c.Params.Page, c.Params.PerPage) + if err != nil { + c.Err = err + return + } + + b, marshalErr := json.Marshal(struct { + Members []*model.User `json:"members"` + Count int `json:"total_member_count"` + }{ + Members: members, + Count: count, + }) + if marshalErr != nil { + c.Err = model.NewAppError("Api4.getGroupMembers", "api.marshal_error", nil, marshalErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} diff --git a/api4/group_test.go b/api4/group_test.go new file mode 100644 index 0000000000..b379a8878a --- /dev/null +++ b/api4/group_test.go @@ -0,0 +1,622 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mattermost/mattermost-server/model" +) + +func TestGetGroup(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + g, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + _, response := th.Client.GetGroup(g.Id, "") + CheckNotImplementedStatus(t, response) + + _, response = th.SystemAdminClient.GetGroup(g.Id, "") + CheckNotImplementedStatus(t, response) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + group, response := th.SystemAdminClient.GetGroup(g.Id, "") + CheckNoError(t, response) + + assert.Equal(t, g.DisplayName, group.DisplayName) + assert.Equal(t, g.Name, group.Name) + assert.Equal(t, g.Source, group.Source) + assert.Equal(t, g.Description, group.Description) + assert.Equal(t, g.RemoteId, group.RemoteId) + assert.Equal(t, g.CreateAt, group.CreateAt) + assert.Equal(t, g.UpdateAt, group.UpdateAt) + assert.Equal(t, g.DeleteAt, group.DeleteAt) + + _, response = th.SystemAdminClient.GetGroup(model.NewId(), "") + CheckNotFoundStatus(t, response) + + _, response = th.SystemAdminClient.GetGroup("12345", "") + CheckBadRequestStatus(t, response) + + th.SystemAdminClient.Logout() + _, response = th.SystemAdminClient.GetGroup(group.Id, "") + CheckUnauthorizedStatus(t, response) +} + +func TestPatchGroup(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + g, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + updateFmt := "%s_updated" + + newName := fmt.Sprintf(updateFmt, g.Name) + newDisplayName := fmt.Sprintf(updateFmt, g.DisplayName) + newDescription := fmt.Sprintf(updateFmt, g.Description) + + gp := &model.GroupPatch{ + Name: &newName, + DisplayName: &newDisplayName, + Description: &newDescription, + } + + _, response := th.Client.PatchGroup(g.Id, gp) + CheckNotImplementedStatus(t, response) + + _, response = th.SystemAdminClient.PatchGroup(g.Id, gp) + CheckNotImplementedStatus(t, response) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + group2, response := th.SystemAdminClient.PatchGroup(g.Id, gp) + CheckOKStatus(t, response) + + group, response := th.SystemAdminClient.GetGroup(g.Id, "") + CheckNoError(t, response) + + assert.Equal(t, *gp.DisplayName, group.DisplayName) + assert.Equal(t, *gp.DisplayName, group2.DisplayName) + assert.Equal(t, *gp.Name, group.Name) + assert.Equal(t, *gp.Name, group2.Name) + assert.Equal(t, *gp.Description, group.Description) + assert.Equal(t, *gp.Description, group2.Description) + + assert.Equal(t, group2.UpdateAt, group.UpdateAt) + + assert.Equal(t, g.Source, group.Source) + assert.Equal(t, g.Source, group2.Source) + assert.Equal(t, g.RemoteId, group.RemoteId) + assert.Equal(t, g.RemoteId, group2.RemoteId) + assert.Equal(t, g.CreateAt, group.CreateAt) + assert.Equal(t, g.CreateAt, group2.CreateAt) + assert.Equal(t, g.DeleteAt, group.DeleteAt) + assert.Equal(t, g.DeleteAt, group2.DeleteAt) + + _, response = th.SystemAdminClient.PatchGroup(model.NewId(), gp) + CheckNotFoundStatus(t, response) + + th.SystemAdminClient.Logout() + _, response = th.SystemAdminClient.PatchGroup(group.Id, gp) + CheckUnauthorizedStatus(t, response) +} + +func TestLinkGroupTeam(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + g, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + patch := &model.GroupSyncablePatch{ + CanLeave: model.NewBool(true), + AutoAdd: model.NewBool(true), + } + + _, response := th.Client.LinkGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + CheckNotImplementedStatus(t, response) + + _, response = th.SystemAdminClient.LinkGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + CheckNotImplementedStatus(t, response) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + groupTeam, response := th.SystemAdminClient.LinkGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + assert.Equal(t, http.StatusCreated, response.StatusCode) + assert.NotNil(t, groupTeam) +} + +func TestLinkGroupChannel(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + g, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + patch := &model.GroupSyncablePatch{ + CanLeave: model.NewBool(true), + AutoAdd: model.NewBool(true), + } + + _, response := th.Client.LinkGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + CheckNotImplementedStatus(t, response) + + _, response = th.SystemAdminClient.LinkGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + CheckNotImplementedStatus(t, response) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + _, response = th.SystemAdminClient.LinkGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + assert.Equal(t, http.StatusCreated, response.StatusCode) +} + +func TestUnlinkGroupTeam(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + g, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + patch := &model.GroupSyncablePatch{ + CanLeave: model.NewBool(true), + AutoAdd: model.NewBool(true), + } + + th.App.SetLicense(model.NewTestLicense("ldap")) + + _, response := th.SystemAdminClient.LinkGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + assert.Equal(t, http.StatusCreated, response.StatusCode) + + th.App.SetLicense(nil) + + response = th.Client.UnlinkGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + CheckNotImplementedStatus(t, response) + + response = th.SystemAdminClient.UnlinkGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + CheckNotImplementedStatus(t, response) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + response = th.SystemAdminClient.UnlinkGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + CheckOKStatus(t, response) +} + +func TestUnlinkGroupChannel(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + g, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + patch := &model.GroupSyncablePatch{ + CanLeave: model.NewBool(true), + AutoAdd: model.NewBool(true), + } + + th.App.SetLicense(model.NewTestLicense("ldap")) + + _, response := th.SystemAdminClient.LinkGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + assert.Equal(t, http.StatusCreated, response.StatusCode) + + th.App.SetLicense(nil) + + response = th.Client.UnlinkGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + CheckNotImplementedStatus(t, response) + + response = th.SystemAdminClient.UnlinkGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + CheckNotImplementedStatus(t, response) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + response = th.SystemAdminClient.UnlinkGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + CheckOKStatus(t, response) +} + +func TestGetGroupTeam(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + g, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + _, response := th.Client.GetGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, "") + CheckNotImplementedStatus(t, response) + + _, response = th.SystemAdminClient.GetGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, "") + CheckNotImplementedStatus(t, response) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + patch := &model.GroupSyncablePatch{ + CanLeave: model.NewBool(true), + AutoAdd: model.NewBool(true), + } + + _, response = th.SystemAdminClient.LinkGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + assert.Equal(t, http.StatusCreated, response.StatusCode) + + groupSyncable, response := th.SystemAdminClient.GetGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, "") + CheckOKStatus(t, response) + assert.NotNil(t, groupSyncable) + + assert.Equal(t, g.Id, groupSyncable.GroupId) + assert.Equal(t, th.BasicTeam.Id, groupSyncable.SyncableId) + assert.Equal(t, *patch.AutoAdd, groupSyncable.AutoAdd) + // assert.Equal(t, *patch.CanLeave, groupSyncable.CanLeave) // TODO: Re-add this test in phase 2 of LDAP groups sync. + + _, response = th.SystemAdminClient.GetGroupSyncable(model.NewId(), th.BasicTeam.Id, model.GroupSyncableTypeTeam, "") + CheckNotFoundStatus(t, response) + + _, response = th.SystemAdminClient.GetGroupSyncable(g.Id, model.NewId(), model.GroupSyncableTypeTeam, "") + CheckNotFoundStatus(t, response) + + _, response = th.SystemAdminClient.GetGroupSyncable("asdfasdfe3", th.BasicTeam.Id, model.GroupSyncableTypeTeam, "") + CheckBadRequestStatus(t, response) + + _, response = th.SystemAdminClient.GetGroupSyncable(g.Id, "asdfasdfe3", model.GroupSyncableTypeTeam, "") + CheckBadRequestStatus(t, response) + + th.SystemAdminClient.Logout() + _, response = th.SystemAdminClient.GetGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, "") + CheckUnauthorizedStatus(t, response) +} + +func TestGetGroupChannel(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + g, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + _, response := th.Client.GetGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, "") + CheckNotImplementedStatus(t, response) + + _, response = th.SystemAdminClient.GetGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, "") + CheckNotImplementedStatus(t, response) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + patch := &model.GroupSyncablePatch{ + CanLeave: model.NewBool(true), + AutoAdd: model.NewBool(true), + } + + _, response = th.SystemAdminClient.LinkGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + assert.Equal(t, http.StatusCreated, response.StatusCode) + + groupSyncable, response := th.SystemAdminClient.GetGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, "") + CheckOKStatus(t, response) + assert.NotNil(t, groupSyncable) + + assert.Equal(t, g.Id, groupSyncable.GroupId) + assert.Equal(t, th.BasicChannel.Id, groupSyncable.SyncableId) + assert.Equal(t, *patch.AutoAdd, groupSyncable.AutoAdd) + // assert.Equal(t, *patch.CanLeave, groupSyncable.CanLeave) // TODO: Re-add this test in phase 2 of LDAP groups sync. + + _, response = th.SystemAdminClient.GetGroupSyncable(model.NewId(), th.BasicChannel.Id, model.GroupSyncableTypeChannel, "") + CheckNotFoundStatus(t, response) + + _, response = th.SystemAdminClient.GetGroupSyncable(g.Id, model.NewId(), model.GroupSyncableTypeChannel, "") + CheckNotFoundStatus(t, response) + + _, response = th.SystemAdminClient.GetGroupSyncable("asdfasdfe3", th.BasicChannel.Id, model.GroupSyncableTypeChannel, "") + CheckBadRequestStatus(t, response) + + _, response = th.SystemAdminClient.GetGroupSyncable(g.Id, "asdfasdfe3", model.GroupSyncableTypeChannel, "") + CheckBadRequestStatus(t, response) + + th.SystemAdminClient.Logout() + _, response = th.SystemAdminClient.GetGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, "") + CheckUnauthorizedStatus(t, response) +} + +func TestGetGroupTeams(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + g, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + patch := &model.GroupSyncablePatch{ + CanLeave: model.NewBool(true), + AutoAdd: model.NewBool(true), + } + + for i := 0; i < 10; i++ { + team := th.CreateTeam() + _, response := th.SystemAdminClient.LinkGroupSyncable(g.Id, team.Id, model.GroupSyncableTypeTeam, patch) + assert.Equal(t, http.StatusCreated, response.StatusCode) + } + + th.App.SetLicense(nil) + + _, response := th.Client.GetGroupSyncables(g.Id, model.GroupSyncableTypeTeam, "") + CheckNotImplementedStatus(t, response) + + _, response = th.SystemAdminClient.GetGroupSyncables(g.Id, model.GroupSyncableTypeTeam, "") + CheckNotImplementedStatus(t, response) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + _, response = th.Client.GetGroupSyncables(g.Id, model.GroupSyncableTypeTeam, "") + assert.Equal(t, http.StatusForbidden, response.StatusCode) + + groupSyncables, response := th.SystemAdminClient.GetGroupSyncables(g.Id, model.GroupSyncableTypeTeam, "") + CheckOKStatus(t, response) + + assert.Len(t, groupSyncables, 10) + + th.SystemAdminClient.Logout() + _, response = th.SystemAdminClient.GetGroupSyncables(g.Id, model.GroupSyncableTypeTeam, "") + CheckUnauthorizedStatus(t, response) +} + +func TestGetGroupChannels(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + g, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + patch := &model.GroupSyncablePatch{ + CanLeave: model.NewBool(true), + AutoAdd: model.NewBool(true), + } + + for i := 0; i < 10; i++ { + channel := th.CreatePublicChannel() + _, response := th.SystemAdminClient.LinkGroupSyncable(g.Id, channel.Id, model.GroupSyncableTypeChannel, patch) + assert.Equal(t, http.StatusCreated, response.StatusCode) + } + + th.App.SetLicense(nil) + + _, response := th.Client.GetGroupSyncables(g.Id, model.GroupSyncableTypeChannel, "") + CheckNotImplementedStatus(t, response) + + _, response = th.SystemAdminClient.GetGroupSyncables(g.Id, model.GroupSyncableTypeChannel, "") + CheckNotImplementedStatus(t, response) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + _, response = th.Client.GetGroupSyncables(g.Id, model.GroupSyncableTypeChannel, "") + assert.Equal(t, http.StatusForbidden, response.StatusCode) + + groupSyncables, response := th.SystemAdminClient.GetGroupSyncables(g.Id, model.GroupSyncableTypeChannel, "") + CheckOKStatus(t, response) + + assert.Len(t, groupSyncables, 10) + + th.SystemAdminClient.Logout() + _, response = th.SystemAdminClient.GetGroupSyncables(g.Id, model.GroupSyncableTypeChannel, "") + CheckUnauthorizedStatus(t, response) +} + +func TestPatchGroupTeam(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + g, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + patch := &model.GroupSyncablePatch{ + CanLeave: model.NewBool(true), + AutoAdd: model.NewBool(true), + } + + th.App.SetLicense(model.NewTestLicense("ldap")) + + groupSyncable, response := th.SystemAdminClient.LinkGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + assert.Equal(t, http.StatusCreated, response.StatusCode) + assert.NotNil(t, groupSyncable) + // assert.True(t, groupSyncable.CanLeave) // TODO: Re-add this test in phase 2 of LDAP groups sync. + assert.True(t, groupSyncable.AutoAdd) + + _, response = th.Client.PatchGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + assert.Equal(t, http.StatusForbidden, response.StatusCode) + + th.App.SetLicense(nil) + + _, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + CheckNotImplementedStatus(t, response) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + patch.AutoAdd = model.NewBool(false) + groupSyncable, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + CheckOKStatus(t, response) + assert.False(t, groupSyncable.AutoAdd) + + assert.Equal(t, g.Id, groupSyncable.GroupId) + assert.Equal(t, th.BasicTeam.Id, groupSyncable.SyncableId) + assert.Equal(t, model.GroupSyncableTypeTeam, groupSyncable.Type) + + // TODO: Re-add this test in phase 2 of LDAP groups sync. + // patch.CanLeave = model.NewBool(false) + // _, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + // CheckBadRequestStatus(t, response) + + patch.AutoAdd = model.NewBool(true) + groupSyncable, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + CheckOKStatus(t, response) + assert.False(t, groupSyncable.CanLeave) + + _, response = th.SystemAdminClient.PatchGroupSyncable(model.NewId(), th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + CheckNotFoundStatus(t, response) + + _, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, model.NewId(), model.GroupSyncableTypeTeam, patch) + CheckNotFoundStatus(t, response) + + _, response = th.SystemAdminClient.PatchGroupSyncable("abc", th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + CheckBadRequestStatus(t, response) + + _, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, "abc", model.GroupSyncableTypeTeam, patch) + CheckBadRequestStatus(t, response) + + th.SystemAdminClient.Logout() + _, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch) + CheckUnauthorizedStatus(t, response) +} + +func TestPatchGroupChannel(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + g, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + patch := &model.GroupSyncablePatch{ + CanLeave: model.NewBool(true), + AutoAdd: model.NewBool(true), + } + + th.App.SetLicense(model.NewTestLicense("ldap")) + + groupSyncable, response := th.SystemAdminClient.LinkGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + assert.Equal(t, http.StatusCreated, response.StatusCode) + assert.NotNil(t, groupSyncable) + // assert.True(t, groupSyncable.CanLeave) // TODO: Re-add this test in phase 2 of LDAP groups sync. + assert.True(t, groupSyncable.AutoAdd) + + _, response = th.Client.PatchGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + assert.Equal(t, http.StatusForbidden, response.StatusCode) + + th.App.SetLicense(nil) + + _, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + CheckNotImplementedStatus(t, response) + + th.App.SetLicense(model.NewTestLicense("ldap")) + + patch.AutoAdd = model.NewBool(false) + groupSyncable, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + CheckOKStatus(t, response) + assert.False(t, groupSyncable.AutoAdd) + + assert.Equal(t, g.Id, groupSyncable.GroupId) + assert.Equal(t, th.BasicChannel.Id, groupSyncable.SyncableId) + assert.Equal(t, model.GroupSyncableTypeChannel, groupSyncable.Type) + + // TODO: Re-add this test in phase 2 of LDAP groups sync. + // patch.CanLeave = model.NewBool(false) + // _, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + // CheckBadRequestStatus(t, response) + + patch.AutoAdd = model.NewBool(true) + groupSyncable, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + CheckOKStatus(t, response) + assert.False(t, groupSyncable.CanLeave) + + _, response = th.SystemAdminClient.PatchGroupSyncable(model.NewId(), th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + CheckNotFoundStatus(t, response) + + _, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, model.NewId(), model.GroupSyncableTypeChannel, patch) + CheckNotFoundStatus(t, response) + + _, response = th.SystemAdminClient.PatchGroupSyncable("abc", th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + CheckBadRequestStatus(t, response) + + _, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, "abc", model.GroupSyncableTypeChannel, patch) + CheckBadRequestStatus(t, response) + + th.SystemAdminClient.Logout() + _, response = th.SystemAdminClient.PatchGroupSyncable(g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch) + CheckUnauthorizedStatus(t, response) +} diff --git a/api4/ldap.go b/api4/ldap.go index 5f2afb485e..69766a4add 100644 --- a/api4/ldap.go +++ b/api4/ldap.go @@ -4,14 +4,32 @@ package api4 import ( + "database/sql" + "encoding/json" "net/http" "github.com/mattermost/mattermost-server/model" ) +type mixedUnlinkedGroup struct { + Id *string `json:"mattermost_group_id"` + DisplayName string `json:"name"` + RemoteId string `json:"primary_key"` + HasSyncables *bool `json:"has_syncables"` +} + func (api *API) InitLdap() { api.BaseRoutes.LDAP.Handle("/sync", api.ApiSessionRequired(syncLdap)).Methods("POST") api.BaseRoutes.LDAP.Handle("/test", api.ApiSessionRequired(testLdap)).Methods("POST") + + // GET /api/v4/ldap/groups?page=0&per_page=1000 + api.BaseRoutes.LDAP.Handle("/groups", api.ApiSessionRequired(getLdapGroups)).Methods("GET") + + // POST /api/v4/ldap/groups/:remote_id/link + api.BaseRoutes.LDAP.Handle(`/groups/{remote_id}/link`, api.ApiSessionRequired(linkLdapGroup)).Methods("POST") + + // DELETE /api/v4/ldap/groups/:remote_id/link + api.BaseRoutes.LDAP.Handle(`/groups/{remote_id}/link`, api.ApiSessionRequired(unlinkLdapGroup)).Methods("DELETE") } func syncLdap(c *Context, w http.ResponseWriter, r *http.Request) { @@ -38,3 +56,159 @@ func testLdap(c *Context, w http.ResponseWriter, r *http.Request) { ReturnStatusOK(w) } + +func getLdapGroups(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + if c.App.License() == nil || !*c.App.License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.getLdapGroups", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + groups, total, err := c.App.GetAllLdapGroupsPage(c.Params.Page, c.Params.PerPage) + if err != nil { + c.Err = err + return + } + + mugs := []*mixedUnlinkedGroup{} + for _, group := range groups { + mug := &mixedUnlinkedGroup{ + DisplayName: group.DisplayName, + RemoteId: group.RemoteId, + } + if len(group.Id) == 26 { + mug.Id = &group.Id + mug.HasSyncables = &group.HasSyncables + } + mugs = append(mugs, mug) + } + + b, marshalErr := json.Marshal(struct { + Count int `json:"count"` + Groups []*mixedUnlinkedGroup `json:"groups"` + }{Count: total, Groups: mugs}) + if marshalErr != nil { + c.Err = model.NewAppError("Api4.getLdapGroups", "api.marshal_error", nil, marshalErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} + +func linkLdapGroup(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireRemoteId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + if c.App.License() == nil || !*c.App.License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.linkLdapGroup", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + ldapGroup, err := c.App.GetLdapGroup(c.Params.RemoteId) + if err != nil { + c.Err = err + return + } + + if ldapGroup == nil { + c.Err = model.NewAppError("Api4.linkLdapGroup", "api.ldap_group.not_found", nil, "", http.StatusNotFound) + return + } + + group, err := c.App.GetGroupByRemoteID(ldapGroup.RemoteId, model.GroupSourceLdap) + if err != nil && err.DetailedError != sql.ErrNoRows.Error() { + c.Err = err + return + } + + var status int + var newOrUpdatedGroup *model.Group + + // Group has been previously linked + if group != nil { + if group.DeleteAt == 0 { + newOrUpdatedGroup = group + } else { + group.DeleteAt = 0 + group.DisplayName = ldapGroup.DisplayName + group.RemoteId = ldapGroup.RemoteId + newOrUpdatedGroup, err = c.App.UpdateGroup(group) + if err != nil { + c.Err = err + return + } + } + status = http.StatusOK + } else { + // Group has never been linked + // + // TODO: In a future phase of LDAP groups sync `Name` will be used for at-mentions and will be editable on + // the front-end so it will not have an initial value of `model.NewId()` but rather a slugified version of + // the LDAP group name with an appended duplicate-breaker. + newGroup := &model.Group{ + Name: model.NewId(), + DisplayName: ldapGroup.DisplayName, + RemoteId: ldapGroup.RemoteId, + Source: model.GroupSourceLdap, + } + newOrUpdatedGroup, err = c.App.CreateGroup(newGroup) + if err != nil { + c.Err = err + return + } + status = http.StatusCreated + } + + b, marshalErr := json.Marshal(newOrUpdatedGroup) + if marshalErr != nil { + c.Err = model.NewAppError("Api4.linkLdapGroup", "api.marshal_error", nil, marshalErr.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(status) + w.Write(b) +} + +func unlinkLdapGroup(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireRemoteId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + if c.App.License() == nil || !*c.App.License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.unlinkLdapGroup", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + group, err := c.App.GetGroupByRemoteID(c.Params.RemoteId, model.GroupSourceLdap) + if err != nil { + c.Err = err + return + } + + if group.DeleteAt == 0 { + _, err = c.App.DeleteGroup(group.Id) + if err != nil { + c.Err = err + return + } + } + + ReturnStatusOK(w) +} diff --git a/api4/ldap_test.go b/api4/ldap_test.go index 5c7f53b1d0..e738a6b78a 100644 --- a/api4/ldap_test.go +++ b/api4/ldap_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -func TestLdapTest(t *testing.T) { +func TestTestLdap(t *testing.T) { th := Setup().InitBasic() defer th.TearDown() @@ -18,7 +18,7 @@ func TestLdapTest(t *testing.T) { CheckNotImplementedStatus(t, resp) } -func TestLdapSync(t *testing.T) { +func TestSyncLdap(t *testing.T) { th := Setup().InitBasic() defer th.TearDown() @@ -28,3 +28,40 @@ func TestLdapSync(t *testing.T) { _, resp = th.Client.SyncLdap() CheckForbiddenStatus(t, resp) } + +func TestGetLdapGroups(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + _, resp := th.Client.GetLdapGroups() + CheckForbiddenStatus(t, resp) + + _, resp = th.SystemAdminClient.GetLdapGroups() + CheckNotImplementedStatus(t, resp) +} + +func TestLinkLdapGroup(t *testing.T) { + const entryUUID string = "foo" + + th := Setup().InitBasic() + defer th.TearDown() + + _, resp := th.Client.LinkLdapGroup(entryUUID) + CheckForbiddenStatus(t, resp) + + _, resp = th.SystemAdminClient.LinkLdapGroup(entryUUID) + CheckNotImplementedStatus(t, resp) +} + +func TestUnlinkLdapGroup(t *testing.T) { + const entryUUID string = "foo" + + th := Setup().InitBasic() + defer th.TearDown() + + _, resp := th.Client.UnlinkLdapGroup(entryUUID) + CheckForbiddenStatus(t, resp) + + _, resp = th.SystemAdminClient.UnlinkLdapGroup(entryUUID) + CheckNotImplementedStatus(t, resp) +} diff --git a/api4/scheme.go b/api4/scheme.go index 99463cdd01..c1438ab555 100644 --- a/api4/scheme.go +++ b/api4/scheme.go @@ -67,10 +67,6 @@ func getScheme(c *Context, w http.ResponseWriter, r *http.Request) { } func getSchemes(c *Context, w http.ResponseWriter, r *http.Request) { - if c.Err != nil { - return - } - if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) return diff --git a/api4/team_test.go b/api4/team_test.go index 76ead3dcbb..eca9cbeb2a 100644 --- a/api4/team_test.go +++ b/api4/team_test.go @@ -2203,7 +2203,6 @@ func TestUpdateTeamScheme(t *testing.T) { // Test an invalid scheme scope. _, resp = th.SystemAdminClient.UpdateTeamScheme(team.Id, channelScheme.Id) - fmt.Printf("resp: %+v\n", resp) CheckBadRequestStatus(t, resp) // Test that an unauthenticated user gets rejected. diff --git a/app/channel.go b/app/channel.go index d9e4d7533d..0a31692e34 100644 --- a/app/channel.go +++ b/app/channel.go @@ -1116,6 +1116,14 @@ func (a *App) GetChannelsForUser(teamId string, userId string, includeDeleted bo return result.Data.(*model.ChannelList), nil } +func (a *App) GetAllChannels(page, perPage int, includeDeleted bool) (*model.ChannelListWithTeamData, *model.AppError) { + result := <-a.Srv.Store.Channel().GetAllChannels(page*perPage, perPage, includeDeleted) + if result.Err != nil { + return nil, result.Err + } + return result.Data.(*model.ChannelListWithTeamData), nil +} + func (a *App) GetDeletedChannels(teamId string, offset int, limit int) (*model.ChannelList, *model.AppError) { result := <-a.Srv.Store.Channel().GetDeleted(teamId, offset, limit) if result.Err != nil { @@ -1577,6 +1585,14 @@ func (a *App) AutocompleteChannelsForSearch(teamId string, userId string, term s return result.Data.(*model.ChannelList), nil } +func (a *App) SearchAllChannels(term string, includeDeleted bool) (*model.ChannelListWithTeamData, *model.AppError) { + result := <-a.Srv.Store.Channel().SearchAllChannels(term, *a.Config().TeamSettings.ExperimentalViewArchivedChannels && includeDeleted) + if result.Err != nil { + return nil, result.Err + } + return result.Data.(*model.ChannelListWithTeamData), nil +} + func (a *App) SearchChannels(teamId string, term string) (*model.ChannelList, *model.AppError) { includeDeleted := *a.Config().TeamSettings.ExperimentalViewArchivedChannels diff --git a/app/diagnostics.go b/app/diagnostics.go index 36e01ec55f..ead5dfba9f 100644 --- a/app/diagnostics.go +++ b/app/diagnostics.go @@ -264,6 +264,7 @@ func (a *App) trackConfig() { "experimental_enable_hardened_mode": *cfg.ServiceSettings.ExperimentalEnableHardenedMode, "enable_email_invitations": *cfg.ServiceSettings.EnableEmailInvitations, "experimental_channel_organization": *cfg.ServiceSettings.ExperimentalChannelOrganization, + "experimental_ldap_group_sync": *cfg.ServiceSettings.ExperimentalLdapGroupSync, }) a.SendDiagnostic(TRACK_CONFIG_TEAM, map[string]interface{}{ @@ -418,25 +419,28 @@ func (a *App) trackConfig() { }) a.SendDiagnostic(TRACK_CONFIG_LDAP, map[string]interface{}{ - "enable": *cfg.LdapSettings.Enable, - "enable_sync": *cfg.LdapSettings.EnableSync, - "connection_security": *cfg.LdapSettings.ConnectionSecurity, - "skip_certificate_verification": *cfg.LdapSettings.SkipCertificateVerification, - "sync_interval_minutes": *cfg.LdapSettings.SyncIntervalMinutes, - "query_timeout": *cfg.LdapSettings.QueryTimeout, - "max_page_size": *cfg.LdapSettings.MaxPageSize, - "isdefault_first_name_attribute": isDefault(*cfg.LdapSettings.FirstNameAttribute, model.LDAP_SETTINGS_DEFAULT_FIRST_NAME_ATTRIBUTE), - "isdefault_last_name_attribute": isDefault(*cfg.LdapSettings.LastNameAttribute, model.LDAP_SETTINGS_DEFAULT_LAST_NAME_ATTRIBUTE), - "isdefault_email_attribute": isDefault(*cfg.LdapSettings.EmailAttribute, model.LDAP_SETTINGS_DEFAULT_EMAIL_ATTRIBUTE), - "isdefault_username_attribute": isDefault(*cfg.LdapSettings.UsernameAttribute, model.LDAP_SETTINGS_DEFAULT_USERNAME_ATTRIBUTE), - "isdefault_nickname_attribute": isDefault(*cfg.LdapSettings.NicknameAttribute, model.LDAP_SETTINGS_DEFAULT_NICKNAME_ATTRIBUTE), - "isdefault_id_attribute": isDefault(*cfg.LdapSettings.IdAttribute, model.LDAP_SETTINGS_DEFAULT_ID_ATTRIBUTE), - "isdefault_position_attribute": isDefault(*cfg.LdapSettings.PositionAttribute, model.LDAP_SETTINGS_DEFAULT_POSITION_ATTRIBUTE), - "isdefault_login_id_attribute": isDefault(*cfg.LdapSettings.LoginIdAttribute, ""), - "isdefault_login_field_name": isDefault(*cfg.LdapSettings.LoginFieldName, model.LDAP_SETTINGS_DEFAULT_LOGIN_FIELD_NAME), - "isdefault_login_button_color": isDefault(*cfg.LdapSettings.LoginButtonColor, ""), - "isdefault_login_button_border_color": isDefault(*cfg.LdapSettings.LoginButtonBorderColor, ""), - "isdefault_login_button_text_color": isDefault(*cfg.LdapSettings.LoginButtonTextColor, ""), + "enable": *cfg.LdapSettings.Enable, + "enable_sync": *cfg.LdapSettings.EnableSync, + "connection_security": *cfg.LdapSettings.ConnectionSecurity, + "skip_certificate_verification": *cfg.LdapSettings.SkipCertificateVerification, + "sync_interval_minutes": *cfg.LdapSettings.SyncIntervalMinutes, + "query_timeout": *cfg.LdapSettings.QueryTimeout, + "max_page_size": *cfg.LdapSettings.MaxPageSize, + "isdefault_first_name_attribute": isDefault(*cfg.LdapSettings.FirstNameAttribute, model.LDAP_SETTINGS_DEFAULT_FIRST_NAME_ATTRIBUTE), + "isdefault_last_name_attribute": isDefault(*cfg.LdapSettings.LastNameAttribute, model.LDAP_SETTINGS_DEFAULT_LAST_NAME_ATTRIBUTE), + "isdefault_email_attribute": isDefault(*cfg.LdapSettings.EmailAttribute, model.LDAP_SETTINGS_DEFAULT_EMAIL_ATTRIBUTE), + "isdefault_username_attribute": isDefault(*cfg.LdapSettings.UsernameAttribute, model.LDAP_SETTINGS_DEFAULT_USERNAME_ATTRIBUTE), + "isdefault_nickname_attribute": isDefault(*cfg.LdapSettings.NicknameAttribute, model.LDAP_SETTINGS_DEFAULT_NICKNAME_ATTRIBUTE), + "isdefault_id_attribute": isDefault(*cfg.LdapSettings.IdAttribute, model.LDAP_SETTINGS_DEFAULT_ID_ATTRIBUTE), + "isdefault_position_attribute": isDefault(*cfg.LdapSettings.PositionAttribute, model.LDAP_SETTINGS_DEFAULT_POSITION_ATTRIBUTE), + "isdefault_login_id_attribute": isDefault(*cfg.LdapSettings.LoginIdAttribute, ""), + "isdefault_login_field_name": isDefault(*cfg.LdapSettings.LoginFieldName, model.LDAP_SETTINGS_DEFAULT_LOGIN_FIELD_NAME), + "isdefault_login_button_color": isDefault(*cfg.LdapSettings.LoginButtonColor, ""), + "isdefault_login_button_border_color": isDefault(*cfg.LdapSettings.LoginButtonBorderColor, ""), + "isdefault_login_button_text_color": isDefault(*cfg.LdapSettings.LoginButtonTextColor, ""), + "isempty_group_filter": isDefault(*cfg.LdapSettings.GroupFilter, ""), + "isdefault_group_display_name_attribute": isDefault(*cfg.LdapSettings.GroupDisplayNameAttribute, model.LDAP_SETTINGS_DEFAULT_GROUP_DISPLAY_NAME_ATTRIBUTE), + "isdefault_group_id_attribute": isDefault(*cfg.LdapSettings.GroupIdAttribute, model.LDAP_SETTINGS_DEFAULT_GROUP_ID_ATTRIBUTE), }) a.SendDiagnostic(TRACK_CONFIG_COMPLIANCE, map[string]interface{}{ diff --git a/app/group.go b/app/group.go new file mode 100644 index 0000000000..102e5d2a0f --- /dev/null +++ b/app/group.go @@ -0,0 +1,150 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "github.com/mattermost/mattermost-server/model" +) + +func (a *App) GetGroup(id string) (*model.Group, *model.AppError) { + result := <-a.Srv.Store.Group().Get(id) + if result.Err != nil { + return nil, result.Err + } + return result.Data.(*model.Group), nil +} + +func (a *App) GetGroupByRemoteID(remoteID string, groupSource model.GroupSource) (*model.Group, *model.AppError) { + result := <-a.Srv.Store.Group().GetByRemoteID(remoteID, groupSource) + if result.Err != nil { + return nil, result.Err + } + return result.Data.(*model.Group), nil +} + +func (a *App) GetGroupsBySource(groupSource model.GroupSource) ([]*model.Group, *model.AppError) { + result := <-a.Srv.Store.Group().GetAllBySource(groupSource) + if result.Err != nil { + return nil, result.Err + } + return result.Data.([]*model.Group), nil +} + +func (a *App) CreateGroup(group *model.Group) (*model.Group, *model.AppError) { + result := <-a.Srv.Store.Group().Create(group) + if result.Err != nil { + return nil, result.Err + } + return result.Data.(*model.Group), nil +} + +func (a *App) UpdateGroup(group *model.Group) (*model.Group, *model.AppError) { + result := <-a.Srv.Store.Group().Update(group) + if result.Err != nil { + return nil, result.Err + } + return result.Data.(*model.Group), nil +} + +func (a *App) DeleteGroup(groupID string) (*model.Group, *model.AppError) { + result := <-a.Srv.Store.Group().Delete(groupID) + if result.Err != nil { + return nil, result.Err + } + return result.Data.(*model.Group), nil +} + +func (a *App) GetGroupMemberUsers(groupID string) ([]*model.User, *model.AppError) { + result := <-a.Srv.Store.Group().GetMemberUsers(groupID) + if result.Err != nil { + return nil, result.Err + } + return result.Data.([]*model.User), nil +} + +func (a *App) GetGroupMemberUsersPage(groupID string, page int, perPage int) ([]*model.User, int, *model.AppError) { + result := <-a.Srv.Store.Group().GetMemberUsersPage(groupID, page, perPage) + if result.Err != nil { + return nil, 0, result.Err + } + members := result.Data.([]*model.User) + result = <-a.Srv.Store.Group().GetMemberCount(groupID) + if result.Err != nil { + return nil, 0, result.Err + } + count := int(result.Data.(int64)) + return members, count, nil +} + +func (a *App) CreateOrRestoreGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { + result := <-a.Srv.Store.Group().CreateOrRestoreMember(groupID, userID) + if result.Err != nil { + return nil, result.Err + } + return result.Data.(*model.GroupMember), nil +} + +func (a *App) DeleteGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) { + result := <-a.Srv.Store.Group().DeleteMember(groupID, userID) + if result.Err != nil { + return nil, result.Err + } + return result.Data.(*model.GroupMember), nil +} + +func (a *App) CreateGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) { + result := <-a.Srv.Store.Group().CreateGroupSyncable(groupSyncable) + if result.Err != nil { + return nil, result.Err + } + return result.Data.(*model.GroupSyncable), nil +} + +func (a *App) GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) { + result := <-a.Srv.Store.Group().GetGroupSyncable(groupID, syncableID, syncableType) + if result.Err != nil { + return nil, result.Err + } + return result.Data.(*model.GroupSyncable), nil +} + +func (a *App) GetGroupSyncables(groupID string, syncableType model.GroupSyncableType) ([]*model.GroupSyncable, *model.AppError) { + result := <-a.Srv.Store.Group().GetAllGroupSyncablesByGroupId(groupID, syncableType) + if result.Err != nil { + return nil, result.Err + } + return result.Data.([]*model.GroupSyncable), nil +} + +func (a *App) UpdateGroupSyncable(groupSyncable *model.GroupSyncable) (*model.GroupSyncable, *model.AppError) { + result := <-a.Srv.Store.Group().UpdateGroupSyncable(groupSyncable) + if result.Err != nil { + return nil, result.Err + } + return result.Data.(*model.GroupSyncable), nil +} + +func (a *App) DeleteGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) { + result := <-a.Srv.Store.Group().DeleteGroupSyncable(groupID, syncableID, syncableType) + if result.Err != nil { + return nil, result.Err + } + return result.Data.(*model.GroupSyncable), nil +} + +func (a *App) PendingAutoAddTeamMembers(minGroupMembersCreateAt int64) ([]*model.UserTeamIDPair, *model.AppError) { + result := <-a.Srv.Store.Group().PendingAutoAddTeamMembers(minGroupMembersCreateAt) + if result.Err != nil { + return nil, result.Err + } + return result.Data.([]*model.UserTeamIDPair), nil +} + +func (a *App) PendingAutoAddChannelMembers(minGroupMembersCreateAt int64) ([]*model.UserChannelIDPair, *model.AppError) { + result := <-a.Srv.Store.Group().PendingAutoAddChannelMembers(minGroupMembersCreateAt) + if result.Err != nil { + return nil, result.Err + } + return result.Data.([]*model.UserChannelIDPair), nil +} diff --git a/app/group_test.go b/app/group_test.go new file mode 100644 index 0000000000..e5cc67b8e4 --- /dev/null +++ b/app/group_test.go @@ -0,0 +1,223 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost-server/model" + "github.com/stretchr/testify/require" +) + +func TestGetGroup(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + group := th.CreateGroup() + + group, err := th.App.GetGroup(group.Id) + require.Nil(t, err) + require.NotNil(t, group) + + group, err = th.App.GetGroup(model.NewId()) + require.NotNil(t, err) + require.Nil(t, group) +} + +func TestGetGroupByRemoteID(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + group := th.CreateGroup() + + g, err := th.App.GetGroupByRemoteID(group.RemoteId, model.GroupSourceLdap) + require.Nil(t, err) + require.NotNil(t, g) + + g, err = th.App.GetGroupByRemoteID(model.NewId(), model.GroupSourceLdap) + require.NotNil(t, err) + require.Nil(t, g) +} + +func TestGetGroupsByType(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + th.CreateGroup() + th.CreateGroup() + th.CreateGroup() + + groups, err := th.App.GetGroupsBySource(model.GroupSourceLdap) + require.Nil(t, err) + require.NotEmpty(t, groups) + + groups, err = th.App.GetGroupsBySource(model.GroupSource("blah")) + require.Nil(t, err) + require.Empty(t, groups) +} + +func TestCreateGroup(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + id := model.NewId() + group := &model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + + g, err := th.App.CreateGroup(group) + require.Nil(t, err) + require.NotNil(t, g) + + g, err = th.App.CreateGroup(group) + require.NotNil(t, err) + require.Nil(t, g) +} + +func TestUpdateGroup(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + group := th.CreateGroup() + group.DisplayName = model.NewId() + + g, err := th.App.UpdateGroup(group) + require.Nil(t, err) + require.NotNil(t, g) +} + +func TestDeleteGroup(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + group := th.CreateGroup() + + g, err := th.App.DeleteGroup(group.Id) + require.Nil(t, err) + require.NotNil(t, g) + + g, err = th.App.DeleteGroup(group.Id) + require.NotNil(t, err) + require.Nil(t, g) +} + +func TestCreateOrRestoreGroupMember(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + group := th.CreateGroup() + + g, err := th.App.CreateOrRestoreGroupMember(group.Id, th.BasicUser.Id) + require.Nil(t, err) + require.NotNil(t, g) + + g, err = th.App.CreateOrRestoreGroupMember(group.Id, th.BasicUser.Id) + require.NotNil(t, err) + require.Nil(t, g) +} + +func TestDeleteGroupMember(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + group := th.CreateGroup() + groupMember, err := th.App.CreateOrRestoreGroupMember(group.Id, th.BasicUser.Id) + require.Nil(t, err) + require.NotNil(t, groupMember) + + groupMember, err = th.App.DeleteGroupMember(groupMember.GroupId, groupMember.UserId) + require.Nil(t, err) + require.NotNil(t, groupMember) + + groupMember, err = th.App.DeleteGroupMember(groupMember.GroupId, groupMember.UserId) + require.NotNil(t, err) + require.Nil(t, groupMember) +} + +func TestCreateGroupSyncable(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + group := th.CreateGroup() + groupSyncable := &model.GroupSyncable{ + GroupId: group.Id, + CanLeave: true, + AutoAdd: false, + SyncableId: th.BasicTeam.Id, + Type: model.GroupSyncableTypeTeam, + } + + gs, err := th.App.CreateGroupSyncable(groupSyncable) + require.Nil(t, err) + require.NotNil(t, gs) + + gs, err = th.App.CreateGroupSyncable(groupSyncable) + require.NotNil(t, err) + require.Nil(t, gs) +} + +func TestGetGroupSyncable(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + group := th.CreateGroup() + groupSyncable := &model.GroupSyncable{ + GroupId: group.Id, + CanLeave: true, + AutoAdd: false, + SyncableId: th.BasicTeam.Id, + Type: model.GroupSyncableTypeTeam, + } + + gs, err := th.App.CreateGroupSyncable(groupSyncable) + require.Nil(t, err) + require.NotNil(t, gs) + + gs, err = th.App.GetGroupSyncable(group.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam) + require.Nil(t, err) + require.NotNil(t, gs) +} + +func TestGetGroupSyncables(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + group := th.CreateGroup() + + // Create a group team + groupSyncable := &model.GroupSyncable{ + GroupId: group.Id, + CanLeave: true, + AutoAdd: false, + SyncableId: th.BasicTeam.Id, + Type: model.GroupSyncableTypeTeam, + } + + gs, err := th.App.CreateGroupSyncable(groupSyncable) + require.Nil(t, err) + require.NotNil(t, gs) + + groupTeams, err := th.App.GetGroupSyncables(group.Id, model.GroupSyncableTypeTeam) + require.Nil(t, err) + + require.NotEmpty(t, groupTeams) +} + +func TestDeleteGroupSyncable(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + group := th.CreateGroup() + groupChannel := &model.GroupSyncable{ + GroupId: group.Id, + CanLeave: true, + AutoAdd: false, + SyncableId: th.BasicChannel.Id, + Type: model.GroupSyncableTypeChannel, + } + + gs, err := th.App.CreateGroupSyncable(groupChannel) + require.Nil(t, err) + require.NotNil(t, gs) + + gs, err = th.App.DeleteGroupSyncable(group.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.Nil(t, err) + require.NotNil(t, gs) + + gs, err = th.App.DeleteGroupSyncable(group.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel) + require.NotNil(t, err) + require.Nil(t, gs) +} diff --git a/app/helper_test.go b/app/helper_test.go index 8b0facc896..d8f749b92e 100644 --- a/app/helper_test.go +++ b/app/helper_test.go @@ -343,6 +343,28 @@ func (me *TestHelper) CreateScheme() (*model.Scheme, []*model.Role) { return scheme, roles } +func (me *TestHelper) CreateGroup() *model.Group { + id := model.NewId() + group := &model.Group{ + DisplayName: "dn_" + id, + Name: "name" + id, + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + } + + utils.DisableDebugLogForTest() + var err *model.AppError + if group, err = me.App.CreateGroup(group); err != nil { + mlog.Error(err.Error()) + + time.Sleep(time.Second) + panic(err) + } + utils.EnableDebugLogForTest() + return group +} + func (me *TestHelper) CreateEmoji() *model.Emoji { utils.DisableDebugLogForTest() diff --git a/app/ldap.go b/app/ldap.go index d254c656cd..ffe73c1456 100644 --- a/app/ldap.go +++ b/app/ldap.go @@ -40,6 +40,46 @@ func (a *App) TestLdap() *model.AppError { return nil } +// GetLdapGroup retrieves a single LDAP group by the given LDAP group id. +func (a *App) GetLdapGroup(ldapGroupID string) (*model.Group, *model.AppError) { + var group *model.Group + + if a.Ldap != nil { + var err *model.AppError + group, err = a.Ldap.GetGroup(ldapGroupID) + if err != nil { + return nil, err + } + } else { + ae := model.NewAppError("GetLdapGroup", "ent.ldap.app_error", nil, "", http.StatusNotImplemented) + mlog.Error(fmt.Sprintf("%v", ae.Error())) + return nil, ae + } + + return group, nil +} + +// GetAllLdapGroupsPage retrieves all LDAP groups under the configured base DN using the default or configured group +// filter. +func (a *App) GetAllLdapGroupsPage(page int, perPage int) ([]*model.Group, int, *model.AppError) { + var groups []*model.Group + var total int + + if a.Ldap != nil { + var err *model.AppError + groups, total, err = a.Ldap.GetAllGroupsPage(page, perPage) + if err != nil { + return nil, 0, err + } + } else { + ae := model.NewAppError("GetAllLdapGroupsPage", "ent.ldap.app_error", nil, "", http.StatusNotImplemented) + mlog.Error(fmt.Sprintf("%v", ae.Error())) + return nil, 0, ae + } + + return groups, total, nil +} + func (a *App) SwitchEmailToLdap(email, password, code, ldapLoginId, ldapPassword string) (string, *model.AppError) { if a.License() != nil && !*a.Config().ServiceSettings.ExperimentalEnableAuthenticationTransfer { return "", model.NewAppError("emailToLdap", "api.user.email_to_ldap.not_available.app_error", nil, "", http.StatusForbidden) diff --git a/app/syncables.go b/app/syncables.go new file mode 100644 index 0000000000..c386b7da9d --- /dev/null +++ b/app/syncables.go @@ -0,0 +1,63 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "github.com/mattermost/mattermost-server/mlog" +) + +// PopulateSyncablesSince adds users to teams and channels based on their group memberships and how those groups are +// configured to sync with teams and channels for group members on or after the given timestamp. +func (a *App) PopulateSyncablesSince(groupMembersCreatedAfter int64) error { + userTeamIDs, appErr := a.PendingAutoAddTeamMembers(groupMembersCreatedAfter) + if appErr != nil { + return appErr + } + + for _, userTeam := range userTeamIDs { + _, err := a.AddTeamMember(userTeam.TeamID, userTeam.UserID) + if err != nil { + return err + } + + a.Log.Info("added teammember", + mlog.String("user_id", userTeam.UserID), + mlog.String("team_id", userTeam.TeamID), + ) + } + + userChannelIDs, appErr := a.PendingAutoAddChannelMembers(groupMembersCreatedAfter) + if appErr != nil { + return appErr + } + + for _, userChannel := range userChannelIDs { + channel, err := a.GetChannel(userChannel.ChannelID) + if err != nil { + return err + } + + // First add user to team + _, err = a.AddTeamMember(channel.TeamId, userChannel.UserID) + if err != nil { + return err + } + a.Log.Info("added teammember", + mlog.String("user_id", userChannel.UserID), + mlog.String("team_id", channel.TeamId), + ) + + _, err = a.AddChannelMember(userChannel.UserID, channel, "", "", false) + if err != nil { + return err + } + + a.Log.Info("added channelmember", + mlog.String("user_id", userChannel.UserID), + mlog.String("channel_id", userChannel.ChannelID), + ) + } + + return nil +} diff --git a/app/syncables_test.go b/app/syncables_test.go new file mode 100644 index 0000000000..3396ee2a6c --- /dev/null +++ b/app/syncables_test.go @@ -0,0 +1,346 @@ +package app + +import ( + "testing" + + "github.com/mattermost/mattermost-server/model" +) + +func TestPopulateSyncablesSince(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + singersTeam, err := th.App.CreateTeam(&model.Team{ + DisplayName: "Singers", + Name: model.NewId(), + Email: "singers@test.com", + Type: model.TEAM_OPEN, + }) + if err != nil { + t.Errorf("test team not created: %s", err.Error()) + } + + nerdsTeam, err := th.App.CreateTeam(&model.Team{ + DisplayName: "Nerds", + Name: model.NewId(), + Email: "nerds@test.com", + Type: model.TEAM_INVITE, + }) + if err != nil { + t.Errorf("test team not created: %s", err.Error()) + } + + practiceChannel, err := th.App.CreateChannel(&model.Channel{ + TeamId: singersTeam.Id, + DisplayName: "Practices", + Name: model.NewId(), + Type: model.CHANNEL_OPEN, + }, false) + if err != nil { + t.Errorf("test channel not created: %s", err.Error()) + } + + experimentsChannel, err := th.App.CreateChannel(&model.Channel{ + TeamId: singersTeam.Id, + DisplayName: "Experiments", + Name: model.NewId(), + Type: model.CHANNEL_PRIVATE, + }, false) + if err != nil { + t.Errorf("test channel not created: %s", err.Error()) + } + + gleeGroup, err := th.App.CreateGroup(&model.Group{ + Name: model.NewId(), + DisplayName: "Glee Club", + RemoteId: model.NewId(), + Source: model.GroupSourceLdap, + }) + if err != nil { + t.Errorf("test group not created: %s", err.Error()) + } + + scienceGroup, err := th.App.CreateGroup(&model.Group{ + Name: model.NewId(), + DisplayName: "Science Club", + RemoteId: model.NewId(), + Source: model.GroupSourceLdap, + }) + if err != nil { + t.Errorf("test group not created: %s", err.Error()) + } + + _, err = th.App.CreateGroupSyncable(&model.GroupSyncable{ + CanLeave: true, + AutoAdd: true, + GroupId: gleeGroup.Id, + SyncableId: practiceChannel.Id, + Type: model.GroupSyncableTypeChannel, + }) + if err != nil { + t.Errorf("test groupchannel not created: %s", err.Error()) + } + + scienceTeamGroupSyncable, err := th.App.CreateGroupSyncable(&model.GroupSyncable{ + CanLeave: true, + AutoAdd: false, + GroupId: scienceGroup.Id, + SyncableId: nerdsTeam.Id, + Type: model.GroupSyncableTypeTeam, + }) + if err != nil { + t.Errorf("test groupteam not created: %s", err.Error()) + } + + scienceChannelGroupSyncable, err := th.App.CreateGroupSyncable(&model.GroupSyncable{ + CanLeave: true, + AutoAdd: false, + GroupId: scienceGroup.Id, + SyncableId: experimentsChannel.Id, + Type: model.GroupSyncableTypeChannel, + }) + if err != nil { + t.Errorf("test groupchannel not created: %s", err.Error()) + } + + singer1 := th.BasicUser + scientist1 := th.BasicUser2 + + _, err = th.App.CreateOrRestoreGroupMember(gleeGroup.Id, singer1.Id) + if err != nil { + t.Errorf("test groupmember not created: %s", err.Error()) + } + + scientistGroupMember, err := th.App.CreateOrRestoreGroupMember(scienceGroup.Id, scientist1.Id) + if err != nil { + t.Errorf("test groupmember not created: %s", err.Error()) + } + + pErr := th.App.PopulateSyncablesSince(0) + if pErr != nil { + t.Errorf("faild to populate syncables: %s", pErr.Error()) + } + + // Singer should be in team and channel + _, err = th.App.GetTeamMember(singersTeam.Id, singer1.Id) + if err != nil { + t.Errorf("error retrieving team member: %s", err.Error()) + } + _, err = th.App.GetChannelMember(practiceChannel.Id, singer1.Id) + if err != nil { + t.Errorf("error retrieving channel member: %s", err.Error()) + } + + tMembers, err := th.App.GetTeamMembers(singersTeam.Id, 0, 999) + if err != nil { + t.Errorf("error retrieving team members: %s", err.Error()) + } + expected := 1 + actual := len(tMembers) + if actual != expected { + t.Errorf("expected %d team members but got %d", expected, actual) + } + + cMembersCount, err := th.App.GetChannelMemberCount(practiceChannel.Id) + if err != nil { + t.Errorf("error retrieving team members: %s", err.Error()) + } + if cMembersCount != int64(expected) { + t.Errorf("expected %d team member but got %d", expected, cMembersCount) + } + + // Scientist should not be in team or channel + _, err = th.App.GetTeamMember(nerdsTeam.Id, scientist1.Id) + if err.Id != "store.sql_team.get_member.missing.app_error" { + t.Errorf("wrong error: %s", err.Id) + } + + _, err = th.App.GetChannelMember(experimentsChannel.Id, scientist1.Id) + if err.Id != "store.sql_channel.get_member.missing.app_error" { + t.Errorf("wrong error: %s", err.Id) + } + + tMembers, err = th.App.GetTeamMembers(nerdsTeam.Id, 0, 999) + if err != nil { + t.Errorf("error retrieving team members: %s", err.Error()) + } + expected = 0 + actual = len(tMembers) + if actual != expected { + t.Errorf("expected %d team members but got %d", expected, actual) + } + + cMembersCount, err = th.App.GetChannelMemberCount(experimentsChannel.Id) + if err != nil { + t.Errorf("error retrieving team members: %s", err.Error()) + } + if cMembersCount != int64(expected) { + t.Errorf("expected %d team members but got %d", expected, cMembersCount) + } + + // update AutoAdd to true + scienceTeamGroupSyncable.AutoAdd = true + scienceTeamGroupSyncable, err = th.App.UpdateGroupSyncable(scienceTeamGroupSyncable) + if err != nil { + t.Errorf("error updating group syncable: %s", err.Error()) + } + + // Sync everything after syncable was created (proving that team updates trigger re-sync) + pErr = th.App.PopulateSyncablesSince(scientistGroupMember.CreateAt + 1) + if pErr != nil { + t.Errorf("faild to populate syncables: %s", pErr.Error()) + } + + // Scientist should be in team but not the channel + _, err = th.App.GetTeamMember(nerdsTeam.Id, scientist1.Id) + if err != nil { + t.Errorf("error retrieving team member: %s", err.Error()) + } + + _, err = th.App.GetChannelMember(experimentsChannel.Id, scientist1.Id) + if err.Id != "store.sql_channel.get_member.missing.app_error" { + t.Errorf("wrong error: %s", err.Id) + } + + tMembers, err = th.App.GetTeamMembers(nerdsTeam.Id, 0, 999) + if err != nil { + t.Errorf("error retrieving team members: %s", err.Error()) + } + expected = 1 + actual = len(tMembers) + if actual != expected { + t.Errorf("expected %d team members but got %d", expected, actual) + } + + expected = 0 + cMembersCount, err = th.App.GetChannelMemberCount(experimentsChannel.Id) + if err != nil { + t.Errorf("error retrieving team members: %s", err.Error()) + } + if cMembersCount != int64(expected) { + t.Errorf("expected %d team members but got %d", expected, cMembersCount) + } + + // Update the channel syncable + scienceChannelGroupSyncable.AutoAdd = true + scienceChannelGroupSyncable, err = th.App.UpdateGroupSyncable(scienceChannelGroupSyncable) + if err != nil { + t.Errorf("error updating group syncable: %s", err.Error()) + } + + // Sync everything after syncable was created (proving that channel updates trigger re-sync) + pErr = th.App.PopulateSyncablesSince(scientistGroupMember.CreateAt + 1) + if pErr != nil { + t.Errorf("faild to populate syncables: %s", pErr.Error()) + } + + expected = 1 + cMembersCount, err = th.App.GetChannelMemberCount(experimentsChannel.Id) + if err != nil { + t.Errorf("error retrieving team members: %s", err.Error()) + } + if cMembersCount != int64(expected) { + t.Errorf("expected %d team members but got %d", expected, cMembersCount) + } + + // singer leaves team and channel + err = th.App.LeaveChannel(practiceChannel.Id, singer1.Id) + if err != nil { + t.Errorf("error leaving channel: %s", err.Error()) + } + err = th.App.LeaveTeam(singersTeam, singer1, "") + if err != nil { + t.Errorf("error leaving team: %s", err.Error()) + } + + // Even re-syncing from the beginning doesn't re-add to channel or team + pErr = th.App.PopulateSyncablesSince(0) + if pErr != nil { + t.Errorf("faild to populate syncables: %s", pErr.Error()) + } + + // Singer should not be in team or channel + tMember, err := th.App.GetTeamMember(singersTeam.Id, singer1.Id) + if err != nil { + t.Errorf("error retrieving team member: %s", err.Error()) + } + if tMember.DeleteAt == 0 { + t.Error("expected team member to remain deleted") + } + + _, err = th.App.GetChannelMember(practiceChannel.Id, singer1.Id) + if err == nil { + t.Error("Expected channel member to remain deleted") + } + + // Ensure members are in channel + _, err = th.App.AddChannelMember(scientist1.Id, experimentsChannel, "", "", false) + if err != nil { + t.Errorf("unable to add user to channel: %s", err.Error()) + } + + // Add other user so that user can leave channel + _, err = th.App.AddTeamMember(singersTeam.Id, singer1.Id) + if err != nil { + t.Errorf("unable to add user to team: %s", err.Error()) + } + _, err = th.App.AddChannelMember(singer1.Id, experimentsChannel, "", "", false) + if err != nil { + t.Errorf("unable to add user to channel: %s", err.Error()) + } + + // the channel syncable is updated + scienceChannelGroupSyncable.CanLeave = false + scienceChannelGroupSyncable, err = th.App.UpdateGroupSyncable(scienceChannelGroupSyncable) + if err != nil { + t.Errorf("error updating group syncable: %s", err.Error()) + } + + pErr = th.App.PopulateSyncablesSince(0) + if pErr != nil { + t.Errorf("faild to populate syncables: %s", pErr.Error()) + } + + timeBeforeLeaving := model.GetMillis() + + // User leaves channel + err = th.App.LeaveChannel(experimentsChannel.Id, scientist1.Id) + if err != nil { + t.Errorf("unable to add user to channel: %s", err.Error()) + } + + timeAfterLeaving := model.GetMillis() + + // Purging channelmemberhistory doesn't re-add user to channel + result := <-th.App.Srv.Store.ChannelMemberHistory().PermanentDeleteBatch(timeBeforeLeaving, 1000) + if result.Err != nil { + t.Errorf("error permanently deleting channelmemberhistory: %s", result.Err.Error()) + } + + pErr = th.App.PopulateSyncablesSince(scienceChannelGroupSyncable.UpdateAt) + if pErr != nil { + t.Errorf("failed to populate syncables: %s", pErr.Error()) + } + + _, err = th.App.GetChannelMember(experimentsChannel.Id, scientist1.Id) + if err == nil { + t.Error("Expected channel member to remain deleted") + } + + // Purging channelmemberhistory doesn't re-add user to channel + result = <-th.App.Srv.Jobs.Store.ChannelMemberHistory().PermanentDeleteBatch(timeAfterLeaving, 1000) + if result.Err != nil { + t.Errorf("error permanently deleting channelmemberhistory: %s", result.Err.Error()) + } + + pErr = th.App.PopulateSyncablesSince(scienceChannelGroupSyncable.UpdateAt) + if pErr != nil { + t.Errorf("failed to populate syncables: %s", pErr.Error()) + } + + // Channel member is re-added. + _, err = th.App.GetChannelMember(experimentsChannel.Id, scientist1.Id) + if err != nil { + t.Errorf("expected channel member: %s", err.Error()) + } +} diff --git a/build/Jenkinsfile.pr b/build/Jenkinsfile.pr index 414db896ca..2dc64e540c 100644 --- a/build/Jenkinsfile.pr +++ b/build/Jenkinsfile.pr @@ -113,13 +113,8 @@ pipeline { sh """ docker-compose --no-ansi run --rm start_dependencies docker-compose --no-ansi ps - - docker-compose --no-ansi exec -T openldap bash -c 'echo -e "dn: ou=testusers,dc=mm,dc=test,dc=com\nobjectclass: organizationalunit" | ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest'; - docker-compose --no-ansi exec -T openldap bash -c 'echo -e "dn: uid=test.one,ou=testusers,dc=mm,dc=test,dc=com\nobjectclass: iNetOrgPerson\nsn: User\ncn: Test1\nmail: success+testone@simulator.amazonses.com" | ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest'; - docker-compose --no-ansi exec -T openldap bash -c 'ldappasswd -s Password1 -D "cn=admin,dc=mm,dc=test,dc=com" -x "uid=test.one,ou=testusers,dc=mm,dc=test,dc=com" -w mostest'; - docker-compose --no-ansi exec -T openldap bash -c 'echo -e "dn: uid=test.two,ou=testusers,dc=mm,dc=test,dc=com\nobjectclass: iNetOrgPerson\nsn: User\ncn: Test2\nmail: success+testtwo@simulator.amazonses.com" | ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest'; - docker-compose --no-ansi exec -T openldap bash -c 'ldappasswd -s Password1 -D "cn=admin,dc=mm,dc=test,dc=com" -x "uid=test.two,ou=testusers,dc=mm,dc=test,dc=com" -w mostest'; - docker-compose --no-ansi exec -T openldap bash -c 'echo -e "dn: cn=tgroup,ou=testusers,dc=mm,dc=test,dc=com\nobjectclass: groupOfUniqueNames\nuniqueMember: uid=test.one,ou=testusers,dc=mm,dc=test,dc=com" | ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest'; + docker-compose --no-ansi exec -T openldap bash -c 'ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest -f /add-users.ldif'; + docker-compose --no-ansi exec -T openldap bash -c 'ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest -f /add-groups.ldif'; """ } } diff --git a/build/docker-compose.yml b/build/docker-compose.yml index 6aa47e1767..bac8ff7179 100644 --- a/build/docker-compose.yml +++ b/build/docker-compose.yml @@ -38,7 +38,7 @@ services: networks: - mm-test openldap: - image: "osixia/openldap:1.1.6" + image: "osixia/openldap:1.2.2" restart: always networks: - mm-test @@ -47,6 +47,9 @@ services: LDAP_ORGANISATION: "Mattermost Test" LDAP_DOMAIN: "mm.test.com" LDAP_ADMIN_PASSWORD: "mostest" + volumes: + - "../tests/add-users.ldif:/add-users.ldif" + - "../tests/add-groups.ldif:/add-groups.ldif" elasticsearch: image: "mattermost/mattermost-elasticsearch-docker:6.5.1" networks: diff --git a/config/default.json b/config/default.json index 3f7b0e9a41..7b940dc997 100644 --- a/config/default.json +++ b/config/default.json @@ -76,7 +76,8 @@ "ImageProxyURL": "", "EnableAPITeamDeletion": false, "ExperimentalEnableHardenedMode": false, - "EnableEmailInvitations": false + "EnableEmailInvitations": false, + "ExperimentalLdapGroupSync": false }, "TeamSettings": { "SiteName": "Mattermost", @@ -277,6 +278,9 @@ "BindUsername": "", "BindPassword": "", "UserFilter": "", + "GroupFilter": "", + "GroupDisplayNameAttribute": "", + "GroupIdAttribute": "", "FirstNameAttribute": "", "LastNameAttribute": "", "EmailAttribute": "", @@ -410,4 +414,4 @@ "Plugins": {}, "PluginStates": {} } -} +} \ No newline at end of file diff --git a/einterfaces/ldap.go b/einterfaces/ldap.go index 31e8b7cf86..da4f1d844c 100644 --- a/einterfaces/ldap.go +++ b/einterfaces/ldap.go @@ -19,4 +19,7 @@ type LdapInterface interface { RunTest() *model.AppError GetAllLdapUsers() ([]*model.User, *model.AppError) MigrateIDAttribute(toAttribute string) error + GetGroup(groupUID string) (*model.Group, *model.AppError) + GetAllGroupsPage(page int, perPage int) ([]*model.Group, int, *model.AppError) + FirstLoginSync(user *model.User) *model.AppError } diff --git a/i18n/en.json b/i18n/en.json index d0411b2f8b..91040f0e56 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -6765,5 +6765,149 @@ { "id": "web.incoming_webhook.user.app_error", "translation": "Couldn't find the user" + }, + { + "id": "api.io_error", + "translation": "input/output error" + }, + { + "id": "api.marshal_error", + "translation": "marshal error" + }, + { + "id": "api.ldap_groups.license_error", + "translation": "your license does not support ldap groups" + }, + { + "id": "api.ldap_group.not_found", + "translation": "ldap group not found" + }, + { + "id": "ent.ldap_groups.reachable_groups_error", + "translation": "error retrieving groups for user" + }, + { + "id": "ent.ldap_groups.no_rows", + "translation": "no groups found with matching uid" + }, + { + "id": "ent.ldap_groups.members_of_group_error", + "translation": "error retrieving members of group" + }, + { + "id": "ent.ldap_groups.group_search_error", + "translation": "error retrieving ldap group" + }, + { + "id": "ent.ldap_groups.groups_search_error", + "translation": "error retrieving ldap groups" + }, + { + "id": "ent.ldap.syncronize.get_all_groups.app_error", + "translation": "error retrieving groups" + }, + { + "id": "ent.ldap.syncronize.populate_syncables", + "translation": "error populating syncables" + }, + { + "id": "model.group.create_at.app_error", + "translation": "invalid create at property for group" + }, + { + "id": "model.group.description.app_error", + "translation": "invalid description property for group" + }, + { + "id": "model.group.display_name.app_error", + "translation": "invalid display name property for group" + }, + { + "id": "model.group.id.app_error", + "translation": "invalid id property for group" + }, + { + "id": "model.group.name.app_error", + "translation": "invalid name property for group" + }, + { + "id": "model.group.remote_id.app_error", + "translation": "invalid remote id property for group" + }, + { + "id": "model.group.source.app_error", + "translation": "invalid source property for group" + }, + { + "id": "model.group_syncable.type.app_error", + "translation": "invalid type property for group syncable" + }, + { + "id": "model.group_syncable.unmarshaljson.duplicate_type_identifier", + "translation": "type identifier already exists: perhaps json contains both 'team_id' and 'channel_id'" + }, + { + "id": "model.group.update_at.app_error", + "translation": "invalid update at property for group" + }, + { + "id": "model.group_member.group_id.app_error", + "translation": "invalid group id property for group member" + }, + { + "id": "model.group_member.user_id.app_error", + "translation": "invalid user id property for group member" + }, + { + "id": "model.group_syncable.group_id.app_error", + "translation": "invalid group id property for group syncable" + }, + { + "id": "model.group_syncable.syncable_id.app_error", + "translation": "invalid syncable id for group syncable" + }, + { + "id": "store.sql_group.group_syncable_already_deleted", + "translation": "group syncable was already deleted" + }, + { + "id": "store.insert_error", + "translation": "insert error" + }, + { + "id": "model.group.delete_at.app_error", + "translation": "invalid delete at property for group" + }, + { + "id": "store.sql_group.no_rows", + "translation": "no matching group found" + }, + { + "id": "store.sql_group.no_rows_changed", + "translation": "no rows changed" + }, + { + "id": "store.sql_group.nothing_to_update", + "translation": "nothing to update for group syncable" + }, + { + "id": "store.select_error", + "translation": "select error" + }, + { + "id": "store.sql_group.unique_constraint", + "translation": "a group with that name already exists" + }, + { + "id": "store.sql_group.uniqueness_error", + "translation": "group member already exists" + }, + { + "id": "store.update_error", + "translation": "update error" + }, + { + "id": "ent.ldap.app_error", + "translation": "ldap interface was nil" } -] +] \ No newline at end of file diff --git a/model/channel.go b/model/channel.go index f8867babb0..16e48743cd 100644 --- a/model/channel.go +++ b/model/channel.go @@ -53,6 +53,13 @@ type Channel struct { Props map[string]interface{} `json:"props" db:"-"` } +type ChannelWithTeamData struct { + Channel + TeamDisplayName string `json:"team_display_name"` + TeamName string `json:"team_name"` + TeamUpdateAt int64 `json:"team_update_at"` +} + type ChannelPatch struct { DisplayName *string `json:"display_name"` Name *string `json:"name"` diff --git a/model/channel_list.go b/model/channel_list.go index 1b3bda46d4..b1db60ecc3 100644 --- a/model/channel_list.go +++ b/model/channel_list.go @@ -51,3 +51,45 @@ func ChannelSliceFromJson(data io.Reader) []*Channel { json.NewDecoder(data).Decode(&o) return o } + +type ChannelListWithTeamData []*ChannelWithTeamData + +func (o *ChannelListWithTeamData) ToJson() string { + if b, err := json.Marshal(o); err != nil { + return "[]" + } else { + return string(b) + } +} + +func (o *ChannelListWithTeamData) Etag() string { + + id := "0" + var t int64 = 0 + var delta int64 = 0 + + for _, v := range *o { + if v.LastPostAt > t { + t = v.LastPostAt + id = v.Id + } + + if v.UpdateAt > t { + t = v.UpdateAt + id = v.Id + } + + if v.TeamUpdateAt > t { + t = v.TeamUpdateAt + id = v.Id + } + } + + return Etag(id, t, delta, len(*o)) +} + +func ChannelListWithTeamDataFromJson(data io.Reader) *ChannelListWithTeamData { + var o *ChannelListWithTeamData + json.NewDecoder(data).Decode(&o) + return o +} diff --git a/model/client4.go b/model/client4.go index 3bb1947fe7..18e8933a26 100644 --- a/model/client4.go +++ b/model/client4.go @@ -409,6 +409,22 @@ func (c *Client4) GetTermsOfServiceRoute() string { return "/terms_of_service" } +func (c *Client4) GetGroupsRoute() string { + return "/groups" +} + +func (c *Client4) GetGroupRoute(groupID string) string { + return fmt.Sprintf("%s/%s", c.GetGroupsRoute(), groupID) +} + +func (c *Client4) GetGroupSyncableRoute(groupID, syncableID string, syncableType GroupSyncableType) string { + return fmt.Sprintf("%s/%ss/%s", c.GetGroupRoute(groupID), strings.ToLower(syncableType.String()), syncableID) +} + +func (c *Client4) GetGroupSyncablesRoute(groupID string, syncableType GroupSyncableType) string { + return fmt.Sprintf("%s/%ss", c.GetGroupRoute(groupID), strings.ToLower(syncableType.String())) +} + func (c *Client4) DoApiGet(url string, etag string) (*http.Response, *AppError) { return c.DoApiRequest(http.MethodGet, c.ApiUrl+url, "", etag) } @@ -1706,6 +1722,17 @@ func (c *Client4) RemoveTeamIcon(teamId string) (bool, *Response) { // Channel Section +// GetAllChannels get all the channels. Must be a system administrator. +func (c *Client4) GetAllChannels(page int, perPage int, etag string) (*ChannelListWithTeamData, *Response) { + query := fmt.Sprintf("?page=%v&per_page=%v", page, perPage) + r, err := c.DoApiGet(c.GetChannelsRoute()+query, etag) + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + return ChannelListWithTeamDataFromJson(r.Body), BuildResponse(r) +} + // CreateChannel creates a channel based on the provided channel struct. func (c *Client4) CreateChannel(channel *Channel) (*Channel, *Response) { r, err := c.DoApiPost(c.GetChannelsRoute(), channel.ToJson()) @@ -1870,6 +1897,16 @@ func (c *Client4) SearchChannels(teamId string, search *ChannelSearch) ([]*Chann return ChannelSliceFromJson(r.Body), BuildResponse(r) } +// SearchAllChannels search in all the channels. Must be a system administrator. +func (c *Client4) SearchAllChannels(search *ChannelSearch) (*ChannelListWithTeamData, *Response) { + r, err := c.DoApiPost(c.GetChannelsRoute()+"/search", search.ToJson()) + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + return ChannelListWithTeamDataFromJson(r.Body), BuildResponse(r) +} + // DeleteChannel deletes channel based on the provided channel id string. func (c *Client4) DeleteChannel(channelId string) (bool, *Response) { r, err := c.DoApiDelete(c.GetChannelRoute(channelId)) @@ -3066,6 +3103,45 @@ func (c *Client4) TestLdap() (bool, *Response) { return CheckStatusOK(r), BuildResponse(r) } +// GetLdapGroups retrieves the immediate child groups of the given parent group. +func (c *Client4) GetLdapGroups() ([]*Group, *Response) { + path := fmt.Sprintf("%s/groups", c.GetLdapRoute()) + + r, appErr := c.DoApiGet(path, "") + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + + return GroupsFromJson(r.Body), BuildResponse(r) +} + +// LinkLdapGroup creates or undeletes a Mattermost group and associates it to the given LDAP group DN. +func (c *Client4) LinkLdapGroup(dn string) (*Group, *Response) { + path := fmt.Sprintf("%s/groups/%s/link", c.GetLdapRoute(), dn) + + r, appErr := c.DoApiPost(path, "") + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + + return GroupFromJson(r.Body), BuildResponse(r) +} + +// UnlinkLdapGroup deletes the Mattermost group associated with the given LDAP group DN. +func (c *Client4) UnlinkLdapGroup(dn string) (*Group, *Response) { + path := fmt.Sprintf("%s/groups/%s/link", c.GetLdapRoute(), dn) + + r, appErr := c.DoApiDelete(path) + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + + return GroupFromJson(r.Body), BuildResponse(r) +} + // Audits Section // GetAudits returns a list of audits for the whole system. @@ -4030,3 +4106,71 @@ func (c *Client4) CreateTermsOfService(text, userId string) (*TermsOfService, *R defer closeBody(r) return TermsOfServiceFromJson(r.Body), BuildResponse(r) } + +func (c *Client4) GetGroup(groupID, etag string) (*Group, *Response) { + r, appErr := c.DoApiGet(c.GetGroupRoute(groupID), etag) + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + return GroupFromJson(r.Body), BuildResponse(r) +} + +func (c *Client4) PatchGroup(groupID string, patch *GroupPatch) (*Group, *Response) { + payload, _ := json.Marshal(patch) + r, appErr := c.DoApiPut(c.GetGroupRoute(groupID)+"/patch", string(payload)) + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + return GroupFromJson(r.Body), BuildResponse(r) +} + +func (c *Client4) LinkGroupSyncable(groupID, syncableID string, syncableType GroupSyncableType, patch *GroupSyncablePatch) (*GroupSyncable, *Response) { + payload, _ := json.Marshal(patch) + url := fmt.Sprintf("%s/link", c.GetGroupSyncableRoute(groupID, syncableID, syncableType)) + r, appErr := c.DoApiPost(url, string(payload)) + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + return GroupSyncableFromJson(r.Body), BuildResponse(r) +} + +func (c *Client4) UnlinkGroupSyncable(groupID, syncableID string, syncableType GroupSyncableType) *Response { + url := fmt.Sprintf("%s/link", c.GetGroupSyncableRoute(groupID, syncableID, syncableType)) + r, appErr := c.DoApiDelete(url) + if appErr != nil { + return BuildErrorResponse(r, appErr) + } + defer closeBody(r) + return BuildResponse(r) +} + +func (c *Client4) GetGroupSyncable(groupID, syncableID string, syncableType GroupSyncableType, etag string) (*GroupSyncable, *Response) { + r, appErr := c.DoApiGet(c.GetGroupSyncableRoute(groupID, syncableID, syncableType), etag) + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + return GroupSyncableFromJson(r.Body), BuildResponse(r) +} + +func (c *Client4) GetGroupSyncables(groupID string, syncableType GroupSyncableType, etag string) ([]*GroupSyncable, *Response) { + r, appErr := c.DoApiGet(c.GetGroupSyncablesRoute(groupID, syncableType), etag) + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + return GroupSyncablesFromJson(r.Body), BuildResponse(r) +} + +func (c *Client4) PatchGroupSyncable(groupID, syncableID string, syncableType GroupSyncableType, patch *GroupSyncablePatch) (*GroupSyncable, *Response) { + payload, _ := json.Marshal(patch) + r, appErr := c.DoApiPut(c.GetGroupSyncableRoute(groupID, syncableID, syncableType)+"/patch", string(payload)) + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + return GroupSyncableFromJson(r.Body), BuildResponse(r) +} diff --git a/model/cluster_message.go b/model/cluster_message.go index d02da3ee1f..c175bf0fcd 100644 --- a/model/cluster_message.go +++ b/model/cluster_message.go @@ -23,6 +23,7 @@ const ( CLUSTER_EVENT_CLEAR_SESSION_CACHE_FOR_USER = "clear_session_user" CLUSTER_EVENT_INVALIDATE_CACHE_FOR_ROLES = "inv_roles" CLUSTER_EVENT_INVALIDATE_CACHE_FOR_SCHEMES = "inv_schemes" + CLUSTER_EVENT_INVALIDATE_CACHE_FOR_GROUPS = "inv_groups" CLUSTER_SEND_BEST_EFFORT = "best_effort" CLUSTER_SEND_RELIABLE = "reliable" diff --git a/model/config.go b/model/config.go index 164d6b4f30..f015703f19 100644 --- a/model/config.go +++ b/model/config.go @@ -114,14 +114,16 @@ const ( SUPPORT_SETTINGS_DEFAULT_SUPPORT_EMAIL = "feedback@mattermost.com" SUPPORT_SETTINGS_DEFAULT_RE_ACCEPTANCE_PERIOD = 365 - LDAP_SETTINGS_DEFAULT_FIRST_NAME_ATTRIBUTE = "" - LDAP_SETTINGS_DEFAULT_LAST_NAME_ATTRIBUTE = "" - LDAP_SETTINGS_DEFAULT_EMAIL_ATTRIBUTE = "" - LDAP_SETTINGS_DEFAULT_USERNAME_ATTRIBUTE = "" - LDAP_SETTINGS_DEFAULT_NICKNAME_ATTRIBUTE = "" - LDAP_SETTINGS_DEFAULT_ID_ATTRIBUTE = "" - LDAP_SETTINGS_DEFAULT_POSITION_ATTRIBUTE = "" - LDAP_SETTINGS_DEFAULT_LOGIN_FIELD_NAME = "" + LDAP_SETTINGS_DEFAULT_FIRST_NAME_ATTRIBUTE = "" + LDAP_SETTINGS_DEFAULT_LAST_NAME_ATTRIBUTE = "" + LDAP_SETTINGS_DEFAULT_EMAIL_ATTRIBUTE = "" + LDAP_SETTINGS_DEFAULT_USERNAME_ATTRIBUTE = "" + LDAP_SETTINGS_DEFAULT_NICKNAME_ATTRIBUTE = "" + LDAP_SETTINGS_DEFAULT_ID_ATTRIBUTE = "" + LDAP_SETTINGS_DEFAULT_POSITION_ATTRIBUTE = "" + LDAP_SETTINGS_DEFAULT_LOGIN_FIELD_NAME = "" + LDAP_SETTINGS_DEFAULT_GROUP_DISPLAY_NAME_ATTRIBUTE = "" + LDAP_SETTINGS_DEFAULT_GROUP_ID_ATTRIBUTE = "" SAML_SETTINGS_DEFAULT_ID_ATTRIBUTE = "" SAML_SETTINGS_DEFAULT_FIRST_NAME_ATTRIBUTE = "" @@ -276,6 +278,7 @@ type ServiceSettings struct { EnableAPITeamDeletion *bool ExperimentalEnableHardenedMode *bool EnableEmailInvitations *bool + ExperimentalLdapGroupSync *bool } func (s *ServiceSettings) SetDefaults() { @@ -566,6 +569,10 @@ func (s *ServiceSettings) SetDefaults() { if s.ExperimentalEnableHardenedMode == nil { s.ExperimentalEnableHardenedMode = NewBool(false) } + + if s.ExperimentalLdapGroupSync == nil { + s.ExperimentalLdapGroupSync = NewBool(false) + } } type ClusterSettings struct { @@ -1322,7 +1329,12 @@ type LdapSettings struct { BindPassword *string // Filtering - UserFilter *string + UserFilter *string + GroupFilter *string + + // Group Mapping + GroupDisplayNameAttribute *string + GroupIdAttribute *string // User Mapping FirstNameAttribute *string @@ -1388,6 +1400,18 @@ func (s *LdapSettings) SetDefaults() { s.UserFilter = NewString("") } + if s.GroupFilter == nil { + s.GroupFilter = NewString("") + } + + if s.GroupDisplayNameAttribute == nil { + s.GroupDisplayNameAttribute = NewString(LDAP_SETTINGS_DEFAULT_GROUP_DISPLAY_NAME_ATTRIBUTE) + } + + if s.GroupIdAttribute == nil { + s.GroupIdAttribute = NewString(LDAP_SETTINGS_DEFAULT_GROUP_ID_ATTRIBUTE) + } + if s.FirstNameAttribute == nil { s.FirstNameAttribute = NewString(LDAP_SETTINGS_DEFAULT_FIRST_NAME_ATTRIBUTE) } diff --git a/model/group.go b/model/group.go new file mode 100644 index 0000000000..f0754848c8 --- /dev/null +++ b/model/group.go @@ -0,0 +1,135 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" + "net/http" +) + +const ( + GroupSourceLdap GroupSource = "ldap" + + GroupNameMaxLength = 64 + GroupSourceMaxLength = 64 + GroupDisplayNameMaxLength = 128 + GroupDescriptionMaxLength = 1024 + GroupRemoteIDMaxLength = 48 +) + +type GroupSource string + +var allGroupSources = []GroupSource{ + GroupSourceLdap, +} + +var groupSourcesRequiringRemoteID = []GroupSource{ + GroupSourceLdap, +} + +type Group struct { + Id string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Source GroupSource `json:"source"` + RemoteId string `json:"remote_id"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + HasSyncables bool `db:"-" json:"has_syncables"` +} + +type GroupPatch struct { + Name *string `json:"name"` + DisplayName *string `json:"display_name"` + Description *string `json:"description"` +} + +func (group *Group) Patch(patch *GroupPatch) { + if patch.Name != nil { + group.Name = *patch.Name + } + if patch.DisplayName != nil { + group.DisplayName = *patch.DisplayName + } + if patch.Description != nil { + group.Description = *patch.Description + } +} + +func (group *Group) IsValidForCreate() *AppError { + if l := len(group.Name); l == 0 || l > GroupNameMaxLength { + return NewAppError("Group.IsValidForCreate", "model.group.name.app_error", map[string]interface{}{"GroupNameMaxLength": GroupNameMaxLength}, "", http.StatusBadRequest) + } + + if l := len(group.DisplayName); l == 0 || l > GroupDisplayNameMaxLength { + return NewAppError("Group.IsValidForCreate", "model.group.display_name.app_error", map[string]interface{}{"GroupDisplayNameMaxLength": GroupDisplayNameMaxLength}, "", http.StatusBadRequest) + } + + if len(group.Description) > GroupDescriptionMaxLength { + return NewAppError("Group.IsValidForCreate", "model.group.description.app_error", map[string]interface{}{"GroupDescriptionMaxLength": GroupDescriptionMaxLength}, "", http.StatusBadRequest) + } + + isValidSource := false + for _, groupSource := range allGroupSources { + if group.Source == groupSource { + isValidSource = true + break + } + } + if !isValidSource { + return NewAppError("Group.IsValidForCreate", "model.group.source.app_error", nil, "", http.StatusBadRequest) + } + + if len(group.RemoteId) > GroupRemoteIDMaxLength || (len(group.RemoteId) == 0 && group.requiresRemoteId()) { + return NewAppError("Group.IsValidForCreate", "model.group.remote_id.app_error", nil, "", http.StatusBadRequest) + } + + return nil +} + +func (group *Group) requiresRemoteId() bool { + for _, groupSource := range groupSourcesRequiringRemoteID { + if groupSource == group.Source { + return true + } + } + return false +} + +func (group *Group) IsValidForUpdate() *AppError { + if len(group.Id) != 26 { + return NewAppError("Group.IsValidForUpdate", "model.group.id.app_error", nil, "", http.StatusBadRequest) + } + if group.CreateAt == 0 { + return NewAppError("Group.IsValidForUpdate", "model.group.create_at.app_error", nil, "", http.StatusBadRequest) + } + if group.UpdateAt == 0 { + return NewAppError("Group.IsValidForUpdate", "model.group.update_at.app_error", nil, "", http.StatusBadRequest) + } + if err := group.IsValidForCreate(); err != nil { + return err + } + return nil +} + +func GroupFromJson(data io.Reader) *Group { + var group *Group + json.NewDecoder(data).Decode(&group) + return group +} + +func GroupsFromJson(data io.Reader) []*Group { + var groups []*Group + json.NewDecoder(data).Decode(&groups) + return groups +} + +func GroupPatchFromJson(data io.Reader) *GroupPatch { + var groupPatch *GroupPatch + json.NewDecoder(data).Decode(&groupPatch) + return groupPatch +} diff --git a/model/group_member.go b/model/group_member.go new file mode 100644 index 0000000000..0f1a0baf01 --- /dev/null +++ b/model/group_member.go @@ -0,0 +1,23 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import "net/http" + +type GroupMember struct { + GroupId string `json:"group_id"` + UserId string `json:"user_id"` + CreateAt int64 `json:"create_at"` + DeleteAt int64 `json:"delete_at"` +} + +func (gm *GroupMember) IsValid() *AppError { + if !IsValidId(gm.GroupId) { + return NewAppError("GroupMember.IsValid", "model.group_member.group_id.app_error", nil, "", http.StatusBadRequest) + } + if !IsValidId(gm.UserId) { + return NewAppError("GroupMember.IsValid", "model.group_member.user_id.app_error", nil, "", http.StatusBadRequest) + } + return nil +} diff --git a/model/group_syncable.go b/model/group_syncable.go new file mode 100644 index 0000000000..841c854c6b --- /dev/null +++ b/model/group_syncable.go @@ -0,0 +1,169 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" +) + +type GroupSyncableType string + +const ( + GroupSyncableTypeTeam GroupSyncableType = "Team" + GroupSyncableTypeChannel GroupSyncableType = "Channel" +) + +func (gst GroupSyncableType) String() string { + return string(gst) +} + +type GroupSyncable struct { + GroupId string `json:"group_id"` + + // SyncableId represents the Id of the model that is being synced with the group, for example a ChannelId or + // TeamId. + SyncableId string `db:"-" json:"-"` + + CanLeave bool `db:"-" json:"can_leave"` + AutoAdd bool `json:"auto_add"` + CreateAt int64 `json:"create_at"` + DeleteAt int64 `json:"delete_at"` + UpdateAt int64 `json:"update_at"` + Type GroupSyncableType `db:"-" json:"-"` + + // Values joined in from the associated team and/or channel + ChannelDisplayName string `db:"-" json:"-"` + TeamDisplayName string `db:"-" json:"-"` + TeamType string `db:"-" json:"-"` + ChannelType string `db:"-" json:"-"` + TeamID string `db:"-" json:"-"` +} + +func (syncable *GroupSyncable) IsValid() *AppError { + if !IsValidId(syncable.GroupId) { + return NewAppError("GroupSyncable.SyncableIsValid", "model.group_syncable.group_id.app_error", nil, "", http.StatusBadRequest) + } + if !IsValidId(syncable.SyncableId) { + return NewAppError("GroupSyncable.SyncableIsValid", "model.group_syncable.syncable_id.app_error", nil, "", http.StatusBadRequest) + } + // TODO: Add this validation check for phase 2 of LDAP group sync. + // if syncable.AutoAdd == false && syncable.CanLeave == false { + // return NewAppError("GroupSyncable.SyncableIsValid", "model.group_syncable.invalid_state", nil, "", http.StatusBadRequest) + // } + return nil +} + +func (syncable *GroupSyncable) UnmarshalJSON(b []byte) error { + var kvp map[string]interface{} + err := json.Unmarshal(b, &kvp) + if err != nil { + return err + } + for key, value := range kvp { + switch key { + case "team_id": + syncable.SyncableId = value.(string) + syncable.Type = GroupSyncableTypeTeam + case "channel_id": + syncable.SyncableId = value.(string) + syncable.Type = GroupSyncableTypeChannel + case "group_id": + syncable.GroupId = value.(string) + case "can_leave": + syncable.CanLeave = value.(bool) + case "auto_add": + syncable.AutoAdd = value.(bool) + default: + } + } + return nil +} + +func (syncable *GroupSyncable) MarshalJSON() ([]byte, error) { + type Alias GroupSyncable + + switch syncable.Type { + case GroupSyncableTypeTeam: + return json.Marshal(&struct { + TeamID string `json:"team_id"` + TeamDisplayName string `json:"team_display_name,omitempty"` + TeamType string `json:"team_type,omitempty"` + *Alias + }{ + TeamDisplayName: syncable.TeamDisplayName, + TeamType: syncable.TeamType, + TeamID: syncable.SyncableId, + Alias: (*Alias)(syncable), + }) + case GroupSyncableTypeChannel: + return json.Marshal(&struct { + ChannelID string `json:"channel_id"` + ChannelDisplayName string `json:"channel_display_name,omitempty"` + ChannelType string `json:"channel_type,omitempty"` + + TeamID string `json:"team_id,omitempty"` + TeamDisplayName string `json:"team_display_name,omitempty"` + TeamType string `json:"team_type,omitempty"` + + *Alias + }{ + ChannelID: syncable.SyncableId, + ChannelDisplayName: syncable.ChannelDisplayName, + ChannelType: syncable.ChannelType, + + TeamID: syncable.TeamID, + TeamDisplayName: syncable.TeamDisplayName, + TeamType: syncable.TeamType, + + Alias: (*Alias)(syncable), + }) + default: + return nil, &json.MarshalerError{ + Err: fmt.Errorf("unknown syncable type: %s", syncable.Type), + } + } +} + +type GroupSyncablePatch struct { + CanLeave *bool `json:"can_leave"` + AutoAdd *bool `json:"auto_add"` +} + +func (syncable *GroupSyncable) Patch(patch *GroupSyncablePatch) { + // TODO: Add this validation check for phase 2 of LDAP group sync. + // if patch.CanLeave != nil { + // syncable.CanLeave = *patch.CanLeave + // } + if patch.AutoAdd != nil { + syncable.AutoAdd = *patch.AutoAdd + } +} + +type UserTeamIDPair struct { + UserID string + TeamID string +} + +type UserChannelIDPair struct { + UserID string + ChannelID string +} + +func GroupSyncableFromJson(data io.Reader) *GroupSyncable { + groupSyncable := &GroupSyncable{} + bodyBytes, _ := ioutil.ReadAll(data) + json.Unmarshal(bodyBytes, groupSyncable) + return groupSyncable +} + +func GroupSyncablesFromJson(data io.Reader) []*GroupSyncable { + groupSyncables := []*GroupSyncable{} + bodyBytes, _ := ioutil.ReadAll(data) + json.Unmarshal(bodyBytes, &groupSyncables) + return groupSyncables +} diff --git a/model/license.go b/model/license.go index e2ef3b0d58..7ab21debc0 100644 --- a/model/license.go +++ b/model/license.go @@ -42,6 +42,7 @@ type Customer struct { type Features struct { Users *int `json:"users"` LDAP *bool `json:"ldap"` + LDAPGroups *bool `json:"ldap_groups"` MFA *bool `json:"mfa"` GoogleOAuth *bool `json:"google_oauth"` Office365OAuth *bool `json:"office365_oauth"` @@ -66,6 +67,7 @@ type Features struct { func (f *Features) ToMap() map[string]interface{} { return map[string]interface{}{ "ldap": *f.LDAP, + "ldap_groups": *f.LDAPGroups, "mfa": *f.MFA, "google": *f.GoogleOAuth, "office365": *f.Office365OAuth, @@ -96,6 +98,10 @@ func (f *Features) SetDefaults() { f.LDAP = NewBool(*f.FutureFeatures) } + if f.LDAPGroups == nil { + f.LDAPGroups = NewBool(*f.FutureFeatures) + } + if f.MFA == nil { f.MFA = NewBool(*f.FutureFeatures) } diff --git a/model/license_test.go b/model/license_test.go index b454734326..d550c327f3 100644 --- a/model/license_test.go +++ b/model/license_test.go @@ -15,6 +15,7 @@ func TestLicenseFeaturesToMap(t *testing.T) { m := f.ToMap() CheckTrue(t, m["ldap"].(bool)) + CheckTrue(t, m["ldap_groups"].(bool)) CheckTrue(t, m["mfa"].(bool)) CheckTrue(t, m["google"].(bool)) CheckTrue(t, m["office365"].(bool)) @@ -37,6 +38,7 @@ func TestLicenseFeaturesSetDefaults(t *testing.T) { CheckInt(t, *f.Users, 0) CheckTrue(t, *f.LDAP) + CheckTrue(t, *f.LDAPGroups) CheckTrue(t, *f.MFA) CheckTrue(t, *f.GoogleOAuth) CheckTrue(t, *f.Office365OAuth) @@ -58,6 +60,7 @@ func TestLicenseFeaturesSetDefaults(t *testing.T) { *f.Users = 300 *f.FutureFeatures = false *f.LDAP = true + *f.LDAPGroups = true *f.MFA = true *f.GoogleOAuth = true *f.Office365OAuth = true @@ -76,6 +79,7 @@ func TestLicenseFeaturesSetDefaults(t *testing.T) { CheckInt(t, *f.Users, 300) CheckTrue(t, *f.LDAP) + CheckTrue(t, *f.LDAPGroups) CheckTrue(t, *f.MFA) CheckTrue(t, *f.GoogleOAuth) CheckTrue(t, *f.Office365OAuth) @@ -159,6 +163,7 @@ func TestLicenseToFromJson(t *testing.T) { CheckInt(t, *f1.Users, *f.Users) CheckBool(t, *f1.LDAP, *f.LDAP) + CheckBool(t, *f1.LDAPGroups, *f.LDAPGroups) CheckBool(t, *f1.MFA, *f.MFA) CheckBool(t, *f1.GoogleOAuth, *f.GoogleOAuth) CheckBool(t, *f1.Office365OAuth, *f.Office365OAuth) diff --git a/store/layered_store.go b/store/layered_store.go index 4ea81f6ede..a9ed76ed82 100644 --- a/store/layered_store.go +++ b/store/layered_store.go @@ -29,6 +29,7 @@ type LayeredStore struct { LocalCacheLayer *LocalCacheSupplier RedisLayer *RedisSupplier LayerChainHead LayeredStoreSupplier + GroupStore GroupStore } func NewLayeredStore(db LayeredStoreDatabaseLayer, metrics einterfaces.MetricsInterface, cluster einterfaces.ClusterInterface) Store { @@ -41,6 +42,7 @@ func NewLayeredStore(db LayeredStoreDatabaseLayer, metrics einterfaces.MetricsIn store.ReactionStore = &LayeredReactionStore{store} store.RoleStore = &LayeredRoleStore{store} store.SchemeStore = &LayeredSchemeStore{store} + store.GroupStore = &LayeredGroupStore{store} // Setup the chain if ENABLE_EXPERIMENTAL_REDIS { @@ -181,6 +183,10 @@ func (s *LayeredStore) Scheme() SchemeStore { return s.SchemeStore } +func (s *LayeredStore) Group() GroupStore { + return s.GroupStore +} + func (s *LayeredStore) MarkSystemRanUnitTests() { s.DatabaseLayer.MarkSystemRanUnitTests() } @@ -333,3 +339,115 @@ func (s *LayeredSchemeStore) PermanentDeleteAll() StoreChannel { return supplier.SchemePermanentDeleteAll(s.TmpContext) }) } + +type LayeredGroupStore struct { + *LayeredStore +} + +func (s *LayeredGroupStore) Create(group *model.Group) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupCreate(s.TmpContext, group) + }) +} + +func (s *LayeredGroupStore) Get(groupID string) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupGet(s.TmpContext, groupID) + }) +} + +func (s *LayeredGroupStore) GetByRemoteID(remoteID string, groupSource model.GroupSource) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupGetByRemoteID(s.TmpContext, remoteID, groupSource) + }) +} + +func (s *LayeredGroupStore) GetAllBySource(groupSource model.GroupSource) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupGetAllBySource(s.TmpContext, groupSource) + }) +} + +func (s *LayeredGroupStore) Update(group *model.Group) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupUpdate(s.TmpContext, group) + }) +} + +func (s *LayeredGroupStore) Delete(groupID string) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupDelete(s.TmpContext, groupID) + }) +} + +func (s *LayeredGroupStore) GetMemberUsers(groupID string) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupGetMemberUsers(s.TmpContext, groupID) + }) +} + +func (s *LayeredGroupStore) GetMemberUsersPage(groupID string, offset int, limit int) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupGetMemberUsersPage(s.TmpContext, groupID, offset, limit) + }) +} + +func (s *LayeredGroupStore) GetMemberCount(groupID string) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupGetMemberCount(s.TmpContext, groupID) + }) +} + +func (s *LayeredGroupStore) CreateOrRestoreMember(groupID string, userID string) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupCreateOrRestoreMember(s.TmpContext, groupID, userID) + }) +} + +func (s *LayeredGroupStore) DeleteMember(groupID string, userID string) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupDeleteMember(s.TmpContext, groupID, userID) + }) +} + +func (s *LayeredGroupStore) CreateGroupSyncable(groupSyncable *model.GroupSyncable) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupCreateGroupSyncable(s.TmpContext, groupSyncable) + }) +} + +func (s *LayeredGroupStore) GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupGetGroupSyncable(s.TmpContext, groupID, syncableID, syncableType) + }) +} + +func (s *LayeredGroupStore) GetAllGroupSyncablesByGroupId(groupID string, syncableType model.GroupSyncableType) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupGetAllGroupSyncablesByGroup(s.TmpContext, groupID, syncableType) + }) +} + +func (s *LayeredGroupStore) UpdateGroupSyncable(groupSyncable *model.GroupSyncable) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupUpdateGroupSyncable(s.TmpContext, groupSyncable) + }) +} + +func (s *LayeredGroupStore) DeleteGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.GroupDeleteGroupSyncable(s.TmpContext, groupID, syncableID, syncableType) + }) +} + +func (s *LayeredGroupStore) PendingAutoAddTeamMembers(minGroupMembersCreateAt int64) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.PendingAutoAddTeamMembers(s.TmpContext, minGroupMembersCreateAt) + }) +} + +func (s *LayeredGroupStore) PendingAutoAddChannelMembers(minGroupMembersCreateAt int64) StoreChannel { + return s.RunQuery(func(supplier LayeredStoreSupplier) *LayeredStoreSupplierResult { + return supplier.PendingAutoAddChannelMembers(s.TmpContext, minGroupMembersCreateAt) + }) +} diff --git a/store/layered_store_supplier.go b/store/layered_store_supplier.go index 45ec00068f..72ff0bea38 100644 --- a/store/layered_store_supplier.go +++ b/store/layered_store_supplier.go @@ -46,4 +46,27 @@ type LayeredStoreSupplier interface { SchemeDelete(ctx context.Context, schemeId string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult SchemeGetAllPage(ctx context.Context, scope string, offset int, limit int, hints ...LayeredStoreHint) *LayeredStoreSupplierResult SchemePermanentDeleteAll(ctx context.Context, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + + // Groups + GroupCreate(ctx context.Context, group *model.Group, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + GroupGet(ctx context.Context, groupID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + GroupGetByRemoteID(ctx context.Context, remoteID string, groupSource model.GroupSource, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + GroupGetAllBySource(ctx context.Context, groupSource model.GroupSource, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + GroupUpdate(ctx context.Context, group *model.Group, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + GroupDelete(ctx context.Context, groupID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + + GroupGetMemberUsers(ctx context.Context, groupID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + GroupGetMemberUsersPage(ctx context.Context, groupID string, offset int, limit int, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + GroupGetMemberCount(ctx context.Context, groupID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + GroupCreateOrRestoreMember(ctx context.Context, groupID string, userID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + GroupDeleteMember(ctx context.Context, groupID string, userID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + + GroupCreateGroupSyncable(ctx context.Context, groupSyncable *model.GroupSyncable, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + GroupGetGroupSyncable(ctx context.Context, groupID string, syncableID string, syncableType model.GroupSyncableType, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + GroupGetAllGroupSyncablesByGroup(ctx context.Context, groupID string, syncableType model.GroupSyncableType, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + GroupUpdateGroupSyncable(ctx context.Context, groupSyncable *model.GroupSyncable, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + GroupDeleteGroupSyncable(ctx context.Context, groupID string, syncableID string, syncableType model.GroupSyncableType, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + + PendingAutoAddTeamMembers(ctx context.Context, minGroupMembersCreateAt int64, hints ...LayeredStoreHint) *LayeredStoreSupplierResult + PendingAutoAddChannelMembers(ctx context.Context, minGroupMembersCreateAt int64, hints ...LayeredStoreHint) *LayeredStoreSupplierResult } diff --git a/store/local_cache_supplier.go b/store/local_cache_supplier.go index 3f87e7c5f9..3d24a86fda 100644 --- a/store/local_cache_supplier.go +++ b/store/local_cache_supplier.go @@ -21,6 +21,9 @@ const ( SCHEME_CACHE_SIZE = 20000 SCHEME_CACHE_SEC = 30 * 60 + GROUP_CACHE_SIZE = 20000 + GROUP_CACHE_SEC = 30 * 60 + CLEAR_CACHE_MESSAGE_DATA = "" ) @@ -31,6 +34,7 @@ type LocalCacheSupplier struct { schemeCache *utils.Cache metrics einterfaces.MetricsInterface cluster einterfaces.ClusterInterface + groupCache *utils.Cache } // Caching Interface @@ -50,6 +54,7 @@ func NewLocalCacheSupplier(metrics einterfaces.MetricsInterface, cluster einterf reactionCache: utils.NewLruWithParams(REACTION_CACHE_SIZE, "Reaction", REACTION_CACHE_SEC, model.CLUSTER_EVENT_INVALIDATE_CACHE_FOR_REACTIONS), roleCache: utils.NewLruWithParams(ROLE_CACHE_SIZE, "Role", ROLE_CACHE_SEC, model.CLUSTER_EVENT_INVALIDATE_CACHE_FOR_ROLES), schemeCache: utils.NewLruWithParams(SCHEME_CACHE_SIZE, "Scheme", SCHEME_CACHE_SEC, model.CLUSTER_EVENT_INVALIDATE_CACHE_FOR_SCHEMES), + groupCache: utils.NewLruWithParams(GROUP_CACHE_SIZE, "Group", GROUP_CACHE_SEC, model.CLUSTER_EVENT_INVALIDATE_CACHE_FOR_GROUPS), metrics: metrics, cluster: cluster, } @@ -57,6 +62,7 @@ func NewLocalCacheSupplier(metrics einterfaces.MetricsInterface, cluster einterf if cluster != nil { cluster.RegisterClusterMessageHandler(model.CLUSTER_EVENT_INVALIDATE_CACHE_FOR_REACTIONS, supplier.handleClusterInvalidateReaction) cluster.RegisterClusterMessageHandler(model.CLUSTER_EVENT_INVALIDATE_CACHE_FOR_ROLES, supplier.handleClusterInvalidateRole) + cluster.RegisterClusterMessageHandler(model.CLUSTER_EVENT_INVALIDATE_CACHE_FOR_GROUPS, supplier.handleClusterInvalidateGroup) } return supplier diff --git a/store/local_cache_supplier_groups.go b/store/local_cache_supplier_groups.go new file mode 100644 index 0000000000..4bedbb7cca --- /dev/null +++ b/store/local_cache_supplier_groups.go @@ -0,0 +1,102 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "context" + + "github.com/mattermost/mattermost-server/model" +) + +func (s *LocalCacheSupplier) handleClusterInvalidateGroup(msg *model.ClusterMessage) { + if msg.Data == CLEAR_CACHE_MESSAGE_DATA { + s.groupCache.Purge() + } else { + s.groupCache.Remove(msg.Data) + } +} + +func (s *LocalCacheSupplier) GroupCreate(ctx context.Context, group *model.Group, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().GroupCreate(ctx, group, hints...) +} + +func (s *LocalCacheSupplier) GroupGet(ctx context.Context, groupID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + if result := s.doStandardReadCache(ctx, s.groupCache, groupID, hints...); result != nil { + return result + } + + result := s.Next().GroupGet(ctx, groupID, hints...) + + s.doStandardAddToCache(ctx, s.groupCache, groupID, result, hints...) + + return result +} + +func (s *LocalCacheSupplier) GroupGetByRemoteID(ctx context.Context, remoteID string, groupSource model.GroupSource, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().GroupGetByRemoteID(ctx, remoteID, groupSource, hints...) +} + +func (s *LocalCacheSupplier) GroupGetAllBySource(ctx context.Context, groupSource model.GroupSource, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().GroupGetAllBySource(ctx, groupSource, hints...) +} + +func (s *LocalCacheSupplier) GroupUpdate(ctx context.Context, group *model.Group, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + defer s.doInvalidateCacheCluster(s.groupCache, group.Id) + return s.Next().GroupUpdate(ctx, group, hints...) +} + +func (s *LocalCacheSupplier) GroupDelete(ctx context.Context, groupID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + defer s.doInvalidateCacheCluster(s.groupCache, groupID) + defer s.doClearCacheCluster(s.groupCache) + + return s.Next().GroupDelete(ctx, groupID, hints...) +} + +func (s *LocalCacheSupplier) GroupGetMemberUsers(ctx context.Context, groupID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().GroupGetMemberUsers(ctx, groupID, hints...) +} + +func (s *LocalCacheSupplier) GroupGetMemberUsersPage(ctx context.Context, groupID string, offset int, limit int, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().GroupGetMemberUsersPage(ctx, groupID, offset, limit, hints...) +} + +func (s *LocalCacheSupplier) GroupGetMemberCount(ctx context.Context, groupID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().GroupGetMemberCount(ctx, groupID, hints...) +} + +func (s *LocalCacheSupplier) GroupCreateOrRestoreMember(ctx context.Context, groupID string, userID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().GroupCreateOrRestoreMember(ctx, groupID, userID, hints...) +} + +func (s *LocalCacheSupplier) GroupDeleteMember(ctx context.Context, groupID string, userID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().GroupDeleteMember(ctx, groupID, userID, hints...) +} + +func (s *LocalCacheSupplier) GroupCreateGroupSyncable(ctx context.Context, groupSyncable *model.GroupSyncable, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().GroupCreateGroupSyncable(ctx, groupSyncable, hints...) +} + +func (s *LocalCacheSupplier) GroupGetGroupSyncable(ctx context.Context, groupID string, syncableID string, syncableType model.GroupSyncableType, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().GroupGetGroupSyncable(ctx, groupID, syncableID, syncableType, hints...) +} + +func (s *LocalCacheSupplier) GroupGetAllGroupSyncablesByGroup(ctx context.Context, groupID string, syncableType model.GroupSyncableType, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().GroupGetAllGroupSyncablesByGroup(ctx, groupID, syncableType, hints...) +} + +func (s *LocalCacheSupplier) GroupUpdateGroupSyncable(ctx context.Context, groupSyncable *model.GroupSyncable, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().GroupUpdateGroupSyncable(ctx, groupSyncable, hints...) +} + +func (s *LocalCacheSupplier) GroupDeleteGroupSyncable(ctx context.Context, groupID string, syncableID string, syncableType model.GroupSyncableType, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().GroupDeleteGroupSyncable(ctx, groupID, syncableID, syncableType, hints...) +} + +func (s *LocalCacheSupplier) PendingAutoAddTeamMembers(ctx context.Context, minGroupMembersCreateAt int64, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().PendingAutoAddTeamMembers(ctx, minGroupMembersCreateAt, hints...) +} + +func (s *LocalCacheSupplier) PendingAutoAddChannelMembers(ctx context.Context, minGroupMembersCreateAt int64, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + return s.Next().PendingAutoAddChannelMembers(ctx, minGroupMembersCreateAt, hints...) +} diff --git a/store/local_cache_supplier_roles.go b/store/local_cache_supplier_roles.go index 41f88a216e..6d8c61a5df 100644 --- a/store/local_cache_supplier_roles.go +++ b/store/local_cache_supplier_roles.go @@ -18,7 +18,7 @@ func (s *LocalCacheSupplier) handleClusterInvalidateRole(msg *model.ClusterMessa } func (s *LocalCacheSupplier) RoleSave(ctx context.Context, role *model.Role, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { - if len(role.Id) != 0 { + if len(role.Name) != 0 { defer s.doInvalidateCacheCluster(s.roleCache, role.Name) } return s.Next().RoleSave(ctx, role, hints...) diff --git a/store/redis_supplier_groups.go b/store/redis_supplier_groups.go new file mode 100644 index 0000000000..ee8bcb04ae --- /dev/null +++ b/store/redis_supplier_groups.go @@ -0,0 +1,100 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "context" + + "github.com/mattermost/mattermost-server/model" +) + +func (s *RedisSupplier) GroupCreate(ctx context.Context, group *model.Group, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupCreate(ctx, group, hints...) +} + +func (s *RedisSupplier) GroupGet(ctx context.Context, groupID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupGet(ctx, groupID, hints...) +} + +func (s *RedisSupplier) GroupGetByRemoteID(ctx context.Context, remoteID string, groupSource model.GroupSource, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupGetByRemoteID(ctx, remoteID, groupSource, hints...) +} + +func (s *RedisSupplier) GroupGetAllBySource(ctx context.Context, groupSource model.GroupSource, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupGetAllBySource(ctx, groupSource, hints...) +} + +func (s *RedisSupplier) GroupUpdate(ctx context.Context, group *model.Group, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupUpdate(ctx, group, hints...) +} + +func (s *RedisSupplier) GroupDelete(ctx context.Context, groupID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupDelete(ctx, groupID, hints...) +} + +func (s *RedisSupplier) GroupGetMemberUsers(ctx context.Context, groupID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupGetMemberUsers(ctx, groupID, hints...) +} + +func (s *RedisSupplier) GroupGetMemberUsersPage(ctx context.Context, groupID string, offset int, limit int, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupGetMemberUsersPage(ctx, groupID, offset, limit, hints...) +} + +func (s *RedisSupplier) GroupGetMemberCount(ctx context.Context, groupID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupGetMemberCount(ctx, groupID, hints...) +} + +func (s *RedisSupplier) GroupCreateOrRestoreMember(ctx context.Context, groupID string, userID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupCreateOrRestoreMember(ctx, groupID, userID, hints...) +} + +func (s *RedisSupplier) GroupDeleteMember(ctx context.Context, groupID string, userID string, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupDeleteMember(ctx, groupID, userID, hints...) +} + +func (s *RedisSupplier) GroupCreateGroupSyncable(ctx context.Context, groupSyncable *model.GroupSyncable, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupCreateGroupSyncable(ctx, groupSyncable, hints...) +} + +func (s *RedisSupplier) GroupGetGroupSyncable(ctx context.Context, groupID string, syncableID string, syncableType model.GroupSyncableType, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupGetGroupSyncable(ctx, groupID, syncableID, syncableType, hints...) +} + +func (s *RedisSupplier) GroupGetAllGroupSyncablesByGroup(ctx context.Context, groupID string, syncableType model.GroupSyncableType, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupGetAllGroupSyncablesByGroup(ctx, groupID, syncableType, hints...) +} + +func (s *RedisSupplier) GroupUpdateGroupSyncable(ctx context.Context, groupSyncable *model.GroupSyncable, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupUpdateGroupSyncable(ctx, groupSyncable, hints...) +} + +func (s *RedisSupplier) GroupDeleteGroupSyncable(ctx context.Context, groupID string, syncableID string, syncableType model.GroupSyncableType, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().GroupDeleteGroupSyncable(ctx, groupID, syncableID, syncableType, hints...) +} + +func (s *RedisSupplier) PendingAutoAddTeamMembers(ctx context.Context, minGroupMembersCreateAt int64, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().PendingAutoAddTeamMembers(ctx, minGroupMembersCreateAt, hints...) +} + +func (s *RedisSupplier) PendingAutoAddChannelMembers(ctx context.Context, minGroupMembersCreateAt int64, hints ...LayeredStoreHint) *LayeredStoreSupplierResult { + // TODO: Redis caching. + return s.Next().PendingAutoAddChannelMembers(ctx, minGroupMembersCreateAt, hints...) +} diff --git a/store/sqlstore/channel_store.go b/store/sqlstore/channel_store.go index f934169f32..e82f0bd277 100644 --- a/store/sqlstore/channel_store.go +++ b/store/sqlstore/channel_store.go @@ -955,6 +955,27 @@ func (s SqlChannelStore) GetChannels(teamId string, userId string, includeDelete }) } +func (s SqlChannelStore) GetAllChannels(offset int, limit int, includeDeleted bool) store.StoreChannel { + return store.Do(func(result *store.StoreResult) { + deleteFilter := "AND c.DeleteAt = 0" + if includeDeleted { + deleteFilter = "" + } + + query := "SELECT c.*, Teams.DisplayName AS TeamDisplayName, Teams.Name AS TeamName, Teams.UpdateAt as TeamUpdateAt FROM Channels AS c JOIN Teams ON Teams.Id = c.TeamId WHERE (c.Type = 'P' OR c.Type = 'O') " + deleteFilter + " ORDER BY c.DisplayName, Teams.DisplayName LIMIT :Limit OFFSET :Offset" + + data := &model.ChannelListWithTeamData{} + _, err := s.GetReplica().Select(data, query, map[string]interface{}{"Limit": limit, "Offset": offset}) + + if err != nil { + result.Err = model.NewAppError("SqlChannelStore.GetAllChannels", "store.sql_channel.get_all_channels.get.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + result.Data = data + }) +} + func (s SqlChannelStore) GetMoreChannels(teamId string, userId string, offset int, limit int) store.StoreChannel { return store.Do(func(result *store.StoreResult) { data := &model.ChannelList{} @@ -2072,6 +2093,36 @@ func (s SqlChannelStore) SearchInTeam(teamId string, term string, includeDeleted }) } +func (s SqlChannelStore) SearchAllChannels(term string, includeDeleted bool) store.StoreChannel { + return store.Do(func(result *store.StoreResult) { + parameters := map[string]interface{}{} + deleteFilter := "AND c.DeleteAt = 0" + if includeDeleted { + deleteFilter = "" + } + searchQuery := `SELECT c.*, t.DisplayName AS TeamDisplayName, t.Name AS TeamName, t.UpdateAt as TeamUpdateAt FROM Channels AS c JOIN Teams AS t ON t.Id = c.TeamId WHERE (c.Type = 'P' OR c.Type = 'O') ` + deleteFilter + ` SEARCH_CLAUSE ORDER BY c.DisplayName, t.DisplayName LIMIT 100` + + likeClause, likeTerm := s.buildLIKEClause(term, "c.Name, c.DisplayName, c.Purpose") + if likeTerm == "" { + // If the likeTerm is empty after preparing, then don't bother searching. + searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", "", 1) + } else { + parameters["LikeTerm"] = likeTerm + fulltextClause, fulltextTerm := s.buildFulltextClause(term, "c.Name, c.DisplayName, c.Purpose") + parameters["FulltextTerm"] = fulltextTerm + searchQuery = strings.Replace(searchQuery, "SEARCH_CLAUSE", "AND ("+likeClause+" OR "+fulltextClause+")", 1) + } + + var channels model.ChannelListWithTeamData + + if _, err := s.GetReplica().Select(&channels, searchQuery, parameters); err != nil { + result.Err = model.NewAppError("SqlChannelStore.Search", "store.sql_channel.search.app_error", nil, "term="+term+", "+", "+err.Error(), http.StatusInternalServerError) + } + + result.Data = &channels + }) +} + func (s SqlChannelStore) SearchMore(userId string, teamId string, term string) store.StoreChannel { return store.Do(func(result *store.StoreResult) { *result = s.performSearch(` diff --git a/store/sqlstore/group_store_test.go b/store/sqlstore/group_store_test.go new file mode 100644 index 0000000000..117f4c5bad --- /dev/null +++ b/store/sqlstore/group_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost-server/store/storetest" +) + +func TestGroupStore(t *testing.T) { + StoreTest(t, storetest.TestGroupStore) +} diff --git a/store/sqlstore/group_supplier.go b/store/sqlstore/group_supplier.go new file mode 100644 index 0000000000..db85187a60 --- /dev/null +++ b/store/sqlstore/group_supplier.go @@ -0,0 +1,771 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package sqlstore + +import ( + "context" + "database/sql" + "fmt" + "net/http" + + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/store" +) + +type groupTeam struct { + model.GroupSyncable + TeamId string `db:"TeamId"` +} + +type groupChannel struct { + model.GroupSyncable + ChannelId string `db:"ChannelId"` +} + +type groupTeamJoin struct { + groupTeam + TeamDisplayName string `db:"TeamDisplayName"` + TeamType string `db:"TeamType"` +} + +type groupChannelJoin struct { + groupChannel + ChannelDisplayName string `db:"ChannelDisplayName"` + TeamDisplayName string `db:"TeamDisplayName"` + TeamType string `db:"TeamType"` + ChannelType string `db:"ChannelType"` + TeamID string `db:"TeamId"` +} + +func initSqlSupplierGroups(sqlStore SqlStore) { + for _, db := range sqlStore.GetAllConns() { + groups := db.AddTableWithName(model.Group{}, "UserGroups").SetKeys(false, "Id") + groups.ColMap("Id").SetMaxSize(26) + groups.ColMap("Name").SetMaxSize(model.GroupNameMaxLength).SetUnique(true) + groups.ColMap("DisplayName").SetMaxSize(model.GroupDisplayNameMaxLength) + groups.ColMap("Description").SetMaxSize(model.GroupDescriptionMaxLength) + groups.ColMap("Source").SetMaxSize(model.GroupSourceMaxLength) + groups.ColMap("RemoteId").SetMaxSize(model.GroupRemoteIDMaxLength) + groups.SetUniqueTogether("Source", "RemoteId") + + groupMembers := db.AddTableWithName(model.GroupMember{}, "GroupMembers").SetKeys(false, "GroupId", "UserId") + groupMembers.ColMap("GroupId").SetMaxSize(26) + groupMembers.ColMap("UserId").SetMaxSize(26) + + groupTeams := db.AddTableWithName(groupTeam{}, "GroupTeams").SetKeys(false, "GroupId", "TeamId") + groupTeams.ColMap("GroupId").SetMaxSize(26) + groupTeams.ColMap("TeamId").SetMaxSize(26) + + groupChannels := db.AddTableWithName(groupChannel{}, "GroupChannels").SetKeys(false, "GroupId", "ChannelId") + groupChannels.ColMap("GroupId").SetMaxSize(26) + groupChannels.ColMap("ChannelId").SetMaxSize(26) + } +} + +func (s *SqlSupplier) CreateIndexesIfNotExistsGroups() { + s.CreateIndexIfNotExists("idx_groupmembers_create_at", "GroupMembers", "CreateAt") + s.CreateIndexIfNotExists("idx_usergroups_remote_id", "UserGroups", "RemoteId") + s.CreateIndexIfNotExists("idx_usergroups_delete_at", "UserGroups", "DeleteAt") +} + +func (s *SqlSupplier) GroupCreate(ctx context.Context, group *model.Group, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + if len(group.Id) != 0 { + result.Err = model.NewAppError("SqlGroupStore.GroupCreate", "model.group.id.app_error", nil, "", http.StatusBadRequest) + return result + } + + if err := group.IsValidForCreate(); err != nil { + result.Err = err + return result + } + + group.Id = model.NewId() + group.CreateAt = model.GetMillis() + group.UpdateAt = group.CreateAt + + if err := s.GetMaster().Insert(group); err != nil { + if IsUniqueConstraintError(err, []string{"Name", "groups_name_key"}) { + result.Err = model.NewAppError("SqlGroupStore.GroupCreate", "store.sql_group.unique_constraint", nil, err.Error(), http.StatusInternalServerError) + } else { + result.Err = model.NewAppError("SqlGroupStore.GroupCreate", "store.insert_error", nil, err.Error(), http.StatusInternalServerError) + } + return result + } + + result.Data = group + return result +} + +func (s *SqlSupplier) GroupGet(ctx context.Context, groupId string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + var group *model.Group + if err := s.GetReplica().SelectOne(&group, "SELECT * from UserGroups WHERE Id = :Id", map[string]interface{}{"Id": groupId}); err != nil { + if err == sql.ErrNoRows { + result.Err = model.NewAppError("SqlGroupStore.GroupGet", "store.sql_group.no_rows", nil, err.Error(), http.StatusNotFound) + } else { + result.Err = model.NewAppError("SqlGroupStore.GroupGet", "store.select_error", nil, err.Error(), http.StatusInternalServerError) + } + return result + } + + result.Data = group + return result +} + +func (s *SqlSupplier) GroupGetByRemoteID(ctx context.Context, remoteID string, groupSource model.GroupSource, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + var group *model.Group + if err := s.GetReplica().SelectOne(&group, "SELECT * from UserGroups WHERE RemoteId = :RemoteId AND Source = :Source", map[string]interface{}{"RemoteId": remoteID, "Source": groupSource}); err != nil { + if err == sql.ErrNoRows { + result.Err = model.NewAppError("SqlGroupStore.GroupGetByRemoteID", "store.sql_group.no_rows", nil, err.Error(), http.StatusNotFound) + } else { + result.Err = model.NewAppError("SqlGroupStore.GroupGetByRemoteID", "store.select_error", nil, err.Error(), http.StatusInternalServerError) + } + return result + } + + result.Data = group + return result +} + +func (s *SqlSupplier) GroupGetAllBySource(ctx context.Context, groupSource model.GroupSource, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + var groups []*model.Group + + if _, err := s.GetReplica().Select(&groups, "SELECT * from UserGroups WHERE DeleteAt = 0 AND Source = :Source", map[string]interface{}{"Source": groupSource}); err != nil { + result.Err = model.NewAppError("SqlGroupStore.GroupGetAllBySource", "store.select_error", nil, err.Error(), http.StatusInternalServerError) + return result + } + + result.Data = groups + + return result +} + +func (s *SqlSupplier) GroupUpdate(ctx context.Context, group *model.Group, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + var retrievedGroup *model.Group + if err := s.GetMaster().SelectOne(&retrievedGroup, "SELECT * FROM UserGroups WHERE Id = :Id", map[string]interface{}{"Id": group.Id}); err != nil { + if err == sql.ErrNoRows { + result.Err = model.NewAppError("SqlGroupStore.GroupUpdate", "store.sql_group.no_rows", nil, "id="+group.Id+","+err.Error(), http.StatusNotFound) + } else { + result.Err = model.NewAppError("SqlGroupStore.GroupUpdate", "store.select_error", nil, "id="+group.Id+","+err.Error(), http.StatusInternalServerError) + } + return result + } + + // If updating DeleteAt it can only be to 0 + if group.DeleteAt != retrievedGroup.DeleteAt && group.DeleteAt != 0 { + result.Err = model.NewAppError("SqlGroupStore.GroupUpdate", "model.group.delete_at.app_error", nil, "", http.StatusInternalServerError) + return result + } + + // Reset these properties, don't update them based on input + group.CreateAt = retrievedGroup.CreateAt + group.UpdateAt = model.GetMillis() + + if err := group.IsValidForUpdate(); err != nil { + result.Err = err + return result + } + + rowsChanged, err := s.GetMaster().Update(group) + if err != nil { + result.Err = model.NewAppError("SqlGroupStore.GroupUpdate", "store.update_error", nil, err.Error(), http.StatusInternalServerError) + return result + } + if rowsChanged != 1 { + result.Err = model.NewAppError("SqlGroupStore.GroupUpdate", "store.sql_group.no_rows_changed", nil, "", http.StatusInternalServerError) + return result + } + + result.Data = group + return result +} + +func (s *SqlSupplier) GroupDelete(ctx context.Context, groupID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + var group *model.Group + if err := s.GetReplica().SelectOne(&group, "SELECT * from UserGroups WHERE Id = :Id AND DeleteAt = 0", map[string]interface{}{"Id": groupID}); err != nil { + if err == sql.ErrNoRows { + result.Err = model.NewAppError("SqlGroupStore.GroupDelete", "store.sql_group.no_rows", nil, "Id="+groupID+", "+err.Error(), http.StatusNotFound) + } else { + result.Err = model.NewAppError("SqlGroupStore.GroupDelete", "store.select_error", nil, err.Error(), http.StatusInternalServerError) + } + + return result + } + + time := model.GetMillis() + group.DeleteAt = time + group.UpdateAt = time + + if _, err := s.GetMaster().Update(group); err != nil { + result.Err = model.NewAppError("SqlGroupStore.GroupDelete", "store.update_error", nil, err.Error(), http.StatusInternalServerError) + } + + result.Data = group + return result +} + +func (s *SqlSupplier) GroupGetMemberUsers(stc context.Context, groupID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + var groupMembers []*model.User + + query := ` + SELECT + Users.* + FROM + GroupMembers + JOIN Users ON Users.Id = GroupMembers.UserId + WHERE + GroupMembers.DeleteAt = 0 + AND Users.DeleteAt = 0 + AND GroupId = :GroupId` + + if _, err := s.GetReplica().Select(&groupMembers, query, map[string]interface{}{"GroupId": groupID}); err != nil { + result.Err = model.NewAppError("SqlGroupStore.GroupGetAllBySource", "store.select_error", nil, err.Error(), http.StatusInternalServerError) + return result + } + + result.Data = groupMembers + + return result +} + +func (s *SqlSupplier) GroupGetMemberUsersPage(stc context.Context, groupID string, offset int, limit int, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + var groupMembers []*model.User + + query := ` + SELECT + Users.* + FROM + GroupMembers + JOIN Users ON Users.Id = GroupMembers.UserId + WHERE + GroupMembers.DeleteAt = 0 + AND Users.DeleteAt = 0 + AND GroupId = :GroupId + ORDER BY + GroupMembers.CreateAt DESC + LIMIT + :Limit + OFFSET + :Offset` + + if _, err := s.GetReplica().Select(&groupMembers, query, map[string]interface{}{"GroupId": groupID, "Limit": limit, "Offset": offset}); err != nil { + result.Err = model.NewAppError("SqlGroupStore.GroupGetMemberUsersPage", "store.select_error", nil, err.Error(), http.StatusInternalServerError) + return result + } + + result.Data = groupMembers + + return result +} + +func (s *SqlSupplier) GroupGetMemberCount(stc context.Context, groupID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + var count int64 + var err error + + query := ` + SELECT + count(*) + FROM + GroupMembers + WHERE + GroupMembers.GroupId = :GroupId` + + if count, err = s.GetReplica().SelectInt(query, map[string]interface{}{"GroupId": groupID}); err != nil { + result.Err = model.NewAppError("SqlGroupStore.GroupGetMemberUsersPage", "store.select_error", nil, err.Error(), http.StatusInternalServerError) + return result + } + + result.Data = count + + return result +} + +func (s *SqlSupplier) GroupCreateOrRestoreMember(ctx context.Context, groupID string, userID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + member := &model.GroupMember{ + GroupId: groupID, + UserId: userID, + CreateAt: model.GetMillis(), + } + + if result.Err = member.IsValid(); result.Err != nil { + return result + } + + var retrievedGroup *model.Group + if err := s.GetMaster().SelectOne(&retrievedGroup, "SELECT * FROM UserGroups WHERE Id = :Id", map[string]interface{}{"Id": groupID}); err != nil { + result.Err = model.NewAppError("SqlGroupStore.GroupCreateOrRestoreMember", "store.insert_error", nil, "group_id="+member.GroupId+"user_id="+member.UserId+","+err.Error(), http.StatusInternalServerError) + return result + } + + var retrievedMember *model.GroupMember + if err := s.GetMaster().SelectOne(&retrievedMember, "SELECT * FROM GroupMembers WHERE GroupId = :GroupId AND UserId = :UserId", map[string]interface{}{"GroupId": member.GroupId, "UserId": member.UserId}); err != nil { + if err != sql.ErrNoRows { + result.Err = model.NewAppError("SqlGroupStore.GroupCreateOrRestoreMember", "store.select_error", nil, "group_id="+member.GroupId+"user_id="+member.UserId+","+err.Error(), http.StatusInternalServerError) + return result + } + } + + if retrievedMember != nil && retrievedMember.DeleteAt == 0 { + result.Err = model.NewAppError("SqlGroupStore.GroupCreateOrRestoreMember", "store.sql_group.uniqueness_error", nil, "group_id="+member.GroupId+", user_id="+member.UserId, http.StatusBadRequest) + return result + } + + if retrievedMember == nil { + if err := s.GetMaster().Insert(member); err != nil { + if IsUniqueConstraintError(err, []string{"GroupId", "UserId", "groupmembers_pkey", "PRIMARY"}) { + result.Err = model.NewAppError("SqlGroupStore.GroupCreateOrRestoreMember", "store.sql_group.uniqueness_error", nil, "group_id="+member.GroupId+", user_id="+member.UserId+", "+err.Error(), http.StatusBadRequest) + return result + } + result.Err = model.NewAppError("SqlGroupStore.GroupCreateOrRestoreMember", "store.insert_error", nil, "group_id="+member.GroupId+", user_id="+member.UserId+", "+err.Error(), http.StatusInternalServerError) + return result + } + } else { + member.DeleteAt = 0 + var rowsChanged int64 + var err error + if rowsChanged, err = s.GetMaster().Update(member); err != nil { + result.Err = model.NewAppError("SqlGroupStore.GroupCreateOrRestoreMember", "store.update_error", nil, "group_id="+member.GroupId+", user_id="+member.UserId+", "+err.Error(), http.StatusInternalServerError) + return result + } + if rowsChanged != 1 { + result.Err = model.NewAppError("SqlGroupStore.GroupCreateOrRestoreMember", "store.sql_group.no_rows_changed", nil, "", http.StatusInternalServerError) + return result + } + } + + result.Data = member + return result +} + +func (s *SqlSupplier) GroupDeleteMember(ctx context.Context, groupID string, userID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + var retrievedMember *model.GroupMember + if err := s.GetMaster().SelectOne(&retrievedMember, "SELECT * FROM GroupMembers WHERE GroupId = :GroupId AND UserId = :UserId AND DeleteAt = 0", map[string]interface{}{"GroupId": groupID, "UserId": userID}); err != nil { + if err == sql.ErrNoRows { + result.Err = model.NewAppError("SqlGroupStore.GroupDeleteMember", "store.sql_group.no_rows", nil, "group_id="+groupID+"user_id="+userID+","+err.Error(), http.StatusNotFound) + return result + } + result.Err = model.NewAppError("SqlGroupStore.GroupDeleteMember", "store.select_error", nil, "group_id="+groupID+"user_id="+userID+","+err.Error(), http.StatusInternalServerError) + return result + } + + retrievedMember.DeleteAt = model.GetMillis() + + if _, err := s.GetMaster().Update(retrievedMember); err != nil { + result.Err = model.NewAppError("SqlGroupStore.GroupDeleteMember", "store.update_error", nil, err.Error(), http.StatusInternalServerError) + return result + } + + result.Data = retrievedMember + return result +} + +func (s *SqlSupplier) GroupCreateGroupSyncable(ctx context.Context, groupSyncable *model.GroupSyncable, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + if err := groupSyncable.IsValid(); err != nil { + result.Err = err + return result + } + + // Reset values that shouldn't be updatable by parameter + groupSyncable.DeleteAt = 0 + groupSyncable.CreateAt = model.GetMillis() + groupSyncable.UpdateAt = groupSyncable.CreateAt + + var err error + + switch groupSyncable.Type { + case model.GroupSyncableTypeTeam: + teamResult := <-s.Team().Get(groupSyncable.SyncableId) + if teamResult.Err != nil { + result.Err = teamResult.Err + return result + } + + err = s.GetMaster().Insert(groupSyncableToGroupTeam(groupSyncable)) + case model.GroupSyncableTypeChannel: + channelResult := <-s.Channel().Get(groupSyncable.SyncableId, false) + if channelResult.Err != nil { + result.Err = channelResult.Err + return result + } + + err = s.GetMaster().Insert(groupSyncableToGroupChannel(groupSyncable)) + default: + result.Err = model.NewAppError("SqlGroupStore.GroupCreateGroupSyncable", "model.group_syncable.type.app_error", nil, "group_id="+groupSyncable.GroupId+", syncable_id="+groupSyncable.SyncableId+", "+err.Error(), http.StatusInternalServerError) + return result + } + + if err != nil { + result.Err = model.NewAppError("SqlGroupStore.GroupCreateGroupSyncable", "store.insert_error", nil, "group_id="+groupSyncable.GroupId+", syncable_id="+groupSyncable.SyncableId+", "+err.Error(), http.StatusInternalServerError) + return result + } + + result.Data = groupSyncable + return result +} + +func (s *SqlSupplier) GroupGetGroupSyncable(ctx context.Context, groupID string, syncableID string, syncableType model.GroupSyncableType, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + groupSyncable, err := s.getGroupSyncable(groupID, syncableID, syncableType) + if err != nil { + if err == sql.ErrNoRows { + result.Err = model.NewAppError("SqlGroupStore.GroupGetGroupSyncable", "store.sql_group.no_rows", nil, err.Error(), http.StatusNotFound) + } else { + result.Err = model.NewAppError("SqlGroupStore.GroupGetGroupSyncable", "store.select_error", nil, err.Error(), http.StatusInternalServerError) + } + return result + } + + result.Data = groupSyncable + + return result +} + +func (s *SqlSupplier) getGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, error) { + var err error + var result interface{} + + switch syncableType { + case model.GroupSyncableTypeTeam: + result, err = s.GetMaster().Get(groupTeam{}, groupID, syncableID) + case model.GroupSyncableTypeChannel: + result, err = s.GetMaster().Get(groupChannel{}, groupID, syncableID) + } + + if err != nil { + return nil, err + } + + if result == nil { + return nil, sql.ErrNoRows + } + + groupSyncable := model.GroupSyncable{} + switch syncableType { + case model.GroupSyncableTypeTeam: + groupTeam := result.(*groupTeam) + groupSyncable.SyncableId = groupTeam.TeamId + groupSyncable.GroupId = groupTeam.GroupId + groupSyncable.CanLeave = groupTeam.CanLeave + groupSyncable.AutoAdd = groupTeam.AutoAdd + groupSyncable.CreateAt = groupTeam.CreateAt + groupSyncable.DeleteAt = groupTeam.DeleteAt + groupSyncable.UpdateAt = groupTeam.UpdateAt + groupSyncable.Type = syncableType + case model.GroupSyncableTypeChannel: + groupChannel := result.(*groupChannel) + groupSyncable.SyncableId = groupChannel.ChannelId + groupSyncable.GroupId = groupChannel.GroupId + groupSyncable.CanLeave = groupChannel.CanLeave + groupSyncable.AutoAdd = groupChannel.AutoAdd + groupSyncable.CreateAt = groupChannel.CreateAt + groupSyncable.DeleteAt = groupChannel.DeleteAt + groupSyncable.UpdateAt = groupChannel.UpdateAt + groupSyncable.Type = syncableType + default: + return nil, fmt.Errorf("unable to convert syncableType: %s", syncableType.String()) + } + + return &groupSyncable, nil +} + +func (s *SqlSupplier) GroupGetAllGroupSyncablesByGroup(ctx context.Context, groupID string, syncableType model.GroupSyncableType, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + args := map[string]interface{}{"GroupId": groupID} + + appErrF := func(msg string) *model.AppError { + return model.NewAppError("SqlGroupStore.GroupGetAllGroupSyncablesByGroup", "store.select_error", nil, msg, http.StatusInternalServerError) + } + + groupSyncables := []*model.GroupSyncable{} + + switch syncableType { + case model.GroupSyncableTypeTeam: + sqlQuery := ` + SELECT + GroupTeams.*, + Teams.DisplayName AS TeamDisplayName, + Teams.Type AS TeamType + FROM + GroupTeams + JOIN Teams ON Teams.Id = GroupTeams.TeamId + WHERE + GroupId = :GroupId AND GroupTeams.DeleteAt = 0` + + results := []*groupTeamJoin{} + _, err := s.GetMaster().Select(&results, sqlQuery, args) + if err != nil { + result.Err = appErrF(err.Error()) + return result + } + for _, result := range results { + groupSyncable := &model.GroupSyncable{ + SyncableId: result.TeamId, + GroupId: result.GroupId, + CanLeave: result.CanLeave, + AutoAdd: result.AutoAdd, + CreateAt: result.CreateAt, + DeleteAt: result.DeleteAt, + UpdateAt: result.UpdateAt, + Type: syncableType, + TeamDisplayName: result.TeamDisplayName, + TeamType: result.TeamType, + } + groupSyncables = append(groupSyncables, groupSyncable) + } + case model.GroupSyncableTypeChannel: + sqlQuery := ` + SELECT + GroupChannels.*, + Channels.DisplayName AS ChannelDisplayName, + Teams.DisplayName AS TeamDisplayName, + Channels.Type As ChannelType, + Teams.Type As TeamType, + Teams.Id AS TeamId + FROM + GroupChannels + JOIN Channels ON Channels.Id = GroupChannels.ChannelId + JOIN Teams ON Teams.Id = Channels.TeamId + WHERE + GroupId = :GroupId AND GroupChannels.DeleteAt = 0` + + results := []*groupChannelJoin{} + _, err := s.GetMaster().Select(&results, sqlQuery, args) + if err != nil { + result.Err = appErrF(err.Error()) + return result + } + for _, result := range results { + groupSyncable := &model.GroupSyncable{ + SyncableId: result.ChannelId, + GroupId: result.GroupId, + CanLeave: result.CanLeave, + AutoAdd: result.AutoAdd, + CreateAt: result.CreateAt, + DeleteAt: result.DeleteAt, + UpdateAt: result.UpdateAt, + Type: syncableType, + ChannelDisplayName: result.ChannelDisplayName, + ChannelType: result.ChannelType, + TeamDisplayName: result.TeamDisplayName, + TeamType: result.TeamType, + TeamID: result.TeamID, + } + groupSyncables = append(groupSyncables, groupSyncable) + } + } + + result.Data = groupSyncables + return result +} + +func (s *SqlSupplier) GroupUpdateGroupSyncable(ctx context.Context, groupSyncable *model.GroupSyncable, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + retrievedGroupSyncable, err := s.getGroupSyncable(groupSyncable.GroupId, groupSyncable.SyncableId, groupSyncable.Type) + if err != nil { + if err == sql.ErrNoRows { + result.Err = model.NewAppError("SqlGroupStore.GroupUpdateGroupSyncable", "store.sql_group.no_rows", nil, err.Error(), http.StatusInternalServerError) + return result + } + result.Err = model.NewAppError("SqlGroupStore.GroupUpdateGroupSyncable", "store.select_error", nil, "GroupId="+groupSyncable.GroupId+", SyncableId="+groupSyncable.SyncableId+", SyncableType="+groupSyncable.Type.String()+", "+err.Error(), http.StatusInternalServerError) + return result + } + + if err := groupSyncable.IsValid(); err != nil { + result.Err = err + return result + } + + // If updating DeleteAt it can only be to 0 + if groupSyncable.DeleteAt != retrievedGroupSyncable.DeleteAt && groupSyncable.DeleteAt != 0 { + result.Err = model.NewAppError("SqlGroupStore.GroupUpdateGroupSyncable", "model.group.delete_at.app_error", nil, "", http.StatusInternalServerError) + return result + } + + // Reset these properties, don't update them based on input + groupSyncable.CreateAt = retrievedGroupSyncable.CreateAt + groupSyncable.UpdateAt = model.GetMillis() + + switch groupSyncable.Type { + case model.GroupSyncableTypeTeam: + _, err = s.GetMaster().Update(groupSyncableToGroupTeam(groupSyncable)) + case model.GroupSyncableTypeChannel: + _, err = s.GetMaster().Update(groupSyncableToGroupChannel(groupSyncable)) + default: + model.NewAppError("SqlGroupStore.GroupUpdateGroupSyncable", "model.group_syncable.type.app_error", nil, "group_id="+groupSyncable.GroupId+", syncable_id="+groupSyncable.SyncableId+", "+err.Error(), http.StatusInternalServerError) + return result + } + + if err != nil { + result.Err = model.NewAppError("SqlGroupStore.GroupUpdateGroupSyncable", "store.update_error", nil, err.Error(), http.StatusInternalServerError) + return result + } + + result.Data = groupSyncable + return result +} + +func (s *SqlSupplier) GroupDeleteGroupSyncable(ctx context.Context, groupID string, syncableID string, syncableType model.GroupSyncableType, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + groupSyncable, err := s.getGroupSyncable(groupID, syncableID, syncableType) + if err != nil { + if err == sql.ErrNoRows { + result.Err = model.NewAppError("SqlGroupStore.GroupDeleteGroupSyncable", "store.sql_group.no_rows", nil, "Id="+groupID+", "+err.Error(), http.StatusNotFound) + } else { + result.Err = model.NewAppError("SqlGroupStore.GroupDeleteGroupSyncable", "store.select_error", nil, err.Error(), http.StatusInternalServerError) + } + return result + } + + if groupSyncable.DeleteAt != 0 { + result.Err = model.NewAppError("SqlGroupStore.GroupDeleteGroupSyncable", "store.sql_group.group_syncable_already_deleted", nil, "group_id="+groupID+"syncable_id="+syncableID, http.StatusBadRequest) + return result + } + + time := model.GetMillis() + groupSyncable.DeleteAt = time + groupSyncable.UpdateAt = time + + switch groupSyncable.Type { + case model.GroupSyncableTypeTeam: + _, err = s.GetMaster().Update(groupSyncableToGroupTeam(groupSyncable)) + case model.GroupSyncableTypeChannel: + _, err = s.GetMaster().Update(groupSyncableToGroupChannel(groupSyncable)) + default: + model.NewAppError("SqlGroupStore.GroupDeleteGroupSyncable", "model.group_syncable.type.app_error", nil, "group_id="+groupSyncable.GroupId+", syncable_id="+groupSyncable.SyncableId+", "+err.Error(), http.StatusInternalServerError) + return result + } + + if err != nil { + result.Err = model.NewAppError("SqlGroupStore.GroupDeleteGroupSyncable", "store.update_error", nil, err.Error(), http.StatusInternalServerError) + return result + } + + result.Data = groupSyncable + return result +} + +// PendingAutoAddTeamMembers returns a slice of UserTeamIDPair that need newly created memberships +// based on the groups configurations. +// +// Typically since will be the last successful group sync time. +func (s *SqlSupplier) PendingAutoAddTeamMembers(ctx context.Context, since int64, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + sql := ` + SELECT + GroupMembers.UserId, GroupTeams.TeamId + FROM + GroupMembers + JOIN GroupTeams + ON GroupTeams.GroupId = GroupMembers.GroupId + JOIN UserGroups ON UserGroups.Id = GroupMembers.GroupId + JOIN Teams ON Teams.Id = GroupTeams.TeamId + LEFT OUTER JOIN TeamMembers + ON + TeamMembers.TeamId = GroupTeams.TeamId + AND TeamMembers.UserId = GroupMembers.UserId + WHERE + TeamMembers.UserId IS NULL + AND UserGroups.DeleteAt = 0 + AND GroupTeams.DeleteAt = 0 + AND GroupTeams.AutoAdd = true + AND GroupMembers.DeleteAt = 0 + AND Teams.DeleteAt = 0 + AND (GroupMembers.CreateAt >= :Since + OR GroupTeams.UpdateAt >= :Since)` + + var userTeamIDs []*model.UserTeamIDPair + + _, err := s.GetMaster().Select(&userTeamIDs, sql, map[string]interface{}{"Since": since}) + if err != nil { + result.Err = model.NewAppError("SqlGroupStore.PendingAutoAddTeamMembers", "store.select_error", nil, err.Error(), http.StatusInternalServerError) + } + + result.Data = userTeamIDs + + return result +} + +// PendingAutoAddChannelMembers returns a slice of UserChannelIDPair that need newly created memberships +// based on the groups configurations. +// +// Typically since will be the last successful group sync time. +func (s *SqlSupplier) PendingAutoAddChannelMembers(ctx context.Context, since int64, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + result := store.NewSupplierResult() + + sql := ` + SELECT + GroupMembers.UserId, GroupChannels.ChannelId + FROM + GroupMembers + JOIN GroupChannels ON GroupChannels.GroupId = GroupMembers.GroupId + JOIN UserGroups ON UserGroups.Id = GroupMembers.GroupId + JOIN Channels ON Channels.Id = GroupChannels.ChannelId + LEFT OUTER JOIN ChannelMemberHistory + ON + ChannelMemberHistory.ChannelId = GroupChannels.ChannelId + AND ChannelMemberHistory.UserId = GroupMembers.UserId + WHERE + ChannelMemberHistory.UserId IS NULL + AND ChannelMemberHistory.LeaveTime IS NULL + AND UserGroups.DeleteAt = 0 + AND GroupChannels.DeleteAt = 0 + AND GroupChannels.AutoAdd = true + AND GroupMembers.DeleteAt = 0 + AND Channels.DeleteAt = 0 + AND (GroupMembers.CreateAt >= :Since + OR GroupChannels.UpdateAt >= :Since)` + + var userChannelIDs []*model.UserChannelIDPair + + _, err := s.GetMaster().Select(&userChannelIDs, sql, map[string]interface{}{"Since": since}) + if err != nil { + result.Err = model.NewAppError("SqlGroupStore.PendingAutoAddChannelMembers", "store.select_error", nil, "", http.StatusInternalServerError) + } + + result.Data = userChannelIDs + + return result +} + +func groupSyncableToGroupTeam(groupSyncable *model.GroupSyncable) *groupTeam { + return &groupTeam{ + GroupSyncable: *groupSyncable, + TeamId: groupSyncable.SyncableId, + } +} + +func groupSyncableToGroupChannel(groupSyncable *model.GroupSyncable) *groupChannel { + return &groupChannel{ + GroupSyncable: *groupSyncable, + ChannelId: groupSyncable.SyncableId, + } +} diff --git a/store/sqlstore/supplier.go b/store/sqlstore/supplier.go index 2ea5ab030c..663e644e12 100644 --- a/store/sqlstore/supplier.go +++ b/store/sqlstore/supplier.go @@ -93,6 +93,7 @@ type SqlSupplierOldStores struct { role store.RoleStore scheme store.SchemeStore TermsOfService store.TermsOfServiceStore + group store.GroupStore UserTermsOfService store.UserTermsOfServiceStore } @@ -148,6 +149,7 @@ func NewSqlSupplier(settings model.SqlSettings, metrics einterfaces.MetricsInter initSqlSupplierReactions(supplier) initSqlSupplierRoles(supplier) initSqlSupplierSchemes(supplier) + initSqlSupplierGroups(supplier) err := supplier.GetMaster().CreateTablesIfNotExists() if err != nil { @@ -182,6 +184,8 @@ func NewSqlSupplier(settings model.SqlSettings, metrics einterfaces.MetricsInter supplier.oldStores.TermsOfService.(SqlTermsOfServiceStore).CreateIndexesIfNotExists() supplier.oldStores.UserTermsOfService.(SqlUserTermsOfServiceStore).CreateIndexesIfNotExists() + supplier.CreateIndexesIfNotExistsGroups() + supplier.oldStores.preference.(*SqlPreferenceStore).DeleteUnusedFeatures() return supplier @@ -1024,6 +1028,10 @@ func (ss *SqlSupplier) Scheme() store.SchemeStore { return ss.oldStores.scheme } +func (ss *SqlSupplier) Group() store.GroupStore { + return ss.oldStores.group +} + func (ss *SqlSupplier) DropAllTables() { ss.master.TruncateTables() } diff --git a/store/sqlstore/upgrade.go b/store/sqlstore/upgrade.go index 30fcd2de3e..183b77a2d1 100644 --- a/store/sqlstore/upgrade.go +++ b/store/sqlstore/upgrade.go @@ -532,6 +532,7 @@ func UpgradeDatabaseToVersion56(sqlStore SqlStore) { sqlStore.RemoveIndexIfExists("idx_users_firstname_lower", "lower(FirstName)") sqlStore.RemoveIndexIfExists("idx_users_lastname_lower", "lower(LastName)") } + saveSchemaVersion(sqlStore, VERSION_5_6_0) } diff --git a/store/store.go b/store/store.go index 7eb080cd6b..4124e48885 100644 --- a/store/store.go +++ b/store/store.go @@ -66,6 +66,7 @@ type Store interface { ChannelMemberHistory() ChannelMemberHistoryStore Plugin() PluginStore TermsOfService() TermsOfServiceStore + Group() GroupStore UserTermsOfService() UserTermsOfServiceStore MarkSystemRanUnitTests() Close() @@ -137,6 +138,7 @@ type ChannelStore interface { GetDeletedByName(team_id string, name string) StoreChannel GetDeleted(team_id string, offset int, limit int) StoreChannel GetChannels(teamId string, userId string, includeDeleted bool) StoreChannel + GetAllChannels(page, perPage int, includeDeleted bool) StoreChannel GetMoreChannels(teamId string, userId string, offset int, limit int) StoreChannel GetPublicChannelsForTeam(teamId string, offset int, limit int) StoreChannel GetPublicChannelsByIdsForTeam(teamId string, channelIds []string) StoreChannel @@ -168,6 +170,7 @@ type ChannelStore interface { GetMembersForUser(teamId string, userId string) StoreChannel AutocompleteInTeam(teamId string, term string, includeDeleted bool) StoreChannel AutocompleteInTeamForSearch(teamId string, userId string, term string, includeDeleted bool) StoreChannel + SearchAllChannels(term string, includeDeleted bool) StoreChannel SearchInTeam(teamId string, term string, includeDeleted bool) StoreChannel SearchMore(userId string, teamId string, term string) StoreChannel GetMembersByIds(channelId string, userIds []string) StoreChannel @@ -536,3 +539,27 @@ type UserTermsOfServiceStore interface { Save(userTermsOfService *model.UserTermsOfService) StoreChannel Delete(userId, termsOfServiceId string) StoreChannel } + +type GroupStore interface { + Create(group *model.Group) StoreChannel + Get(groupID string) StoreChannel + GetByRemoteID(remoteID string, groupSource model.GroupSource) StoreChannel + GetAllBySource(groupSource model.GroupSource) StoreChannel + Update(group *model.Group) StoreChannel + Delete(groupID string) StoreChannel + + GetMemberUsers(groupID string) StoreChannel + GetMemberUsersPage(groupID string, offset int, limit int) StoreChannel + GetMemberCount(groupID string) StoreChannel + CreateOrRestoreMember(groupID string, userID string) StoreChannel + DeleteMember(groupID string, userID string) StoreChannel + + CreateGroupSyncable(groupSyncable *model.GroupSyncable) StoreChannel + GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) StoreChannel + GetAllGroupSyncablesByGroupId(groupID string, syncableType model.GroupSyncableType) StoreChannel + UpdateGroupSyncable(groupSyncable *model.GroupSyncable) StoreChannel + DeleteGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) StoreChannel + + PendingAutoAddTeamMembers(minGroupMembersCreateAt int64) StoreChannel + PendingAutoAddChannelMembers(minGroupMembersCreateAt int64) StoreChannel +} diff --git a/store/storetest/channel_store.go b/store/storetest/channel_store.go index 7fc4190610..cebf6cdf42 100644 --- a/store/storetest/channel_store.go +++ b/store/storetest/channel_store.go @@ -21,6 +21,17 @@ type SqlSupplier interface { GetMaster() *gorp.DbMap } +func cleanupChannels(t *testing.T, ss store.Store) { + result := <-ss.Channel().GetAllChannels(0, 100000, true) + if result.Err != nil { + t.Fatal("error cleaning all channels") + } + list := result.Data.(*model.ChannelListWithTeamData) + for _, channel := range *list { + ss.Channel().PermanentDelete(channel.Id) + } +} + func TestChannelStore(t *testing.T, ss store.Store, s SqlSupplier) { createDefaultRoles(t, ss) @@ -40,6 +51,7 @@ func TestChannelStore(t *testing.T, ss store.Store, s SqlSupplier) { t.Run("ChannelMemberStore", func(t *testing.T) { testChannelMemberStore(t, ss) }) t.Run("ChannelDeleteMemberStore", func(t *testing.T) { testChannelDeleteMemberStore(t, ss) }) t.Run("GetChannels", func(t *testing.T) { testChannelStoreGetChannels(t, ss) }) + t.Run("GetAllChannels", func(t *testing.T) { testChannelStoreGetAllChannels(t, ss) }) t.Run("GetMoreChannels", func(t *testing.T) { testChannelStoreGetMoreChannels(t, ss) }) t.Run("GetPublicChannelsForTeam", func(t *testing.T) { testChannelStoreGetPublicChannelsForTeam(t, ss) }) t.Run("GetPublicChannelsByIdsForTeam", func(t *testing.T) { testChannelStoreGetPublicChannelsByIdsForTeam(t, ss) }) @@ -53,6 +65,7 @@ func TestChannelStore(t *testing.T, ss store.Store, s SqlSupplier) { t.Run("GetMemberCount", func(t *testing.T) { testGetMemberCount(t, ss) }) t.Run("SearchMore", func(t *testing.T) { testChannelStoreSearchMore(t, ss) }) t.Run("SearchInTeam", func(t *testing.T) { testChannelStoreSearchInTeam(t, ss) }) + t.Run("SearchAllChannels", func(t *testing.T) { testChannelStoreSearchAllChannels(t, ss) }) t.Run("AutocompleteInTeamForSearch", func(t *testing.T) { testChannelStoreAutocompleteInTeamForSearch(t, ss) }) t.Run("GetMembersByIds", func(t *testing.T) { testChannelStoreGetMembersByIds(t, ss) }) t.Run("AnalyticsDeletedTypeCount", func(t *testing.T) { testChannelStoreAnalyticsDeletedTypeCount(t, ss) }) @@ -959,6 +972,81 @@ func testChannelStoreGetChannels(t *testing.T, ss store.Store) { ss.Channel().InvalidateAllChannelMembersForUser(m1.UserId) } +func testChannelStoreGetAllChannels(t *testing.T, ss store.Store) { + cleanupChannels(t, ss) + + t1 := model.Team{} + t1.DisplayName = "Name" + t1.Name = model.NewId() + t1.Email = MakeEmail() + t1.Type = model.TEAM_OPEN + store.Must(ss.Team().Save(&t1)) + + t2 := model.Team{} + t2.DisplayName = "Name2" + t2.Name = model.NewId() + t2.Email = MakeEmail() + t2.Type = model.TEAM_OPEN + store.Must(ss.Team().Save(&t2)) + + c1 := model.Channel{} + c1.TeamId = t1.Id + c1.DisplayName = "Channel1" + model.NewId() + c1.Name = "zz" + model.NewId() + "b" + c1.Type = model.CHANNEL_OPEN + store.Must(ss.Channel().Save(&c1, -1)) + + c2 := model.Channel{} + c2.TeamId = t1.Id + c2.DisplayName = "Channel2" + model.NewId() + c2.Name = "zz" + model.NewId() + "b" + c2.Type = model.CHANNEL_OPEN + store.Must(ss.Channel().Save(&c2, -1)) + c2.DeleteAt = model.GetMillis() + c2.UpdateAt = c2.DeleteAt + store.Must(ss.Channel().Delete(c2.Id, c2.DeleteAt)) + + c3 := model.Channel{} + c3.TeamId = t2.Id + c3.DisplayName = "Channel3" + model.NewId() + c3.Name = "zz" + model.NewId() + "b" + c3.Type = model.CHANNEL_PRIVATE + store.Must(ss.Channel().Save(&c3, -1)) + + store.Must(ss.Channel().CreateDirectChannel(model.NewId(), model.NewId())) + + userIds := []string{model.NewId(), model.NewId(), model.NewId()} + + c5 := model.Channel{} + c5.Name = model.GetGroupNameFromUserIds(userIds) + c5.DisplayName = "GroupChannel" + model.NewId() + c5.Name = "zz" + model.NewId() + "b" + c5.Type = model.CHANNEL_GROUP + store.Must(ss.Channel().Save(&c5, -1)) + + cresult := <-ss.Channel().GetAllChannels(0, 10, false) + list := cresult.Data.(*model.ChannelListWithTeamData) + assert.Len(t, *list, 2) + assert.Equal(t, (*list)[0].Id, c1.Id) + assert.Equal(t, (*list)[0].TeamDisplayName, "Name") + assert.Equal(t, (*list)[1].Id, c3.Id) + assert.Equal(t, (*list)[1].TeamDisplayName, "Name2") + + cresult = <-ss.Channel().GetAllChannels(0, 10, true) + list = cresult.Data.(*model.ChannelListWithTeamData) + assert.Len(t, *list, 3) + assert.Equal(t, (*list)[0].Id, c1.Id) + assert.Equal(t, (*list)[0].TeamDisplayName, "Name") + assert.Equal(t, (*list)[1].Id, c2.Id) + assert.Equal(t, (*list)[2].Id, c3.Id) + + cresult = <-ss.Channel().GetAllChannels(0, 1, true) + list = cresult.Data.(*model.ChannelListWithTeamData) + assert.Len(t, *list, 1) + assert.Equal(t, (*list)[0].Id, c1.Id) + assert.Equal(t, (*list)[0].TeamDisplayName, "Name") +} + func testChannelStoreGetMoreChannels(t *testing.T, ss store.Store) { teamId := model.NewId() otherTeamId := model.NewId() @@ -2006,6 +2094,184 @@ func testChannelStoreSearchInTeam(t *testing.T, ss store.Store) { } } +func testChannelStoreSearchAllChannels(t *testing.T, ss store.Store) { + cleanupChannels(t, ss) + + t1 := model.Team{} + t1.DisplayName = "Name" + t1.Name = model.NewId() + t1.Email = MakeEmail() + t1.Type = model.TEAM_OPEN + store.Must(ss.Team().Save(&t1)) + + t2 := model.Team{} + t2.DisplayName = "Name2" + t2.Name = model.NewId() + t2.Email = MakeEmail() + t2.Type = model.TEAM_OPEN + store.Must(ss.Team().Save(&t2)) + + o1 := model.Channel{ + TeamId: t1.Id, + DisplayName: "ChannelA", + Name: "zz" + model.NewId() + "b", + Type: model.CHANNEL_OPEN, + } + store.Must(ss.Channel().Save(&o1, -1)) + + o2 := model.Channel{ + TeamId: t2.Id, + DisplayName: "ChannelA", + Name: "zz" + model.NewId() + "b", + Type: model.CHANNEL_OPEN, + } + store.Must(ss.Channel().Save(&o2, -1)) + + m1 := model.ChannelMember{ + ChannelId: o1.Id, + UserId: model.NewId(), + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + store.Must(ss.Channel().SaveMember(&m1)) + + m2 := model.ChannelMember{ + ChannelId: o1.Id, + UserId: model.NewId(), + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + store.Must(ss.Channel().SaveMember(&m2)) + + m3 := model.ChannelMember{ + ChannelId: o2.Id, + UserId: model.NewId(), + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + store.Must(ss.Channel().SaveMember(&m3)) + + o3 := model.Channel{ + TeamId: t1.Id, + DisplayName: "ChannelA (alternate)", + Name: "zz" + model.NewId() + "b", + Type: model.CHANNEL_OPEN, + } + store.Must(ss.Channel().Save(&o3, -1)) + + o4 := model.Channel{ + TeamId: t1.Id, + DisplayName: "ChannelB", + Name: "zz" + model.NewId() + "b", + Type: model.CHANNEL_PRIVATE, + } + store.Must(ss.Channel().Save(&o4, -1)) + + o5 := model.Channel{ + TeamId: t1.Id, + DisplayName: "ChannelC", + Name: "zz" + model.NewId() + "b", + Type: model.CHANNEL_PRIVATE, + } + store.Must(ss.Channel().Save(&o5, -1)) + + o6 := model.Channel{ + TeamId: t1.Id, + DisplayName: "Off-Topic", + Name: "off-topic", + Type: model.CHANNEL_OPEN, + } + store.Must(ss.Channel().Save(&o6, -1)) + + o7 := model.Channel{ + TeamId: t1.Id, + DisplayName: "Off-Set", + Name: "off-set", + Type: model.CHANNEL_OPEN, + } + store.Must(ss.Channel().Save(&o7, -1)) + + o8 := model.Channel{ + TeamId: t1.Id, + DisplayName: "Off-Limit", + Name: "off-limit", + Type: model.CHANNEL_PRIVATE, + } + store.Must(ss.Channel().Save(&o8, -1)) + + o9 := model.Channel{ + TeamId: t1.Id, + DisplayName: "Town Square", + Name: "town-square", + Type: model.CHANNEL_OPEN, + } + store.Must(ss.Channel().Save(&o9, -1)) + + o10 := model.Channel{ + TeamId: t1.Id, + DisplayName: "The", + Name: "the", + Type: model.CHANNEL_OPEN, + } + store.Must(ss.Channel().Save(&o10, -1)) + + o11 := model.Channel{ + TeamId: t1.Id, + DisplayName: "Native Mobile Apps", + Name: "native-mobile-apps", + Type: model.CHANNEL_OPEN, + } + store.Must(ss.Channel().Save(&o11, -1)) + + o12 := model.Channel{ + TeamId: t1.Id, + DisplayName: "ChannelZ", + Purpose: "This can now be searchable!", + Name: "with-purpose", + Type: model.CHANNEL_OPEN, + } + store.Must(ss.Channel().Save(&o12, -1)) + + o13 := model.Channel{ + TeamId: t1.Id, + DisplayName: "ChannelA (deleted)", + Name: model.NewId(), + Type: model.CHANNEL_OPEN, + } + store.Must(ss.Channel().Save(&o13, -1)) + o13.DeleteAt = model.GetMillis() + o13.UpdateAt = o13.DeleteAt + store.Must(ss.Channel().Delete(o13.Id, o13.DeleteAt)) + + testCases := []struct { + Description string + Term string + IncludeDeleted bool + ExpectedResults *model.ChannelList + }{ + {"ChannelA", "ChannelA", false, &model.ChannelList{&o1, &o2, &o3}}, + {"ChannelA, include deleted", "ChannelA", true, &model.ChannelList{&o1, &o2, &o3, &o13}}, + {"empty string", "", false, &model.ChannelList{&o1, &o2, &o3, &o4, &o5, &o12, &o11, &o8, &o7, &o6, &o10, &o9}}, + {"no matches", "blargh", false, &model.ChannelList{}}, + {"prefix", "off-", false, &model.ChannelList{&o8, &o7, &o6}}, + {"full match with dash", "off-topic", false, &model.ChannelList{&o6}}, + {"town square", "town square", false, &model.ChannelList{&o9}}, + {"the in name", "the", false, &model.ChannelList{&o10}}, + {"Mobile", "Mobile", false, &model.ChannelList{&o11}}, + {"search purpose", "now searchable", false, &model.ChannelList{&o12}}, + {"pipe ignored", "town square |", false, &model.ChannelList{&o9}}, + } + + for _, testCase := range testCases { + t.Run(testCase.Description, func(t *testing.T) { + result := <-ss.Channel().SearchAllChannels(testCase.Term, testCase.IncludeDeleted) + require.Nil(t, result.Err) + channels := result.Data.(*model.ChannelListWithTeamData) + require.Equal(t, len(*channels), len(*testCase.ExpectedResults)) + for i, expected := range *testCase.ExpectedResults { + require.Equal(t, (*channels)[i].Id, expected.Id) + } + }) + } +} + func testChannelStoreAutocompleteInTeamForSearch(t *testing.T, ss store.Store) { u1 := &model.User{} u1.Email = MakeEmail() diff --git a/store/storetest/group_supplier.go b/store/storetest/group_supplier.go new file mode 100644 index 0000000000..9cee84e945 --- /dev/null +++ b/store/storetest/group_supplier.go @@ -0,0 +1,1318 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package storetest + +import ( + "strings" + "testing" + + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/store" + "github.com/stretchr/testify/assert" +) + +func TestGroupStore(t *testing.T, ss store.Store) { + t.Run("Create", func(t *testing.T) { testGroupStoreCreate(t, ss) }) + t.Run("Get", func(t *testing.T) { testGroupStoreGet(t, ss) }) + t.Run("GetByRemoteID", func(t *testing.T) { testGroupStoreGetByRemoteID(t, ss) }) + t.Run("GetAllBySource", func(t *testing.T) { testGroupStoreGetAllByType(t, ss) }) + t.Run("Update", func(t *testing.T) { testGroupStoreUpdate(t, ss) }) + t.Run("Delete", func(t *testing.T) { testGroupStoreDelete(t, ss) }) + + t.Run("GetMemberUsers", func(t *testing.T) { testGroupGetMemberUsers(t, ss) }) + t.Run("GetMemberUsersPage", func(t *testing.T) { testGroupGetMemberUsersPage(t, ss) }) + t.Run("CreateOrRestoreMember", func(t *testing.T) { testGroupCreateOrRestoreMember(t, ss) }) + t.Run("DeleteMember", func(t *testing.T) { testGroupDeleteMember(t, ss) }) + + t.Run("CreateGroupSyncable", func(t *testing.T) { testCreateGroupSyncable(t, ss) }) + t.Run("GetGroupSyncable", func(t *testing.T) { testGetGroupSyncable(t, ss) }) + t.Run("GetAllGroupSyncablesByGroupId", func(t *testing.T) { testGetAllGroupSyncablesByGroup(t, ss) }) + t.Run("UpdateGroupSyncable", func(t *testing.T) { testUpdateGroupSyncable(t, ss) }) + t.Run("DeleteGroupSyncable", func(t *testing.T) { testDeleteGroupSyncable(t, ss) }) + + t.Run("PendingAutoAddTeamMembers", func(t *testing.T) { testPendingAutoAddTeamMembers(t, ss) }) + t.Run("PendingAutoAddChannelMembers", func(t *testing.T) { testPendingAutoAddChannelMembers(t, ss) }) +} + +func testGroupStoreCreate(t *testing.T, ss store.Store) { + // Save a new group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Source: model.GroupSourceLdap, + Description: model.NewId(), + RemoteId: model.NewId(), + } + + // Happy path + res1 := <-ss.Group().Create(g1) + assert.Nil(t, res1.Err) + d1 := res1.Data.(*model.Group) + assert.Len(t, d1.Id, 26) + assert.Equal(t, g1.Name, d1.Name) + assert.Equal(t, g1.DisplayName, d1.DisplayName) + assert.Equal(t, g1.Description, d1.Description) + assert.Equal(t, g1.RemoteId, d1.RemoteId) + assert.NotZero(t, d1.CreateAt) + assert.NotZero(t, d1.UpdateAt) + assert.Zero(t, d1.DeleteAt) + + // Requires name and display name + g2 := &model.Group{ + Name: "", + DisplayName: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res2 := <-ss.Group().Create(g2) + assert.Nil(t, res2.Data) + assert.NotNil(t, res2.Err) + assert.Equal(t, res2.Err.Id, "model.group.name.app_error") + + g2.Name = model.NewId() + g2.DisplayName = "" + res3 := <-ss.Group().Create(g2) + assert.Nil(t, res3.Data) + assert.NotNil(t, res3.Err) + assert.Equal(t, res3.Err.Id, "model.group.display_name.app_error") + + // Won't accept a duplicate name + g4 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res5 := <-ss.Group().Create(g4) + assert.Nil(t, res5.Err) + g4b := &model.Group{ + Name: g4.Name, + DisplayName: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res5b := <-ss.Group().Create(g4b) + assert.Nil(t, res5b.Data) + assert.Equal(t, res5b.Err.Id, "store.sql_group.unique_constraint") + + // Fields cannot be greater than max values + g5 := &model.Group{ + Name: strings.Repeat("x", model.GroupNameMaxLength), + DisplayName: strings.Repeat("x", model.GroupDisplayNameMaxLength), + Description: strings.Repeat("x", model.GroupDescriptionMaxLength), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + assert.Nil(t, g5.IsValidForCreate()) + + g5.Name = g5.Name + "x" + assert.Equal(t, g5.IsValidForCreate().Id, "model.group.name.app_error") + g5.Name = model.NewId() + assert.Nil(t, g5.IsValidForCreate()) + + g5.DisplayName = g5.DisplayName + "x" + assert.Equal(t, g5.IsValidForCreate().Id, "model.group.display_name.app_error") + g5.DisplayName = model.NewId() + assert.Nil(t, g5.IsValidForCreate()) + + g5.Description = g5.Description + "x" + assert.Equal(t, g5.IsValidForCreate().Id, "model.group.description.app_error") + g5.Description = model.NewId() + assert.Nil(t, g5.IsValidForCreate()) + + // Must use a valid type + g6 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSource("fake"), + RemoteId: model.NewId(), + } + assert.Equal(t, g6.IsValidForCreate().Id, "model.group.source.app_error") +} + +func testGroupStoreGet(t *testing.T, ss store.Store) { + // Create a group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res1 := <-ss.Group().Create(g1) + assert.Nil(t, res1.Err) + d1 := res1.Data.(*model.Group) + assert.Len(t, d1.Id, 26) + + // Get the group + res2 := <-ss.Group().Get(d1.Id) + assert.Nil(t, res2.Err) + d2 := res2.Data.(*model.Group) + assert.Equal(t, d1.Id, d2.Id) + assert.Equal(t, d1.Name, d2.Name) + assert.Equal(t, d1.DisplayName, d2.DisplayName) + assert.Equal(t, d1.Description, d2.Description) + assert.Equal(t, d1.RemoteId, d2.RemoteId) + assert.Equal(t, d1.CreateAt, d2.CreateAt) + assert.Equal(t, d1.UpdateAt, d2.UpdateAt) + assert.Equal(t, d1.DeleteAt, d2.DeleteAt) + + // Get an invalid group + res3 := <-ss.Group().Get(model.NewId()) + assert.NotNil(t, res3.Err) + assert.Equal(t, res3.Err.Id, "store.sql_group.no_rows") +} + +func testGroupStoreGetByRemoteID(t *testing.T, ss store.Store) { + // Create a group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res1 := <-ss.Group().Create(g1) + assert.Nil(t, res1.Err) + d1 := res1.Data.(*model.Group) + assert.Len(t, d1.Id, 26) + + // Get the group + res2 := <-ss.Group().GetByRemoteID(d1.RemoteId, model.GroupSourceLdap) + assert.Nil(t, res2.Err) + d2 := res2.Data.(*model.Group) + assert.Equal(t, d1.Id, d2.Id) + assert.Equal(t, d1.Name, d2.Name) + assert.Equal(t, d1.DisplayName, d2.DisplayName) + assert.Equal(t, d1.Description, d2.Description) + assert.Equal(t, d1.RemoteId, d2.RemoteId) + assert.Equal(t, d1.CreateAt, d2.CreateAt) + assert.Equal(t, d1.UpdateAt, d2.UpdateAt) + assert.Equal(t, d1.DeleteAt, d2.DeleteAt) + + // Get an invalid group + res3 := <-ss.Group().GetByRemoteID(model.NewId(), model.GroupSource("fake")) + assert.NotNil(t, res3.Err) + assert.Equal(t, res3.Err.Id, "store.sql_group.no_rows") +} + +func testGroupStoreGetAllByType(t *testing.T, ss store.Store) { + numGroups := 10 + + groups := []*model.Group{} + + // Create groups + for i := 0; i < numGroups; i++ { + g := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + groups = append(groups, g) + res := <-ss.Group().Create(g) + assert.Nil(t, res.Err) + } + + // Returns all the groups + res1 := <-ss.Group().GetAllBySource(model.GroupSourceLdap) + d1 := res1.Data.([]*model.Group) + assert.Condition(t, func() bool { return len(d1) >= numGroups }) + for _, expectedGroup := range groups { + present := false + for _, dbGroup := range d1 { + if dbGroup.Id == expectedGroup.Id { + present = true + break + } + } + assert.True(t, present) + } +} + +func testGroupStoreUpdate(t *testing.T, ss store.Store) { + // Save a new group + g1 := &model.Group{ + Name: "g1-test", + DisplayName: model.NewId(), + Source: model.GroupSourceLdap, + Description: model.NewId(), + RemoteId: model.NewId(), + } + + // Create a group + res := <-ss.Group().Create(g1) + assert.Nil(t, res.Err) + d1 := res.Data.(*model.Group) + + // Update happy path + g1Update := &model.Group{} + *g1Update = *g1 + g1Update.Name = model.NewId() + g1Update.DisplayName = model.NewId() + g1Update.Description = model.NewId() + g1Update.RemoteId = model.NewId() + + res2 := <-ss.Group().Update(g1Update) + assert.Nil(t, res2.Err) + ud1 := res2.Data.(*model.Group) + // Not changed... + assert.Equal(t, d1.Id, ud1.Id) + assert.Equal(t, d1.CreateAt, ud1.CreateAt) + assert.Equal(t, d1.Source, ud1.Source) + // Still zero... + assert.Zero(t, ud1.DeleteAt) + // Updated... + assert.Equal(t, g1Update.Name, ud1.Name) + assert.Equal(t, g1Update.DisplayName, ud1.DisplayName) + assert.Equal(t, g1Update.Description, ud1.Description) + assert.Equal(t, g1Update.RemoteId, ud1.RemoteId) + + // Requires name and display name + res3 := <-ss.Group().Update(&model.Group{ + Id: d1.Id, + Name: "", + DisplayName: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + Description: model.NewId(), + }) + assert.Nil(t, res3.Data) + assert.NotNil(t, res3.Err) + assert.Equal(t, res3.Err.Id, "model.group.name.app_error") + + res4 := <-ss.Group().Update(&model.Group{ + Id: d1.Id, + Name: model.NewId(), + DisplayName: "", + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + }) + assert.Nil(t, res4.Data) + assert.NotNil(t, res4.Err) + assert.Equal(t, res4.Err.Id, "model.group.display_name.app_error") + + // Create another Group + g2 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Source: model.GroupSourceLdap, + Description: model.NewId(), + RemoteId: model.NewId(), + } + res5 := <-ss.Group().Create(g2) + assert.Nil(t, res5.Err) + d2 := res5.Data.(*model.Group) + + // Can't update the name to be a duplicate of an existing group's name + res6 := <-ss.Group().Update(&model.Group{ + Id: d2.Id, + Name: g1Update.Name, + DisplayName: model.NewId(), + Source: model.GroupSourceLdap, + Description: model.NewId(), + RemoteId: model.NewId(), + }) + assert.Equal(t, res6.Err.Id, "store.update_error") + + // Cannot update CreateAt + someVal := model.GetMillis() + d1.CreateAt = someVal + res7 := <-ss.Group().Update(d1) + d3 := res7.Data.(*model.Group) + assert.NotEqual(t, someVal, d3.CreateAt) + + // Cannot update DeleteAt to non-zero + d1.DeleteAt = 1 + res9 := <-ss.Group().Update(d1) + assert.Equal(t, "model.group.delete_at.app_error", res9.Err.Id) + + //...except for 0 for DeleteAt + d1.DeleteAt = 0 + res8 := <-ss.Group().Update(d1) + assert.Nil(t, res8.Err) + d4 := res8.Data.(*model.Group) + assert.Zero(t, d4.DeleteAt) +} + +func testGroupStoreDelete(t *testing.T, ss store.Store) { + // Save a group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + + res1 := <-ss.Group().Create(g1) + assert.Nil(t, res1.Err) + d1 := res1.Data.(*model.Group) + assert.Len(t, d1.Id, 26) + + // Check the group is retrievable + res2 := <-ss.Group().Get(d1.Id) + assert.Nil(t, res2.Err) + + // Get the before count + res7 := <-ss.Group().GetAllBySource(model.GroupSourceLdap) + d7 := res7.Data.([]*model.Group) + beforeCount := len(d7) + + // Delete the group + res3 := <-ss.Group().Delete(d1.Id) + assert.Nil(t, res3.Err) + + // Check the group is deleted + res4 := <-ss.Group().Get(d1.Id) + d4 := res4.Data.(*model.Group) + assert.NotZero(t, d4.DeleteAt) + + // Check the after count + res5 := <-ss.Group().GetAllBySource(model.GroupSourceLdap) + d5 := res5.Data.([]*model.Group) + afterCount := len(d5) + assert.Condition(t, func() bool { return beforeCount == afterCount+1 }) + + // Try and delete a nonexistent group + res6 := <-ss.Group().Delete(model.NewId()) + assert.NotNil(t, res6.Err) + assert.Equal(t, res6.Err.Id, "store.sql_group.no_rows") + + // Cannot delete again + res8 := <-ss.Group().Delete(d1.Id) + assert.Equal(t, res8.Err.Id, "store.sql_group.no_rows") +} + +func testGroupGetMemberUsers(t *testing.T, ss store.Store) { + // Save a group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res := <-ss.Group().Create(g1) + assert.Nil(t, res.Err) + group := res.Data.(*model.Group) + + u1 := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + res = <-ss.User().Save(u1) + assert.Nil(t, res.Err) + user1 := res.Data.(*model.User) + + res = <-ss.Group().CreateOrRestoreMember(group.Id, user1.Id) + assert.Nil(t, res.Err) + + u2 := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + res = <-ss.User().Save(u2) + assert.Nil(t, res.Err) + user2 := res.Data.(*model.User) + + res = <-ss.Group().CreateOrRestoreMember(group.Id, user2.Id) + assert.Nil(t, res.Err) + + // Check returns members + res = <-ss.Group().GetMemberUsers(group.Id) + assert.Nil(t, res.Err) + groupMembers := res.Data.([]*model.User) + assert.Equal(t, 2, len(groupMembers)) + + // Check madeup id + res = <-ss.Group().GetMemberUsers(model.NewId()) + assert.Equal(t, 0, len(res.Data.([]*model.User))) + + // Delete a member + <-ss.Group().DeleteMember(group.Id, user1.Id) + + // Should not return deleted members + res = <-ss.Group().GetMemberUsers(group.Id) + groupMembers = res.Data.([]*model.User) + assert.Equal(t, 1, len(groupMembers)) +} + +func testGroupGetMemberUsersPage(t *testing.T, ss store.Store) { + // Save a group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res := <-ss.Group().Create(g1) + assert.Nil(t, res.Err) + group := res.Data.(*model.Group) + + u1 := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + res = <-ss.User().Save(u1) + assert.Nil(t, res.Err) + user1 := res.Data.(*model.User) + + res = <-ss.Group().CreateOrRestoreMember(group.Id, user1.Id) + assert.Nil(t, res.Err) + + u2 := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + res = <-ss.User().Save(u2) + assert.Nil(t, res.Err) + user2 := res.Data.(*model.User) + + res = <-ss.Group().CreateOrRestoreMember(group.Id, user2.Id) + assert.Nil(t, res.Err) + + // Check returns members + res = <-ss.Group().GetMemberUsersPage(group.Id, 0, 100) + assert.Nil(t, res.Err) + groupMembers := res.Data.([]*model.User) + assert.Equal(t, 2, len(groupMembers)) + + // Check page 1 + res = <-ss.Group().GetMemberUsersPage(group.Id, 0, 1) + assert.Nil(t, res.Err) + groupMembers = res.Data.([]*model.User) + assert.Equal(t, 1, len(groupMembers)) + assert.Equal(t, user2.Id, groupMembers[0].Id) + + // Check page 2 + res = <-ss.Group().GetMemberUsersPage(group.Id, 1, 1) + assert.Nil(t, res.Err) + groupMembers = res.Data.([]*model.User) + assert.Equal(t, 1, len(groupMembers)) + assert.Equal(t, user1.Id, groupMembers[0].Id) + + // Check madeup id + res = <-ss.Group().GetMemberUsersPage(model.NewId(), 0, 100) + assert.Equal(t, 0, len(res.Data.([]*model.User))) + + // Delete a member + <-ss.Group().DeleteMember(group.Id, user1.Id) + + // Should not return deleted members + res = <-ss.Group().GetMemberUsersPage(group.Id, 0, 100) + groupMembers = res.Data.([]*model.User) + assert.Equal(t, 1, len(groupMembers)) +} + +func testGroupCreateOrRestoreMember(t *testing.T, ss store.Store) { + // Create group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res1 := <-ss.Group().Create(g1) + assert.Nil(t, res1.Err) + group := res1.Data.(*model.Group) + + // Create user + u1 := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + res2 := <-ss.User().Save(u1) + assert.Nil(t, res2.Err) + user := res2.Data.(*model.User) + + // Happy path + res3 := <-ss.Group().CreateOrRestoreMember(group.Id, user.Id) + assert.Nil(t, res3.Err) + d2 := res3.Data.(*model.GroupMember) + assert.Equal(t, d2.GroupId, group.Id) + assert.Equal(t, d2.UserId, user.Id) + assert.NotZero(t, d2.CreateAt) + assert.Zero(t, d2.DeleteAt) + + // Duplicate composite key (GroupId, UserId) + res4 := <-ss.Group().CreateOrRestoreMember(group.Id, user.Id) + assert.Equal(t, res4.Err.Id, "store.sql_group.uniqueness_error") + + // Invalid GroupId + res6 := <-ss.Group().CreateOrRestoreMember(model.NewId(), user.Id) + assert.Equal(t, res6.Err.Id, "store.insert_error") + + // Restores a deleted member + res := <-ss.Group().CreateOrRestoreMember(group.Id, user.Id) + assert.NotNil(t, res.Err) + + res = <-ss.Group().DeleteMember(group.Id, user.Id) + assert.Nil(t, res.Err) + + res = <-ss.Group().GetMemberUsers(group.Id) + beforeRestoreCount := len(res.Data.([]*model.User)) + + res = <-ss.Group().CreateOrRestoreMember(group.Id, user.Id) + assert.Nil(t, res.Err) + + res = <-ss.Group().GetMemberUsers(group.Id) + afterRestoreCount := len(res.Data.([]*model.User)) + + assert.Equal(t, beforeRestoreCount+1, afterRestoreCount) +} + +func testGroupDeleteMember(t *testing.T, ss store.Store) { + // Create group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res1 := <-ss.Group().Create(g1) + assert.Nil(t, res1.Err) + group := res1.Data.(*model.Group) + + // Create user + u1 := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + res2 := <-ss.User().Save(u1) + assert.Nil(t, res2.Err) + user := res2.Data.(*model.User) + + // Create member + res3 := <-ss.Group().CreateOrRestoreMember(group.Id, user.Id) + assert.Nil(t, res3.Err) + d1 := res3.Data.(*model.GroupMember) + + // Happy path + res4 := <-ss.Group().DeleteMember(group.Id, user.Id) + assert.Nil(t, res4.Err) + d2 := res4.Data.(*model.GroupMember) + assert.Equal(t, d2.GroupId, group.Id) + assert.Equal(t, d2.UserId, user.Id) + assert.Equal(t, d2.CreateAt, d1.CreateAt) + assert.NotZero(t, d2.DeleteAt) + + // Delete an already deleted member + res5 := <-ss.Group().DeleteMember(group.Id, user.Id) + assert.Equal(t, res5.Err.Id, "store.sql_group.no_rows") + + // Delete with non-existent User + res8 := <-ss.Group().DeleteMember(group.Id, model.NewId()) + assert.Equal(t, res8.Err.Id, "store.sql_group.no_rows") + + // Delete non-existent Group + res9 := <-ss.Group().DeleteMember(model.NewId(), group.Id) + assert.Equal(t, res9.Err.Id, "store.sql_group.no_rows") +} + +func testCreateGroupSyncable(t *testing.T, ss store.Store) { + // Invalid GroupID + res2 := <-ss.Group().CreateGroupSyncable(&model.GroupSyncable{ + GroupId: "x", + CanLeave: true, + SyncableId: string(model.NewId()), + Type: model.GroupSyncableTypeTeam, + }) + assert.Equal(t, res2.Err.Id, "model.group_syncable.group_id.app_error") + + // TODO: Add this validation test in phase 2 of LDAP groups sync. + // Invalid CanLeave/AutoAdd combo (both false) + // res3 := <-ss.Group().CreateGroupSyncable(&model.GroupSyncable{ + // GroupId: model.NewId(), + // CanLeave: false, + // AutoAdd: false, + // SyncableId: string(model.NewId()), + // Type: model.GroupSyncableTypeTeam, + // }) + // assert.Equal(t, res3.Err.Id, "model.group_syncable.invalid_state") + + // Create Group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res4 := <-ss.Group().Create(g1) + assert.Nil(t, res4.Err) + group := res4.Data.(*model.Group) + + // Create Team + t1 := &model.Team{ + DisplayName: "Name", + Description: "Some description", + CompanyName: "Some company name", + AllowOpenInvite: false, + InviteId: "inviteid0", + Name: "z-z-" + model.NewId() + "a", + Email: "success+" + model.NewId() + "@simulator.amazonses.com", + Type: model.TEAM_OPEN, + } + res5 := <-ss.Team().Save(t1) + assert.Nil(t, res5.Err) + team := res5.Data.(*model.Team) + + // New GroupSyncable, happy path + gt1 := &model.GroupSyncable{ + GroupId: group.Id, + CanLeave: true, + AutoAdd: false, + SyncableId: string(team.Id), + Type: model.GroupSyncableTypeTeam, + } + res6 := <-ss.Group().CreateGroupSyncable(gt1) + assert.Nil(t, res6.Err) + d1 := res6.Data.(*model.GroupSyncable) + assert.Equal(t, gt1.SyncableId, d1.SyncableId) + assert.Equal(t, gt1.GroupId, d1.GroupId) + assert.Equal(t, gt1.CanLeave, d1.CanLeave) + assert.Equal(t, gt1.AutoAdd, d1.AutoAdd) + assert.NotZero(t, d1.CreateAt) + assert.Zero(t, d1.DeleteAt) +} + +func testGetGroupSyncable(t *testing.T, ss store.Store) { + // Create a group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res1 := <-ss.Group().Create(g1) + assert.Nil(t, res1.Err) + group := res1.Data.(*model.Group) + + // Create Team + t1 := &model.Team{ + DisplayName: "Name", + Description: "Some description", + CompanyName: "Some company name", + AllowOpenInvite: false, + InviteId: "inviteid0", + Name: "z-z-" + model.NewId() + "a", + Email: "success+" + model.NewId() + "@simulator.amazonses.com", + Type: model.TEAM_OPEN, + } + res2 := <-ss.Team().Save(t1) + assert.Nil(t, res2.Err) + team := res2.Data.(*model.Team) + + // Create GroupSyncable + gt1 := &model.GroupSyncable{ + GroupId: group.Id, + CanLeave: true, + AutoAdd: false, + SyncableId: string(team.Id), + Type: model.GroupSyncableTypeTeam, + } + res3 := <-ss.Group().CreateGroupSyncable(gt1) + assert.Nil(t, res3.Err) + groupTeam := res3.Data.(*model.GroupSyncable) + + // Get GroupSyncable + res4 := <-ss.Group().GetGroupSyncable(groupTeam.GroupId, groupTeam.SyncableId, model.GroupSyncableTypeTeam) + assert.Nil(t, res4.Err) + dgt := res4.Data.(*model.GroupSyncable) + assert.Equal(t, gt1.GroupId, dgt.GroupId) + assert.Equal(t, gt1.SyncableId, dgt.SyncableId) + // assert.Equal(t, gt1.CanLeave, dgt.CanLeave) // TODO: Re-add this test in phase 2 of LDAP groups sync. + assert.Equal(t, gt1.AutoAdd, dgt.AutoAdd) + assert.NotZero(t, gt1.CreateAt) + assert.NotZero(t, gt1.UpdateAt) + assert.Zero(t, gt1.DeleteAt) +} + +func testGetAllGroupSyncablesByGroup(t *testing.T, ss store.Store) { + numGroupSyncables := 10 + + // Create group + g := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res1 := <-ss.Group().Create(g) + assert.Nil(t, res1.Err) + group := res1.Data.(*model.Group) + + groupTeams := []*model.GroupSyncable{} + + // Create groupTeams + for i := 0; i < numGroupSyncables; i++ { + // Create Team + t1 := &model.Team{ + DisplayName: "Name", + Description: "Some description", + CompanyName: "Some company name", + AllowOpenInvite: false, + InviteId: "inviteid0", + Name: "z-z-" + model.NewId() + "a", + Email: "success+" + model.NewId() + "@simulator.amazonses.com", + Type: model.TEAM_OPEN, + } + res2 := <-ss.Team().Save(t1) + assert.Nil(t, res2.Err) + team := res2.Data.(*model.Team) + + // create groupteam + res3 := <-ss.Group().CreateGroupSyncable(&model.GroupSyncable{ + GroupId: group.Id, + CanLeave: true, + SyncableId: string(team.Id), + Type: model.GroupSyncableTypeTeam, + }) + assert.Nil(t, res3.Err) + groupTeam := res3.Data.(*model.GroupSyncable) + groupTeams = append(groupTeams, groupTeam) + } + + // Returns all the group teams + res4 := <-ss.Group().GetAllGroupSyncablesByGroupId(group.Id, model.GroupSyncableTypeTeam) + d1 := res4.Data.([]*model.GroupSyncable) + assert.Condition(t, func() bool { return len(d1) >= numGroupSyncables }) + for _, expectedGroupTeam := range groupTeams { + present := false + for _, dbGroupTeam := range d1 { + if dbGroupTeam.GroupId == expectedGroupTeam.GroupId && dbGroupTeam.SyncableId == expectedGroupTeam.SyncableId { + present = true + break + } + } + assert.True(t, present) + } +} + +func testUpdateGroupSyncable(t *testing.T, ss store.Store) { + // Create Group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res4 := <-ss.Group().Create(g1) + assert.Nil(t, res4.Err) + group := res4.Data.(*model.Group) + + // Create Team + t1 := &model.Team{ + DisplayName: "Name", + Description: "Some description", + CompanyName: "Some company name", + AllowOpenInvite: false, + InviteId: "inviteid0", + Name: "z-z-" + model.NewId() + "a", + Email: "success+" + model.NewId() + "@simulator.amazonses.com", + Type: model.TEAM_OPEN, + } + res5 := <-ss.Team().Save(t1) + assert.Nil(t, res5.Err) + team := res5.Data.(*model.Team) + + // New GroupSyncable, happy path + gt1 := &model.GroupSyncable{ + GroupId: group.Id, + CanLeave: true, + AutoAdd: false, + SyncableId: string(team.Id), + Type: model.GroupSyncableTypeTeam, + } + res6 := <-ss.Group().CreateGroupSyncable(gt1) + assert.Nil(t, res6.Err) + d1 := res6.Data.(*model.GroupSyncable) + + // Update existing group team + gt1.CanLeave = false + gt1.AutoAdd = true + res7 := <-ss.Group().UpdateGroupSyncable(gt1) + assert.Nil(t, res7.Err) + d2 := res7.Data.(*model.GroupSyncable) + assert.False(t, d2.CanLeave) + assert.True(t, d2.AutoAdd) + + // TODO: Add this validation check test in phase 2 of LDAP groups sync. + // Update to invalid state + // gt1.AutoAdd = false + // gt1.CanLeave = false + // res8 := <-ss.Group().UpdateGroupSyncable(gt1) + // assert.Equal(t, res8.Err.Id, "model.group_syncable.invalid_state") + + // Non-existent Group + gt2 := &model.GroupSyncable{ + GroupId: model.NewId(), + CanLeave: true, + AutoAdd: false, + SyncableId: string(team.Id), + Type: model.GroupSyncableTypeTeam, + } + res9 := <-ss.Group().UpdateGroupSyncable(gt2) + assert.Equal(t, res9.Err.Id, "store.sql_group.no_rows") + + // Non-existent Team + gt3 := &model.GroupSyncable{ + GroupId: group.Id, + CanLeave: true, + AutoAdd: false, + SyncableId: string(model.NewId()), + Type: model.GroupSyncableTypeTeam, + } + res10 := <-ss.Group().UpdateGroupSyncable(gt3) + assert.Equal(t, res10.Err.Id, "store.sql_group.no_rows") + + // Cannot update CreateAt or DeleteAt + origCreateAt := d1.CreateAt + d1.CreateAt = model.GetMillis() + d1.AutoAdd = true + d1.CanLeave = true + res11 := <-ss.Group().UpdateGroupSyncable(d1) + assert.Nil(t, res11.Err) + d3 := res11.Data.(*model.GroupSyncable) + assert.Equal(t, origCreateAt, d3.CreateAt) + + // Cannot update DeleteAt to arbitrary value + d1.DeleteAt = 1 + res12 := <-ss.Group().UpdateGroupSyncable(d1) + assert.Equal(t, "model.group.delete_at.app_error", res12.Err.Id) + + // Can update DeleteAt to 0 + d1.DeleteAt = 0 + res13 := <-ss.Group().UpdateGroupSyncable(d1) + assert.Nil(t, res13.Err) + d4 := res13.Data.(*model.GroupSyncable) + assert.Zero(t, d4.DeleteAt) +} + +func testDeleteGroupSyncable(t *testing.T, ss store.Store) { + // Create Group + g1 := &model.Group{ + Name: model.NewId(), + DisplayName: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + res1 := <-ss.Group().Create(g1) + assert.Nil(t, res1.Err) + group := res1.Data.(*model.Group) + + // Create Team + t1 := &model.Team{ + DisplayName: "Name", + Description: "Some description", + CompanyName: "Some company name", + AllowOpenInvite: false, + InviteId: "inviteid0", + Name: "z-z-" + model.NewId() + "a", + Email: "success+" + model.NewId() + "@simulator.amazonses.com", + Type: model.TEAM_OPEN, + } + res2 := <-ss.Team().Save(t1) + assert.Nil(t, res2.Err) + team := res2.Data.(*model.Team) + + // Create GroupSyncable + gt1 := &model.GroupSyncable{ + GroupId: group.Id, + CanLeave: true, + AutoAdd: false, + SyncableId: string(team.Id), + Type: model.GroupSyncableTypeTeam, + } + res7 := <-ss.Group().CreateGroupSyncable(gt1) + assert.Nil(t, res7.Err) + groupTeam := res7.Data.(*model.GroupSyncable) + + // Non-existent Group + res5 := <-ss.Group().DeleteGroupSyncable(model.NewId(), groupTeam.SyncableId, model.GroupSyncableTypeTeam) + assert.Equal(t, res5.Err.Id, "store.sql_group.no_rows") + + // Non-existent Team + res6 := <-ss.Group().DeleteGroupSyncable(groupTeam.GroupId, string(model.NewId()), model.GroupSyncableTypeTeam) + assert.Equal(t, res6.Err.Id, "store.sql_group.no_rows") + + // Happy path... + res8 := <-ss.Group().DeleteGroupSyncable(groupTeam.GroupId, groupTeam.SyncableId, model.GroupSyncableTypeTeam) + assert.Nil(t, res8.Err) + d1 := res8.Data.(*model.GroupSyncable) + assert.NotZero(t, d1.DeleteAt) + assert.Equal(t, d1.GroupId, groupTeam.GroupId) + assert.Equal(t, d1.SyncableId, groupTeam.SyncableId) + // assert.Equal(t, d1.CanLeave, groupTeam.CanLeave) // TODO: Re-add this test in phase 2 of LDAP groups sync. + assert.Equal(t, d1.AutoAdd, groupTeam.AutoAdd) + assert.Equal(t, d1.CreateAt, groupTeam.CreateAt) + assert.Condition(t, func() bool { return d1.UpdateAt > groupTeam.UpdateAt }) + + // Record already deleted + res9 := <-ss.Group().DeleteGroupSyncable(d1.GroupId, d1.SyncableId, d1.Type) + assert.NotNil(t, res9.Err) + assert.Equal(t, res9.Err.Id, "store.sql_group.group_syncable_already_deleted") +} + +func testPendingAutoAddTeamMembers(t *testing.T, ss store.Store) { + // Create Group + res := <-ss.Group().Create(&model.Group{ + Name: model.NewId(), + DisplayName: "PendingAutoAddTeamMembers Test Group", + RemoteId: model.NewId(), + Source: model.GroupSourceLdap, + }) + assert.Nil(t, res.Err) + group := res.Data.(*model.Group) + + // Create User + user := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + res = <-ss.User().Save(user) + assert.Nil(t, res.Err) + user = res.Data.(*model.User) + + // Create GroupMember + res = <-ss.Group().CreateOrRestoreMember(group.Id, user.Id) + assert.Nil(t, res.Err) + + // Create Team + team := &model.Team{ + DisplayName: "Name", + Description: "Some description", + CompanyName: "Some company name", + AllowOpenInvite: false, + InviteId: "inviteid0", + Name: "z-z-" + model.NewId() + "a", + Email: "success+" + model.NewId() + "@simulator.amazonses.com", + Type: model.TEAM_OPEN, + } + res = <-ss.Team().Save(team) + assert.Nil(t, res.Err) + team = res.Data.(*model.Team) + + // Create GroupTeam + res = <-ss.Group().CreateGroupSyncable(&model.GroupSyncable{ + AutoAdd: true, + CanLeave: true, + SyncableId: team.Id, + Type: model.GroupSyncableTypeTeam, + GroupId: group.Id, + }) + assert.Nil(t, res.Err) + syncable := res.Data.(*model.GroupSyncable) + + // Time before syncable was created + res = <-ss.Group().PendingAutoAddTeamMembers(syncable.CreateAt - 1) + assert.Nil(t, res.Err) + userTeamIDs := res.Data.([]*model.UserTeamIDPair) + assert.Len(t, userTeamIDs, 1) + assert.Equal(t, user.Id, userTeamIDs[0].UserID) + assert.Equal(t, team.Id, userTeamIDs[0].TeamID) + + // Time after syncable was created + res = <-ss.Group().PendingAutoAddTeamMembers(syncable.CreateAt + 1) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // Delete and restore GroupMember should return result + res = <-ss.Group().DeleteMember(group.Id, user.Id) + assert.Nil(t, res.Err) + res = <-ss.Group().CreateOrRestoreMember(group.Id, user.Id) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddTeamMembers(syncable.CreateAt + 1) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 1) + + pristineSyncable := *syncable + + syncable.CanLeave = false + res = <-ss.Group().UpdateGroupSyncable(syncable) + assert.Nil(t, res.Err) + + // Time before syncable was updated + res = <-ss.Group().PendingAutoAddTeamMembers(syncable.UpdateAt - 1) + assert.Nil(t, res.Err) + userTeamIDs = res.Data.([]*model.UserTeamIDPair) + assert.Len(t, userTeamIDs, 1) + assert.Equal(t, user.Id, userTeamIDs[0].UserID) + assert.Equal(t, team.Id, userTeamIDs[0].TeamID) + + // Time after syncable was updated + res = <-ss.Group().PendingAutoAddTeamMembers(syncable.UpdateAt + 1) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // Only includes if auto-add + syncable.AutoAdd = false + syncable.CanLeave = true // have to update this or the model isn't valid + res = <-ss.Group().UpdateGroupSyncable(syncable) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddTeamMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // reset state of syncable and verify + res = <-ss.Group().UpdateGroupSyncable(&pristineSyncable) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddTeamMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 1) + + // No result if Group deleted + res = <-ss.Group().Delete(group.Id) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddTeamMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // reset state of group and verify + group.DeleteAt = 0 + res = <-ss.Group().Update(group) + res = <-ss.Group().PendingAutoAddTeamMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 1) + + // No result if Team deleted + team.DeleteAt = model.GetMillis() + res = <-ss.Team().Update(team) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddTeamMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // reset state of team and verify + team.DeleteAt = 0 + res = <-ss.Team().Update(team) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddTeamMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 1) + + // No result if GroupTeam deleted + res = <-ss.Group().DeleteGroupSyncable(group.Id, team.Id, model.GroupSyncableTypeTeam) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddTeamMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // reset GroupTeam and verify + res = <-ss.Group().UpdateGroupSyncable(&pristineSyncable) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddTeamMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 1) + + // No result if GroupMember deleted + res = <-ss.Group().DeleteMember(group.Id, user.Id) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddTeamMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // restore group member and verify + res = <-ss.Group().CreateOrRestoreMember(group.Id, user.Id) + res = <-ss.Group().PendingAutoAddTeamMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 1) + + // adding team membership stops returning result + res = <-ss.Team().SaveMember(&model.TeamMember{ + TeamId: team.Id, + UserId: user.Id, + }, 999) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddTeamMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) +} + +func testPendingAutoAddChannelMembers(t *testing.T, ss store.Store) { + // Create Group + res := <-ss.Group().Create(&model.Group{ + Name: model.NewId(), + DisplayName: "PendingAutoAddChannelMembers Test Group", + RemoteId: model.NewId(), + Source: model.GroupSourceLdap, + }) + assert.Nil(t, res.Err) + group := res.Data.(*model.Group) + + // Create User + user := &model.User{ + Email: MakeEmail(), + Username: model.NewId(), + } + res = <-ss.User().Save(user) + assert.Nil(t, res.Err) + user = res.Data.(*model.User) + + // Create GroupMember + res = <-ss.Group().CreateOrRestoreMember(group.Id, user.Id) + assert.Nil(t, res.Err) + + // Create Channel + channel := &model.Channel{ + TeamId: model.NewId(), + DisplayName: "A Name", + Name: model.NewId(), + Type: model.CHANNEL_OPEN, // Query does not look at type so this shouldn't matter. + } + res = <-ss.Channel().Save(channel, 9999) + assert.Nil(t, res.Err) + channel = res.Data.(*model.Channel) + + // Create GroupChannel + res = <-ss.Group().CreateGroupSyncable(&model.GroupSyncable{ + AutoAdd: true, + CanLeave: true, + SyncableId: channel.Id, + Type: model.GroupSyncableTypeChannel, + GroupId: group.Id, + }) + assert.Nil(t, res.Err) + syncable := res.Data.(*model.GroupSyncable) + + // Time before syncable was created + res = <-ss.Group().PendingAutoAddChannelMembers(syncable.CreateAt - 1) + assert.Nil(t, res.Err) + userChannelIDs := res.Data.([]*model.UserChannelIDPair) + assert.Len(t, userChannelIDs, 1) + assert.Equal(t, user.Id, userChannelIDs[0].UserID) + assert.Equal(t, channel.Id, userChannelIDs[0].ChannelID) + + // Time after syncable was created + res = <-ss.Group().PendingAutoAddChannelMembers(syncable.CreateAt + 1) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // Delete and restore GroupMember should return result + res = <-ss.Group().DeleteMember(group.Id, user.Id) + assert.Nil(t, res.Err) + res = <-ss.Group().CreateOrRestoreMember(group.Id, user.Id) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddChannelMembers(syncable.CreateAt + 1) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 1) + + pristineSyncable := *syncable + + syncable.CanLeave = false + res = <-ss.Group().UpdateGroupSyncable(syncable) + assert.Nil(t, res.Err) + + // Time before syncable was updated + res = <-ss.Group().PendingAutoAddChannelMembers(syncable.UpdateAt - 1) + assert.Nil(t, res.Err) + userChannelIDs = res.Data.([]*model.UserChannelIDPair) + assert.Len(t, userChannelIDs, 1) + assert.Equal(t, user.Id, userChannelIDs[0].UserID) + assert.Equal(t, channel.Id, userChannelIDs[0].ChannelID) + + // Time after syncable was updated + res = <-ss.Group().PendingAutoAddChannelMembers(syncable.UpdateAt + 1) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // Only includes if auto-add + syncable.AutoAdd = false + syncable.CanLeave = true // have to update this or the model isn't valid + res = <-ss.Group().UpdateGroupSyncable(syncable) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddChannelMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // reset state of syncable and verify + res = <-ss.Group().UpdateGroupSyncable(&pristineSyncable) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddChannelMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 1) + + // No result if Group deleted + res = <-ss.Group().Delete(group.Id) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddChannelMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // reset state of group and verify + group.DeleteAt = 0 + res = <-ss.Group().Update(group) + res = <-ss.Group().PendingAutoAddChannelMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 1) + + // No result if Channel deleted + res = <-ss.Channel().Delete(channel.Id, model.GetMillis()) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddChannelMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // reset state of channel and verify + channel.DeleteAt = 0 + res = <-ss.Channel().Update(channel) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddChannelMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 1) + + // No result if GroupChannel deleted + res = <-ss.Group().DeleteGroupSyncable(group.Id, channel.Id, model.GroupSyncableTypeChannel) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddChannelMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // reset GroupChannel and verify + res = <-ss.Group().UpdateGroupSyncable(&pristineSyncable) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddChannelMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 1) + + // No result if GroupMember deleted + res = <-ss.Group().DeleteMember(group.Id, user.Id) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddChannelMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // restore group member and verify + res = <-ss.Group().CreateOrRestoreMember(group.Id, user.Id) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddChannelMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 1) + + // Adding Channel (ChannelMemberHistory) should stop returning result + res = <-ss.ChannelMemberHistory().LogJoinEvent(user.Id, channel.Id, model.GetMillis()) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddChannelMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // Leaving Channel (ChannelMemberHistory) should still not return result + res = <-ss.ChannelMemberHistory().LogLeaveEvent(user.Id, channel.Id, model.GetMillis()) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddChannelMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 0) + + // Purging ChannelMemberHistory re-returns the result + res = <-ss.ChannelMemberHistory().PermanentDeleteBatch(model.GetMillis()+1, 100) + assert.Nil(t, res.Err) + res = <-ss.Group().PendingAutoAddChannelMembers(0) + assert.Nil(t, res.Err) + assert.Len(t, res.Data, 1) +} diff --git a/store/storetest/mocks/ChannelStore.go b/store/storetest/mocks/ChannelStore.go index b12d2a0009..a0094301be 100644 --- a/store/storetest/mocks/ChannelStore.go +++ b/store/storetest/mocks/ChannelStore.go @@ -194,6 +194,22 @@ func (_m *ChannelStore) GetAllChannelMembersNotifyPropsForChannel(channelId stri return r0 } +// GetAllChannels provides a mock function with given fields: page, perPage, includeDeleted +func (_m *ChannelStore) GetAllChannels(page int, perPage int, includeDeleted bool) store.StoreChannel { + ret := _m.Called(page, perPage, includeDeleted) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(int, int, bool) store.StoreChannel); ok { + r0 = rf(page, perPage, includeDeleted) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + // GetAllChannelsForExportAfter provides a mock function with given fields: limit, afterId func (_m *ChannelStore) GetAllChannelsForExportAfter(limit int, afterId string) store.StoreChannel { ret := _m.Called(limit, afterId) @@ -885,6 +901,22 @@ func (_m *ChannelStore) SaveMember(member *model.ChannelMember) store.StoreChann return r0 } +// SearchAllChannels provides a mock function with given fields: term, includeDeleted +func (_m *ChannelStore) SearchAllChannels(term string, includeDeleted bool) store.StoreChannel { + ret := _m.Called(term, includeDeleted) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string, bool) store.StoreChannel); ok { + r0 = rf(term, includeDeleted) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + // SearchInTeam provides a mock function with given fields: teamId, term, includeDeleted func (_m *ChannelStore) SearchInTeam(teamId string, term string, includeDeleted bool) store.StoreChannel { ret := _m.Called(teamId, term, includeDeleted) diff --git a/store/storetest/mocks/GroupStore.go b/store/storetest/mocks/GroupStore.go new file mode 100644 index 0000000000..36f8f8787b --- /dev/null +++ b/store/storetest/mocks/GroupStore.go @@ -0,0 +1,302 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import mock "github.com/stretchr/testify/mock" +import model "github.com/mattermost/mattermost-server/model" +import store "github.com/mattermost/mattermost-server/store" + +// GroupStore is an autogenerated mock type for the GroupStore type +type GroupStore struct { + mock.Mock +} + +// Create provides a mock function with given fields: group +func (_m *GroupStore) Create(group *model.Group) store.StoreChannel { + ret := _m.Called(group) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(*model.Group) store.StoreChannel); ok { + r0 = rf(group) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// CreateGroupSyncable provides a mock function with given fields: groupSyncable +func (_m *GroupStore) CreateGroupSyncable(groupSyncable *model.GroupSyncable) store.StoreChannel { + ret := _m.Called(groupSyncable) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(*model.GroupSyncable) store.StoreChannel); ok { + r0 = rf(groupSyncable) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// CreateOrRestoreMember provides a mock function with given fields: groupID, userID +func (_m *GroupStore) CreateOrRestoreMember(groupID string, userID string) store.StoreChannel { + ret := _m.Called(groupID, userID) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string, string) store.StoreChannel); ok { + r0 = rf(groupID, userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// Delete provides a mock function with given fields: groupID +func (_m *GroupStore) Delete(groupID string) store.StoreChannel { + ret := _m.Called(groupID) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string) store.StoreChannel); ok { + r0 = rf(groupID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// DeleteGroupSyncable provides a mock function with given fields: groupID, syncableID, syncableType +func (_m *GroupStore) DeleteGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) store.StoreChannel { + ret := _m.Called(groupID, syncableID, syncableType) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string, string, model.GroupSyncableType) store.StoreChannel); ok { + r0 = rf(groupID, syncableID, syncableType) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// DeleteMember provides a mock function with given fields: groupID, userID +func (_m *GroupStore) DeleteMember(groupID string, userID string) store.StoreChannel { + ret := _m.Called(groupID, userID) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string, string) store.StoreChannel); ok { + r0 = rf(groupID, userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// Get provides a mock function with given fields: groupID +func (_m *GroupStore) Get(groupID string) store.StoreChannel { + ret := _m.Called(groupID) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string) store.StoreChannel); ok { + r0 = rf(groupID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// GetAllBySource provides a mock function with given fields: groupSource +func (_m *GroupStore) GetAllBySource(groupSource model.GroupSource) store.StoreChannel { + ret := _m.Called(groupSource) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(model.GroupSource) store.StoreChannel); ok { + r0 = rf(groupSource) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// GetAllGroupSyncablesByGroupId provides a mock function with given fields: groupID, syncableType +func (_m *GroupStore) GetAllGroupSyncablesByGroupId(groupID string, syncableType model.GroupSyncableType) store.StoreChannel { + ret := _m.Called(groupID, syncableType) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string, model.GroupSyncableType) store.StoreChannel); ok { + r0 = rf(groupID, syncableType) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// GetByRemoteID provides a mock function with given fields: remoteID, groupSource +func (_m *GroupStore) GetByRemoteID(remoteID string, groupSource model.GroupSource) store.StoreChannel { + ret := _m.Called(remoteID, groupSource) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string, model.GroupSource) store.StoreChannel); ok { + r0 = rf(remoteID, groupSource) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// GetGroupSyncable provides a mock function with given fields: groupID, syncableID, syncableType +func (_m *GroupStore) GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) store.StoreChannel { + ret := _m.Called(groupID, syncableID, syncableType) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string, string, model.GroupSyncableType) store.StoreChannel); ok { + r0 = rf(groupID, syncableID, syncableType) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// GetMemberCount provides a mock function with given fields: groupID +func (_m *GroupStore) GetMemberCount(groupID string) store.StoreChannel { + ret := _m.Called(groupID) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string) store.StoreChannel); ok { + r0 = rf(groupID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// GetMemberUsers provides a mock function with given fields: groupID +func (_m *GroupStore) GetMemberUsers(groupID string) store.StoreChannel { + ret := _m.Called(groupID) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string) store.StoreChannel); ok { + r0 = rf(groupID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// GetMemberUsersPage provides a mock function with given fields: groupID, offset, limit +func (_m *GroupStore) GetMemberUsersPage(groupID string, offset int, limit int) store.StoreChannel { + ret := _m.Called(groupID, offset, limit) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(string, int, int) store.StoreChannel); ok { + r0 = rf(groupID, offset, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// PendingAutoAddChannelMembers provides a mock function with given fields: minGroupMembersCreateAt +func (_m *GroupStore) PendingAutoAddChannelMembers(minGroupMembersCreateAt int64) store.StoreChannel { + ret := _m.Called(minGroupMembersCreateAt) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(int64) store.StoreChannel); ok { + r0 = rf(minGroupMembersCreateAt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// PendingAutoAddTeamMembers provides a mock function with given fields: minGroupMembersCreateAt +func (_m *GroupStore) PendingAutoAddTeamMembers(minGroupMembersCreateAt int64) store.StoreChannel { + ret := _m.Called(minGroupMembersCreateAt) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(int64) store.StoreChannel); ok { + r0 = rf(minGroupMembersCreateAt) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// Update provides a mock function with given fields: group +func (_m *GroupStore) Update(group *model.Group) store.StoreChannel { + ret := _m.Called(group) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(*model.Group) store.StoreChannel); ok { + r0 = rf(group) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} + +// UpdateGroupSyncable provides a mock function with given fields: groupSyncable +func (_m *GroupStore) UpdateGroupSyncable(groupSyncable *model.GroupSyncable) store.StoreChannel { + ret := _m.Called(groupSyncable) + + var r0 store.StoreChannel + if rf, ok := ret.Get(0).(func(*model.GroupSyncable) store.StoreChannel); ok { + r0 = rf(groupSyncable) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.StoreChannel) + } + } + + return r0 +} diff --git a/store/storetest/mocks/LayeredStoreDatabaseLayer.go b/store/storetest/mocks/LayeredStoreDatabaseLayer.go index eb0881dd85..8234c0594d 100644 --- a/store/storetest/mocks/LayeredStoreDatabaseLayer.go +++ b/store/storetest/mocks/LayeredStoreDatabaseLayer.go @@ -168,6 +168,390 @@ func (_m *LayeredStoreDatabaseLayer) FileInfo() store.FileInfoStore { return r0 } +// Group provides a mock function with given fields: +func (_m *LayeredStoreDatabaseLayer) Group() store.GroupStore { + ret := _m.Called() + + var r0 store.GroupStore + if rf, ok := ret.Get(0).(func() store.GroupStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.GroupStore) + } + } + + return r0 +} + +// GroupCreate provides a mock function with given fields: ctx, group, hints +func (_m *LayeredStoreDatabaseLayer) GroupCreate(ctx context.Context, group *model.Group, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, group) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, *model.Group, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, group, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupCreateGroupSyncable provides a mock function with given fields: ctx, groupSyncable, hints +func (_m *LayeredStoreDatabaseLayer) GroupCreateGroupSyncable(ctx context.Context, groupSyncable *model.GroupSyncable, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupSyncable) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, *model.GroupSyncable, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupSyncable, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupCreateOrRestoreMember provides a mock function with given fields: ctx, groupID, userID, hints +func (_m *LayeredStoreDatabaseLayer) GroupCreateOrRestoreMember(ctx context.Context, groupID string, userID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID, userID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, string, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, userID, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupDelete provides a mock function with given fields: ctx, groupID, hints +func (_m *LayeredStoreDatabaseLayer) GroupDelete(ctx context.Context, groupID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupDeleteGroupSyncable provides a mock function with given fields: ctx, groupID, syncableID, syncableType, hints +func (_m *LayeredStoreDatabaseLayer) GroupDeleteGroupSyncable(ctx context.Context, groupID string, syncableID string, syncableType model.GroupSyncableType, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID, syncableID, syncableType) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, string, model.GroupSyncableType, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, syncableID, syncableType, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupDeleteMember provides a mock function with given fields: ctx, groupID, userID, hints +func (_m *LayeredStoreDatabaseLayer) GroupDeleteMember(ctx context.Context, groupID string, userID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID, userID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, string, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, userID, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGet provides a mock function with given fields: ctx, groupID, hints +func (_m *LayeredStoreDatabaseLayer) GroupGet(ctx context.Context, groupID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetAllBySource provides a mock function with given fields: ctx, groupSource, hints +func (_m *LayeredStoreDatabaseLayer) GroupGetAllBySource(ctx context.Context, groupSource model.GroupSource, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupSource) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, model.GroupSource, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupSource, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetAllGroupSyncablesByGroup provides a mock function with given fields: ctx, groupID, syncableType, hints +func (_m *LayeredStoreDatabaseLayer) GroupGetAllGroupSyncablesByGroup(ctx context.Context, groupID string, syncableType model.GroupSyncableType, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID, syncableType) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, model.GroupSyncableType, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, syncableType, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetByRemoteID provides a mock function with given fields: ctx, remoteID, groupSource, hints +func (_m *LayeredStoreDatabaseLayer) GroupGetByRemoteID(ctx context.Context, remoteID string, groupSource model.GroupSource, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, remoteID, groupSource) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, model.GroupSource, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, remoteID, groupSource, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetGroupSyncable provides a mock function with given fields: ctx, groupID, syncableID, syncableType, hints +func (_m *LayeredStoreDatabaseLayer) GroupGetGroupSyncable(ctx context.Context, groupID string, syncableID string, syncableType model.GroupSyncableType, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID, syncableID, syncableType) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, string, model.GroupSyncableType, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, syncableID, syncableType, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetMemberCount provides a mock function with given fields: ctx, groupID, hints +func (_m *LayeredStoreDatabaseLayer) GroupGetMemberCount(ctx context.Context, groupID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetMemberUsers provides a mock function with given fields: ctx, groupID, hints +func (_m *LayeredStoreDatabaseLayer) GroupGetMemberUsers(ctx context.Context, groupID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetMemberUsersPage provides a mock function with given fields: ctx, groupID, offset, limit, hints +func (_m *LayeredStoreDatabaseLayer) GroupGetMemberUsersPage(ctx context.Context, groupID string, offset int, limit int, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID, offset, limit) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, int, int, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, offset, limit, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupUpdate provides a mock function with given fields: ctx, group, hints +func (_m *LayeredStoreDatabaseLayer) GroupUpdate(ctx context.Context, group *model.Group, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, group) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, *model.Group, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, group, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupUpdateGroupSyncable provides a mock function with given fields: ctx, groupSyncable, hints +func (_m *LayeredStoreDatabaseLayer) GroupUpdateGroupSyncable(ctx context.Context, groupSyncable *model.GroupSyncable, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupSyncable) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, *model.GroupSyncable, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupSyncable, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + // Job provides a mock function with given fields: func (_m *LayeredStoreDatabaseLayer) Job() store.JobStore { ret := _m.Called() @@ -242,6 +626,52 @@ func (_m *LayeredStoreDatabaseLayer) OAuth() store.OAuthStore { return r0 } +// PendingAutoAddChannelMembers provides a mock function with given fields: ctx, minGroupMembersCreateAt, hints +func (_m *LayeredStoreDatabaseLayer) PendingAutoAddChannelMembers(ctx context.Context, minGroupMembersCreateAt int64, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, minGroupMembersCreateAt) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, int64, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, minGroupMembersCreateAt, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// PendingAutoAddTeamMembers provides a mock function with given fields: ctx, minGroupMembersCreateAt, hints +func (_m *LayeredStoreDatabaseLayer) PendingAutoAddTeamMembers(ctx context.Context, minGroupMembersCreateAt int64, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, minGroupMembersCreateAt) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, int64, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, minGroupMembersCreateAt, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + // Plugin provides a mock function with given fields: func (_m *LayeredStoreDatabaseLayer) Plugin() store.PluginStore { ret := _m.Called() diff --git a/store/storetest/mocks/LayeredStoreSupplier.go b/store/storetest/mocks/LayeredStoreSupplier.go index 4b3da6efc7..efcb1dbdb3 100644 --- a/store/storetest/mocks/LayeredStoreSupplier.go +++ b/store/storetest/mocks/LayeredStoreSupplier.go @@ -14,6 +14,374 @@ type LayeredStoreSupplier struct { mock.Mock } +// GroupCreate provides a mock function with given fields: ctx, group, hints +func (_m *LayeredStoreSupplier) GroupCreate(ctx context.Context, group *model.Group, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, group) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, *model.Group, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, group, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupCreateGroupSyncable provides a mock function with given fields: ctx, groupSyncable, hints +func (_m *LayeredStoreSupplier) GroupCreateGroupSyncable(ctx context.Context, groupSyncable *model.GroupSyncable, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupSyncable) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, *model.GroupSyncable, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupSyncable, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupCreateOrRestoreMember provides a mock function with given fields: ctx, groupID, userID, hints +func (_m *LayeredStoreSupplier) GroupCreateOrRestoreMember(ctx context.Context, groupID string, userID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID, userID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, string, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, userID, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupDelete provides a mock function with given fields: ctx, groupID, hints +func (_m *LayeredStoreSupplier) GroupDelete(ctx context.Context, groupID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupDeleteGroupSyncable provides a mock function with given fields: ctx, groupID, syncableID, syncableType, hints +func (_m *LayeredStoreSupplier) GroupDeleteGroupSyncable(ctx context.Context, groupID string, syncableID string, syncableType model.GroupSyncableType, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID, syncableID, syncableType) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, string, model.GroupSyncableType, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, syncableID, syncableType, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupDeleteMember provides a mock function with given fields: ctx, groupID, userID, hints +func (_m *LayeredStoreSupplier) GroupDeleteMember(ctx context.Context, groupID string, userID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID, userID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, string, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, userID, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGet provides a mock function with given fields: ctx, groupID, hints +func (_m *LayeredStoreSupplier) GroupGet(ctx context.Context, groupID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetAllBySource provides a mock function with given fields: ctx, groupSource, hints +func (_m *LayeredStoreSupplier) GroupGetAllBySource(ctx context.Context, groupSource model.GroupSource, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupSource) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, model.GroupSource, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupSource, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetAllGroupSyncablesByGroup provides a mock function with given fields: ctx, groupID, syncableType, hints +func (_m *LayeredStoreSupplier) GroupGetAllGroupSyncablesByGroup(ctx context.Context, groupID string, syncableType model.GroupSyncableType, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID, syncableType) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, model.GroupSyncableType, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, syncableType, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetByRemoteID provides a mock function with given fields: ctx, remoteID, groupSource, hints +func (_m *LayeredStoreSupplier) GroupGetByRemoteID(ctx context.Context, remoteID string, groupSource model.GroupSource, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, remoteID, groupSource) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, model.GroupSource, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, remoteID, groupSource, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetGroupSyncable provides a mock function with given fields: ctx, groupID, syncableID, syncableType, hints +func (_m *LayeredStoreSupplier) GroupGetGroupSyncable(ctx context.Context, groupID string, syncableID string, syncableType model.GroupSyncableType, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID, syncableID, syncableType) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, string, model.GroupSyncableType, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, syncableID, syncableType, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetMemberCount provides a mock function with given fields: ctx, groupID, hints +func (_m *LayeredStoreSupplier) GroupGetMemberCount(ctx context.Context, groupID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetMemberUsers provides a mock function with given fields: ctx, groupID, hints +func (_m *LayeredStoreSupplier) GroupGetMemberUsers(ctx context.Context, groupID string, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupGetMemberUsersPage provides a mock function with given fields: ctx, groupID, offset, limit, hints +func (_m *LayeredStoreSupplier) GroupGetMemberUsersPage(ctx context.Context, groupID string, offset int, limit int, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupID, offset, limit) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, string, int, int, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupID, offset, limit, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupUpdate provides a mock function with given fields: ctx, group, hints +func (_m *LayeredStoreSupplier) GroupUpdate(ctx context.Context, group *model.Group, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, group) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, *model.Group, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, group, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// GroupUpdateGroupSyncable provides a mock function with given fields: ctx, groupSyncable, hints +func (_m *LayeredStoreSupplier) GroupUpdateGroupSyncable(ctx context.Context, groupSyncable *model.GroupSyncable, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, groupSyncable) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, *model.GroupSyncable, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, groupSyncable, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + // Next provides a mock function with given fields: func (_m *LayeredStoreSupplier) Next() store.LayeredStoreSupplier { ret := _m.Called() @@ -30,6 +398,52 @@ func (_m *LayeredStoreSupplier) Next() store.LayeredStoreSupplier { return r0 } +// PendingAutoAddChannelMembers provides a mock function with given fields: ctx, minGroupMembersCreateAt, hints +func (_m *LayeredStoreSupplier) PendingAutoAddChannelMembers(ctx context.Context, minGroupMembersCreateAt int64, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, minGroupMembersCreateAt) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, int64, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, minGroupMembersCreateAt, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + +// PendingAutoAddTeamMembers provides a mock function with given fields: ctx, minGroupMembersCreateAt, hints +func (_m *LayeredStoreSupplier) PendingAutoAddTeamMembers(ctx context.Context, minGroupMembersCreateAt int64, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { + _va := make([]interface{}, len(hints)) + for _i := range hints { + _va[_i] = hints[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, minGroupMembersCreateAt) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *store.LayeredStoreSupplierResult + if rf, ok := ret.Get(0).(func(context.Context, int64, ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult); ok { + r0 = rf(ctx, minGroupMembersCreateAt, hints...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*store.LayeredStoreSupplierResult) + } + } + + return r0 +} + // ReactionDelete provides a mock function with given fields: ctx, reaction, hints func (_m *LayeredStoreSupplier) ReactionDelete(ctx context.Context, reaction *model.Reaction, hints ...store.LayeredStoreHint) *store.LayeredStoreSupplierResult { _va := make([]interface{}, len(hints)) diff --git a/store/storetest/mocks/Store.go b/store/storetest/mocks/Store.go index 1f52d98ecc..10282660ed 100644 --- a/store/storetest/mocks/Store.go +++ b/store/storetest/mocks/Store.go @@ -166,6 +166,22 @@ func (_m *Store) FileInfo() store.FileInfoStore { return r0 } +// Group provides a mock function with given fields: +func (_m *Store) Group() store.GroupStore { + ret := _m.Called() + + var r0 store.GroupStore + if rf, ok := ret.Get(0).(func() store.GroupStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.GroupStore) + } + } + + return r0 +} + // Job provides a mock function with given fields: func (_m *Store) Job() store.JobStore { ret := _m.Called() diff --git a/store/storetest/store.go b/store/storetest/store.go index 15971a53fa..4d5d5e0428 100644 --- a/store/storetest/store.go +++ b/store/storetest/store.go @@ -46,6 +46,7 @@ type Store struct { RoleStore mocks.RoleStore SchemeStore mocks.SchemeStore TermsOfServiceStore mocks.TermsOfServiceStore + GroupStore mocks.GroupStore UserTermsOfServiceStore mocks.UserTermsOfServiceStore } @@ -79,6 +80,7 @@ func (s *Store) UserTermsOfService() store.UserTermsOfServiceStore { return &s.U func (s *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore { return &s.ChannelMemberHistoryStore } +func (s *Store) Group() store.GroupStore { return &s.GroupStore } func (s *Store) MarkSystemRanUnitTests() { /* do nothing */ } func (s *Store) Close() { /* do nothing */ } func (s *Store) LockToMaster() { /* do nothing */ } diff --git a/tests/add-groups.ldif b/tests/add-groups.ldif new file mode 100644 index 0000000000..e6202871bb --- /dev/null +++ b/tests/add-groups.ldif @@ -0,0 +1,81 @@ +dn: ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: organizationalunit + +# groupOfNames +dn: cn=outsiders,ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: groupOfNames +member: uid=board.three,ou=testusers,dc=mm,dc=test,dc=com + +dn: cn=board,ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: groupOfNames +member: uid=board.one,ou=testusers,dc=mm,dc=test,dc=com +member: uid=board.two,ou=testusers,dc=mm,dc=test,dc=com +member: cn=outsiders,ou=testgroups,dc=mm,dc=test,dc=com + +dn: cn=executive,ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: groupOfNames +member: uid=exec.one,ou=testusers,dc=mm,dc=test,dc=com +member: uid=exec.two,ou=testusers,dc=mm,dc=test,dc=com +member: cn=board,ou=testgroups,dc=mm,dc=test,dc=com + +dn: cn=tgroup-84,ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: groupOfNames +member: cn=tgroup-9,ou=testgroups,dc=mm,dc=test,dc=com +member: uid=test.five,ou=testusers,dc=mm,dc=test,dc=com + +dn: cn=tgroup-9,ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: groupOfNames +member: cn=tgroup-97,ou=testgroups,dc=mm,dc=test,dc=com + +dn: cn=tgroup-97,ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: groupOfNames +member: uid=test.four,ou=testusers,dc=mm,dc=test,dc=com + +# groupOfUniqueNames +dn: cn=tgroup,ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: groupOfUniqueNames +uniqueMember: uid=test.one,ou=testusers,dc=mm,dc=test,dc=com + +dn: cn=ugroup,cn=tgroup,ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: groupOfUniqueNames +uniqueMember: uid=test.two,ou=testusers,dc=mm,dc=test,dc=com + +dn: cn=vgroup,cn=tgroup,ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: groupOfUniqueNames +uniqueMember: uid=test.three,ou=testusers,dc=mm,dc=test,dc=com + +# Adds a group with a cycle +dn: cn=team-one-a,ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: groupOfUniqueNames +uniqueMember: uid=dev.four,ou=testusers,dc=mm,dc=test,dc=com +uniqueMember: cn=developers,ou=testgroups,dc=mm,dc=test,dc=com + +dn: cn=team-one,ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: groupOfUniqueNames +uniqueMember: uid=dev.one,ou=testusers,dc=mm,dc=test,dc=com +uniqueMember: uid=dev.three,ou=testusers,dc=mm,dc=test,dc=com +uniqueMember: cn=team-one-a,ou=testgroups,dc=mm,dc=test,dc=com + +dn: cn=team-two,ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: groupOfUniqueNames +uniqueMember: uid=dev.two,ou=testusers,dc=mm,dc=test,dc=com + +dn: cn=developers,ou=testgroups,dc=mm,dc=test,dc=com +changetype: add +objectclass: groupOfUniqueNames +uniqueMember: uid=dev-ops.one,ou=testusers,dc=mm,dc=test,dc=com +uniqueMember: cn=team-one,ou=testgroups,dc=mm,dc=test,dc=com +uniqueMember: cn=team-two,ou=testgroups,dc=mm,dc=test,dc=com \ No newline at end of file diff --git a/tests/add-users.ldif b/tests/add-users.ldif new file mode 100644 index 0000000000..d96d0e46c1 --- /dev/null +++ b/tests/add-users.ldif @@ -0,0 +1,130 @@ +dn: ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: organizationalunit + +# generic test users +dn: uid=test.one,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Test1 +mail: success+testone@simulator.amazonses.com +userPassword: Password1 + +dn: uid=test.two,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Test2 +mail: success+testtwo@simulator.amazonses.com +userPassword: Password1 + +dn: uid=test.three,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Test3 +mail: success+testthree@simulator.amazonses.com +userPassword: Password1 + +dn: uid=test.four,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Test4 +mail: success+testfour@simulator.amazonses.com +userPassword: Password1 + +dn: uid=test.five,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Test5 +mail: success+testfive@simulator.amazonses.com +userPassword: Password1 + + +# developers +dn: uid=dev-ops.one,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Dev3 +mail: success+devopsone@simulator.amazonses.com +userPassword: Password1 + +dn: uid=dev.one,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Dev1 +mail: success+devone@simulator.amazonses.com +userPassword: Password1 + +dn: uid=dev.two,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Dev2 +mail: success+devtwo@simulator.amazonses.com +userPassword: Password1 + +dn: uid=dev.three,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Dev3 +mail: success+devthree@simulator.amazonses.com +userPassword: Password1 + +dn: uid=dev.four,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Dev4 +mail: success+devfour@simulator.amazonses.com +userPassword: Password1 + + +# executive +dn: uid=exec.one,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Exec1 +mail: success+execone@simulator.amazonses.com +userPassword: Password1 + +dn: uid=exec.two,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Exec2 +mail: success+exectwo@simulator.amazonses.com +userPassword: Password1 + + +# board of directors +dn: uid=board.one,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Board1 +mail: success+boardone@simulator.amazonses.com +userPassword: Password1 + +dn: uid=board.two,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Board2 +mail: success+boardtwo@simulator.amazonses.com +userPassword: Password1 + +dn: uid=board.three,ou=testusers,dc=mm,dc=test,dc=com +changetype: add +objectclass: iNetOrgPerson +sn: User +cn: Board3 +mail: success+boardthree@simulator.amazonses.com +userPassword: Password1 \ No newline at end of file diff --git a/tests/qa-data-graph.png b/tests/qa-data-graph.png new file mode 100644 index 0000000000000000000000000000000000000000..1d8fe16e1330ae9951906006756ba572f4129d3a GIT binary patch literal 40731 zcmdqJcT|(j*EWixVg&>grHD#X5Rf8Bje<&VBA`?ikPcEKEkQw%jv&2>bdcVJ0HGsA zI)om2s0lqG_1x(1c^;o{y=T4Od)7MZls_n$nZ5V5ui3Njxo7rDpq7T>xzjACsi>&V zDJwmAOht8qOht8E<|NQSHKtWJ0{l588*>cQY8Zk8@K4!%>qRxIfc%;M)2xR21Qn|-#r<>LMS0;S`^+5_Q5Q$lIoxU+y` z|YR@!y&cb4#`Xi!b}CDi8WG@&`6+2Oo!RPqxt9@kw*^@I!?8 zt2hkQQdqu7i5RMK)ITn(7pWjC+LbViE|i$g26rHMUs)L^j1`*LV??GFMerJ?mIUt# z>&cS3ArRbMwzX}_jJl+(--_QXXX1bizR0;7)B?=$)h}s9Ycjpv$+vpfYB=Pej?oTl z*9t!D9V&fhX4_NLk@W%-Rd`1iiA_G&+JD?^jnUD=z+qj%$HH-hXANyaZhPr(@(ITp zeaycf(Hc8S5aeH#bG;mnwYZRB0rn78>D>F6yw>$BFkae!A9*I$-H(KtMC}V#$vrXU z(ZBOhk(kU9eT=QBXxq@Ts5H4w+Rb>X#?NCIv;$cxk1X}#SK%9kRKIZ8s`_S+JkLgI zi0!&VUJ5X(a`|R|A+!Y-HG$P z7A7%qExL`%mM8FpY_5&~VNqp=NpfH3BgfgMK_sQ;FMioFex|JakS%t-T(*y}1k@Rq zV&((s+zWVXRN0niupDC>3WB+d7)xJxUvWY~3+$%A~iJuplcX z@l?!%%@<{DUnRXVmimFm3+4T)SD?-Mm$%OyU2xHk?nH;S?J6o6O8{CK{splduIKs+_0JA_p=aCfy4mJC&Hm0R z#pxZ!!n~L&@2=^^l`7dU*$(!11e1QJnf<}+Hm*dD=QfOHlNsnlovKO}W{uw&2Igc8 zcMhOdL%;lHs?p}QI-)nMcO6tuk`{FTKT1oH@Q#{Gg3d#>Go0&u^**k&$mTH^B~qyvRs%r!I&J(K+_ zFZym&H$%KaJPtZTedFnqSg4RdLw5M7)8vUi4&PL6$%~OJIXlwk76^mcUPc^ zrDe2GVZQe!wuc$Sj`NJj^3JQ`EmlwV_3N$?=b@GlxsN2~_+h-)Lvj_0j^Cr3qZ^fX zR6AdD_PwyrvIvz8J+$FO)=)+x?{wgeV_%MM(b#>t*uS+pMmi-Eildg3IF`KgS|U z&zrmofixy|E@nJ5q{IXR+Qhg;+UmMht_566JGVtM^RRMzIlfZ$6p*_7 zEE!Y-ZHNMFiIz%1T`9i2=b;UjkAY?cNmYUQh?Xy)XW;Z2@c_-?LfMS8&L%B-)nUW| z0nYd&NoYPXgPGjyP@4S68ogIPaTS1dwozyYt7F+s5l1E@IKpE~X$i#X6?-H()G}In zqXD_*Q+1zsB$eLdi-0j08UuoacQLvj)D2G56TD`2kvQf#I$XnNCAL@35-IM}C^3Qe zFtQ2l>HlaUCEun#Mv*5T;fRpfY<;mAj3{N;6arj%+r*R>{3Yk9FBkBkUfD{b%Uxv* z6M$NmmAsE$pqZn4lR6aliKUC)3e$1QnHL1i1fd0O&SZG|V;OegSalQ!;FC|FwLJ4| zNgG#MFx5##;t|KShhYTQNB8w-&VBHJ;&=fqhTKj)gj;uXm#-gduLThqw`ghwViSd< zYjr+d3H#5O#F?$=g4wcA5PJpm=@P_B?s@}k;R=3!h9kv!i2Nd+kQNAlMrNGb-e)Iy>*e^4@W&c)P*(`-ql%pb4EA}&DOOR5=kA&pOGNRVPH4IVz zV6{1^{gUEK9yZ*ajfzLDOrTUovU?m67aCX4$@5{5e{ec&0GrDjPk>W zIPCIbDV83J8>U}Qyq^OnT;iagQAgnTyKfouNPbUs;k#3121O7o4*;C~lH?J?T#A*R zZ45U#b@9%^l9fxYCkJ%H8EFBD(3IGu^S*cWZGUd6YAOMeJ9WrIxdlJ);OTP+d83pURr4Fe7%Q|H7a%aiqqgG>tG><&t&;0t&!vY+n55CvtV(F&Ss_Ss2)x5mp(G8&C0}4 zHVDLOrkr3J;Dm=>T)ach>gL*Sb#ToUwodp7dw51aWY0)wE}XPsBDX}6W%9gdw+x4J z5~<%QDLLjsZ@R*#+1A906enhu(CM<2_29PfJy%K%9C}Dt#lEFi-J%In;2CP~AvD*> zQ79F|(&-9`O>;Kx@P4g%rHii0Q|a$IE0aO?J1KPSj^i z{XqoR)En4Ie~RT|z#uUQM{Ux0;^e6(mHw3aly5&l^S$=_S=XG3QXvN@PUM^>@n(^( zo|J|^Fn2k2rzqcN`iCSocxfab5%5UL!97#xDZ?#hna9so?w%LFr}U+i+k4x&D(xeD zvY=C?J+SzQ{LyHEv`zGIl>YoVMT<;+UJJ2^5a++z{cNA@Zt+@vOuO`$hyBNmfHplE zRJsV+pRRz2YIt%g4hxpKi9BikSz#B&+#kWt1-}%M6CCR!%EI|I@PK`XOdsAfU2U~c=y7=q+#c!)n19@=Kk%ZyT8d)pIJ{Iuv6|EJ|94=H|j)S=(5 zmHrjVAZS!wMkFjNrAGyk?@c)CZ7D$aTf_dl_WF=H8VFy3Yh9_@$i30c#TP&jsu;?f zwS)=SEfx)sP^42=;+Yzlr0iRw6^V++v?GvuATC_F<(#I6`@PI;XK7T#JrQRQH1 zxmXH~7knZYbf@buDd|{bxl4m=2eI)TlS`l&o;4Wvo{ib06RM=L=!a2HMZB=GWQ`(= zmuTu_>PCGme|P8UgZ8)Y$78gXH6Eq4;)6V((ywQC?jEtsy_wJ%I=ZT!Fr@p^Q9ZCD zk*lr^=}jq@lemm=*lUxkPMjCgToX|yMr#I1J?j-2isdO%B-9n#oB>mLszhe(iTc|_ zWr$PlWNZsD-m}g3bJ1pn7W^nlP4ClK(eEZqyp1Qt_@+}#hT;c84N|EzT=e99%USMN%H6fyRH9Z)^Qt zUMY2#xnj!_E_p_#BFjEu0YfmKMqcS2Za!wGHih-EbWVJX4!W95CsLKtX-llmu_cj0 zeH?64y3BZD0wm(Jl7E!y=V!AvfoiWg5VU^a0OoSI4wDCOmkf;&ClJ2ev zE-g(PlCF|RP!Q-a0@p>noxg2JbR!@4Pm+7b~wGji$m|k=#%4=b`lXoroRMeLUN*7pN2XjlY3_7gqF(Pv1XjSN%K9 z<6j{R!{;2k)gzY{6RdP>wnyr+y$r!>{pLKmA=bcy|203q>k;2zbWHKZ5fbd&eP#`p z%;3HD78_Vt6(Mf86>lAQ-K}yjEn5p;N2t`ntLyAKrcH+ly2`(_25KV(t>HJ$gKK#1FP9u9^jZ?TgHoh}w+jykuPpVy2AQssQ?p|# z4=jn*Kc~gMKZ9WG-IBIh`lc90-W3W=Lf3R@s(VjHcZ?%&7Y@x0#(Aej3AgTra0&)eWSv zALh+z2HHn$%dwB!$K))>`Ps)yfIPA%kQUM}6x3E{q@lIa>7Q(VwM6DHIr*sG%{=-c z>En%-ogw`ikr%v|m2Uk?zDS1gF#W?qBjgS+}Qf`512y|4O|2EJ*UZG%AFkao6y zXHPdf$Mwdc&prJNHMX7~@DriJy332uen~##*!w@qwJt9{2Qogd^{Jib*=)9n$hwJFp@TBpw`;!S0hZvp+yzpA+QK~}eSmKlO2QHkF zUAYQu84e#-D4SDs>k;hr`EClRJXcwHO{tS4tmC1{4g>jEXh0GrQ#%P_neUp@d`QCx zLs-1Pk-Z+%e7-i6F zPbC0v}|d2%2?(C%&jNuCFaf4)Mb9-;A?$ZjKJ>pkZ_GLh8k*L zYk030Hg~ldGJTWZIB>{+?2SBWs{Ih1vSHKsy6DliP!D0Z8|g7z0rCXP4Ni7-~}EQ@MC zkvev`)BRNLuMm$rW@oZY9TwVKpTUHkI?(y_M5^Ubd4IXTs$x%uF5$tkF#G5P30TPX zL!A}+ARusBh8V_=sV$wnwTecZWrNkkr3*<~>>JPJxhk~)w7;}HMc|VNzE!kZ1J^<5 zrWAxP>2lP3g9R20a7)>UysK5FqXm5XSi-%&HkaTWZvJp8KybeB}@; zA7pag0_NeK>ZNOZ>@N$3g&M$ix2piv++wxCp z*u2y=BBcLYf_UYUHsce#ixInlWIDg z$#VbyiJkwFw*R|S&WH)Th!2FfR9wo=b-i&oi5i0p4A5R_^-P7EhYKntCSuoX+=Gu@ z>c5!G{}olX)hPmp#HP^S3Ac~IJuAWSN!A(Tix+ydR{BB?vtAl^QqYTfVWzVb@3zZ) z>-OlM=u7qZ{uOWkDu3N&j4OZ@+J5*QB-TgCFL0S^KSM(Wp z0rLa2+^|0tu!=#wUQ9>cC~!?W@xK|HzhmaTz%0LlLCWRpj2!Qy!*mW;DBhqcmPbrR z`2KZyJRdviaGSwY>0hK~34P`1?ac&OegEHM|7R7xEsQBvpT6`%k}HY0!*3j2=%;;i zbo{ijal=DHRVAXmynf~yz0?oj2uruqps7l5w%v_VudTPjXJwS#!ZDY%r4vl2y)rTQ zT_X_GqCTH}k>l$?Rkl+y1N38Jo2G!=gG@)wc`0r(y@YJ)xPFh@{$NWr$Z_{c%@r2J z&8>j%xd%h_u32lTrRHbrjydP#x;_*u)S07m8r;eCS(H))RTv#CKhn%xOZ%@>hNc>L ze^f!Hw7IDeuQ{-QDxf)QecTaf|L7}8f4AvCYaU8nw6*g3k3rlJZELJD-3GjixOQFr zco~Pxh3LYN-^2OjDxz2F$u4Bs=_Qd6Te-0Ci`E_{mY2vYxjC*D|3S8uyFm({>VAcxuXk%e=84?tH{m8k7wv4 zCe+l4@i^L#A55DhNW`y=UEs;xSuiAyb(gO^^%tz;3gR<97rOQtr!cweZ9_A!K^PcL z-yn~%K9s9T_l-6@HbKL-k)E!c75uZ+aSJi(@FLP*u%644&zSDF6qApQj!ut){`TIe zDe`;Wm$vUPMwgW-xSbj#7(Na6cS)JLk>`5nw?8_&xf_qo7{DKU+f7sPUAm~X>kES9 zJhxBi`Q9nK_!}PQzu++DcV`(x=Fe=UjuSlB@`f^0iI=1H%&-Dv6)!XV6)1Wy6GIJw z!k-2fxxPPCu6b+D(b8<=(mb@oSi*G8zEf>&z6jfXkSDYyagdx!xWk# zHC<1?Lt)wKt!&Um*A+}*3=`HpVl~IrM=pq=YHMi;;z)vrkZiFSouzPk=*e%j75*$) zeiLD)dZ_1C-K77pfrU({EhH z8xr?OroDwf|Ir1j@aNMwA^v?cf9hn9NH1`vdGV)3&h|AhOhzc0AQ@vVj59R;bdD@X zIFA{ObV?1r|12|q%jxrisTZW#Ug&*Mjx~N;&5{jT-&|q=VciPoi@fCSXU!H_oK0a< z$i)0)+Q9lO3prRquIHsz&3~})K^{mR=&)4lT!^n#Q5!HxXaU{<$&nnJlZ$P#Gl~y* zJ>&|p@X|{ysSgG<+Aa4zO*2(iV2${5)AO^6-oX-jd66b7EVvBowjyxdF75j@Zxs2V z_4fRzseMpSTZ(<5+RHo<xvf<+Cy#eX(eQWBLHmv>|>7P;&IxNOU^dnek<5cmBQN!G7wGK~lF5pPt zq|u=m_v8t_y}Pb_U9d<|IcEGx;x_Sd!ipl%tz+-=qe5GKLUle5jO+$|H8HkKdnTd$ z3UGpdK>*e`SGG=CSBN!ZeDon>-6S-!GsI(Qi7#U;+yk0k;sYx=xB@+JOPeybz*Lhi z8OKleP&Nm~&*(X(k#83#jGnugMpy0v^?nrB6|*wUU}naP-V7bNUUf(_xJ|+ z0(1(v`Zarbcb}%V1w%5IcxpuSMMkk5ryKHxcGP3tAtsSSp*F%RGRJmRiEe1~N5*rS z*1LDeo>ftU52rqV-(eY(n-K})FT-*dnT+`37%hSO4>id5c`PJo5<5ob{Oq-}YJGl< zrYOgRl?!qO(tuI^P=6vE4i2ct6{SR-kwwr^AnUz6rZ$~Ja{B^gV!=3B7u+JGIVESu z>hBR&UAT|+$BJvqtxP~8Y_z8g3Sv6OyY zW8MKrC?y!iiW`5}m*Ifw7}ic!J>iw?s_)f0hx22nX+f$&C3nA4#~%2;WAIZpNc2UE zX=Yt%9}0=>H{PvGROo#&Kl7n~%0b;>d2&YM)}A@Bdc9181B#NQy2|Ui@cDs4{Ds0> zNm@J5e92==y>IX8R)oS`4^X27;UjXpBLs&k19*6m=%UV+L}i!q>mCj$Ws4(CwfKT$ z8fP{aOXs)O78{Otm+uZ~LlyIWl|`U=g)BTw5Op8-M7YAUxtP}CRXjmP?a4y@`-s&V zC2xvD9mYLh%)%J4)3M2+XpneIRYn|aJO6nL0e0iA>OpebZFsAxLySS9A_RZrcA)Z(Of`CX-G^9EwQUhk4uT7ffrH_0AC~c6?B~nK zDT3h`3R${5H$Qkc)y+iQXIZ{pS!4Of&A<;L{6^0RwsZ-B-b2XqSw-8n!Lj=L!4&oh z)f%^R&Y;vAai;Z&PpYPoEUz=A_rb~=)Ej&C`-0?=BVlkfF-cHoXMU|=d(z^=^5>t7 z$J)*1UW#)KZ+lG#Nr_S%V&QrMvYjI5BcM9^ui(QTs8wP@u*YVREGB|c%30-WTK8%F zi%f~*FXe*04Th$QHn!hv+~5KQxCbm@-A#hLS20Hm-4J0VjV>^Z9GkILC<(3zaktpM z3z)y&g}G@9Cz{MVDg_9^Cl+f@+i&jh8+lbcdxnLN9P|x$WnC-opxrX?l3fkUgz{m$ zHcob36+H^fL~dt!MGa-K&(CH%-)S^Y*4<2Cc5e$+7f$Uk8EE)8y#Z5iFKQ>-QG>$#44n15d0z3i^ltVm4(G($1=}!qVl^w@Cz7uAOQV z9yqTfd8~R+ke2ish9UQN-Nmf16QB<$p8I9CM{UC4B^ur#br74_#e2&oZsb#c#Npa< z$4PB9NSqDgD?#->+|0-XGAY+b#JLntr4l@IN(JVNPL=Gu*<=>$M5-5O_V_W8-2U-u z9MK^io{Q-v9hE_F@rL-!_5mXmUbuv`` zk3f?06lq%F1$ED2pD8SNpUVqs(@JR8PbkVi=ebr_?KkY^!=+*x*+Sdf8-iCBl z?MBg#Qu85}`Etg~-{HF#t(T7zc>gdLC^t3V5V~fxUL*1C^mng2EfI<8)zozp;9(X* zVf=yyJgM)K4#PPMz3-*_qQ`1*t~qd+5MqxXyqvARSm-i`=Do@$*x(;5P+e#`u)f)* zbIt2Y#vd~Cg+?rc$f?1&4TQ4GRHt2)uK2OZ3aO2)hgReBsy{fBH!?0rlL{PS9TxQ# z{KJwWt!|VxMOUO=>vJg5f`S#jD*at7uS#0PdeHs6Ps(!6E<|=CJfJ_`)oT3XY(&wx zbc#`}4M@X1PNCjdeJsV;xt;w8Qw{Z#KL$B;VmWeekB*329?Q9e{)Y5QHF~9ll}sQ? z{=vsx@Ten24=6PmtEzg7s^0oe0E>{Ye1QkN|16mwOZX!M_MwCkG(9HEYn$Kg zbGLA8Wg9H>3jv2`hSpKkiM*^86R+NXl%m)EsQ+0J9l0Jh*^t7`OWr)4XMY}Fr$h$(SP?T1_}WQZV#7 z=UWy5bd19v3PN76a3d~4)G2@YCb6PJ2{L5^C_d? zJcr%IfoR+jU$BbVcXI!1-j^s1XESndpY8L9hlpOBF=N<$+WpyHn0Q?16d@EPLZVC4 z9Oj-aH^vlpbXOw>_*6C%4Khq z8MZO)3!m#O)R3#6Mr>!s1;e5oK>4DzyQTkO63Un-v&Mv3!(_4>@)~N|vh5-vMFE~Q zMUH)EE`89yGzce*SDAhnN^4EHb0k2jAjnt2@%7REc2gc}d>?u7^8R;&@z1B@{BM1w z-@3$jBuYI?)7^u*za7c-tz)uUX6lOB-YT7x$;8Uo1@|vTEl<;bcbnR|Ta|q%=A9uH}a3rKi&vx3JaMR;Pq?_)r9(UT-6U+0*#e#R944qt3!7G+-?<+S4gt;k6Om$EFCMecF?NwveihQVXc zwk_Bv)PyIH`Y+gKw`aXH36E`IQeQ;L8*@~2M2J@_N0q8{TW+*2zjF;Xvoq`^L=7KA zjfkX1mDi^AZ1K!to)u8KE%z;BVTLOSR`m*ZaZEXI83WkcOHmx5xc+C{0ep#?`LI^{ zs8E;!vF68w=m$|hBgu}G!5)W9RLcQ4uLPwvBv6ZD#vf$>( zx!yf89`fC5Hyy`cM!7NQh+s0Db5t4+fM* zEE6y4EbF87CVvojE*+>r68Ur-8N6tz-gs5wceje4Un zw1sVZ{~lk%{H&1vcd-}AIi4Vw>r%_PifdM6$jvtRJpQm>(}6cws(tMvs5X~b5YRIJ|wBZ zg~xfBZ7V!kJV+jGz6VjWS-ze$z~wsbr;t2v(eEhYs0R6%Cj*c4S`Mea@3qo;1)tsn z-kz}D0g(h0fZ7QA$hYO&#Jrea{nF=6Q1Ud_^B)n9fC5vjxNBZ}UU+Ot3KU8*=`}G~ z8b)5HhrZF|srJ+DVo%G7U)H$e2Hc@R--OR-(?cKzfay=m&9+05Cao9!s?NSstj9Ug z8hgykkGCPgh}gTKMKoneQF! zoMQdjBT0vEJcA z0g{ieN>`0C&F4?2_Fl&Bc5q)5tvrRKUw0AeC+v}kPxIJU1WSXWy}oFOAp5v^I25rh zlNDyzbH)iz4s6S6KEl~25!LxQJnBm<0tuaQP6%5nQTAv5^8p7xOPYE_Wfv&x{MI#O@JTnu#R;KP1O(p9rK ztm@WgJCOVzM9wM~V12QUdpW-g0ZLE)WFs2tUd?uLm0;(yN4$q-t!Y9!Q_w{CR6%u` zOZcu(a(`UbYZ|N@dZ$y*bfYv6<2B(^Hmujv?*{8?MG}XmKg`P9&8*scW}2!kq#f^C zwO?F2!PnVUqg%e8jHx<}RRT*~ik3q5O-C`Bt3jUoH5!@(!zVH6&Zhg>oo|4kv2j_u z7*_vmUC35JUkEE34wy5Y@7)M4^r`tNQgz0xuyMlJg?QlSvv2C6uEA3$$7zZ#lU1zp zbMe^m;Lj;EY&D7}`4xPHPw)X5=Hs~&m^hPA5aMaiMm%X6+TpMT_WDx)&FKxY&#jBI zIe&}y1*+3FmojU<omEO){K$k` z{%gbg9#ecGrtwt;Uu%Nl6VU}vMkM8d7!G5eFRDlX+;(YqC@Lg{34>2sJDgypVD`uP z<7isDVcjP2)@h|T&$2rdwIIE3R2D}+p5}XX+hUP#(_J5f4Xep^uh&2&xCWd`gjwE7 za9e7sRh0p%Xo#4$&korui*s<(@%5WUSDkz|nzLdMUP7sGC^SkxEoOzS>L#RaL_M_LB;3?mQbGw^v_6t4>r;^MV-W#v2{gg%35FV#GP%y13@xx_FugW7tkOlQItP zCuVmW{NbK{@tHj^Xv=`8o~u02vN69N(h=DrZod<8d?G7eOsH@RMSJ=}n}lDWa#=aR z0@t%Sg9cQt-RaEO1J&dSmY*T1Cr!usREf(*v5xg7Mc@O23O5@{*JMk;t&S-f8tAv8 zP3+IT>4frI1AC+my-yh)9#gH>$ZORTe5EhZB%cYsCnMDl6xEwfgT{i-EorbUJHJXJ zqj^p0YSa?-q8wMouNQn!)%nJ`kN>&{EB|0I)BH3m+LYZay2gV6-4Ed#MATo;`Gv{a=Jn$&Djl2cR@pUT<%R<=FP$K+E55OxCbNb7+di2Mj5Q& z>(2m>{2%Bu8KphIwJd`kuEXRPFHv;B{kUeWi&F(2H4E!cr76InP+uVPn!kOYKeIZl zc?F(a6+~=3oKIEPA+B$mQFWuwH)BG9Ek&e59>r(xqb2K4FTO)b zgDu76c{P9NOc;1~c62iUisU5bS8nES$??dN{ZYZ4Qn5?G(E(6`c9PP-j#+N}K1)NX zSH!)I38&L}e-#*m@W?Ca$qdXVF_Pa(n;0P>)4bkRi|u|)%}i3Rr^xEKK9y)UL3JZ9 z_yfn@2;19{sMYWyV>bPhV=Hs;Z7tN@^8oUX7A8_Os78OR4&+*W-&caZ6accMbw>7U zc;P9K%HEl+jjH8z+rw660D9gWTE%wmqu^vwOS|cd2S(|JFJ`Ys?$R02Zzb)ZsVkHf zm}kLXVK?Cn9MG?8L{K6H<88*SzK_O)BWK!K`OYIRIA%a+-hD)cpg1CAXkRk?3W=q;EmxDPp;SVPb&ccZ#8B&FhD#bksrh1s*jTAAGAPcuIh4Q?`iQ>MU$Bi zw)8vMjc@M0E<(%7Sn`soX>u2?5OqA4HSZJMUeb}33ta5mDRb9>juM7Jf59fRP-UR* zU0{3Ok-@7$-zM2sn#|0+#qnLMGnx1N28m2VFI}x+*GRq!A`4azb+{R<83pwTvoB$~ zC=JIWEePoHQ38ps@AMQSPgU?X&G9YXim1%Wp3IYVQR3amSjyWo=Hu1k`w$AXlD%_A z+jc5b3-X5Rs^7R&?Dbvxu0Xu=Xin=71v0odNt(2?Q$V z;$f(iFW1Px_BI~lye6|7!9WcDKxcFtxc$G|m=Q2`_4U;U+1Rg0zPTdC@9l=KlLJz; zzT}3q$0{__2*%a%@FqPUXs+Sf)+motT~C%#d`L8A@Q@W~brXF1`O50nqSn^iGZsK? z0dL%HafAf!;ZjL1YWXAFV3N(x#3@LPC8*C4;AbF?)dW!Mh5*taAuTT`cqAtdD= zZ@u+0evug`*pax~k}BPm^^w!zea_qV@lZF) zEaT_?9e@pyNAMdQSZD1E(84aEt6-@;H$fE&Vw|{hD*W70@#p|1pTkK%eaZRySNha` zCTO~UU6{^wvJqn7L(C*Wn9r>U=e4EV9vY*&3uvpH*Yy`vxc2o7{=E7Y*!tKy`PKpk z9V1>cgDnmHwVSj^!PLmjqAg5Qw|*42nZl<37L)z@~Ftf>tG5jor_LDO#$k4D}<}teU(M!7hl4^4x4+?BGdlo6MM+ciyOddGyJN9^NyP z7iqV_ek65$VyJsG&BoYShJAS6%r?sDXyKzodG&y0>!&)Pbwqj2N_4jOhoamK| ziR#M53iSoPjcuCaM7skac!ReY2LS1@6kuR}kXONx84xLhBHPUZIt|yMysZOz;{eV-T zBD_aJ88d{QC(cBjecQ(jEo8iS{exXHzf^nU5_7P}-tlmE2*lW@uA^NQ^9Zuf!!Bg@ zsYXV1P;85?5L|tgbxNn*`^wUQY1TmKD<@HwHW_5cSMsacm#r7)y2T-9^DCUQ_B_JTs&N$SLrwPU& zMJsbyXUuAU6PUNo0Q!_7HIpDsUp5^J+|{;cWA(?l_R^h&B5Kl z{6B@7mIm{^DCGSMPL)!0D}#ehSZQNo%$X}MB)lX_1u3+W6jhhX7%h7uJG08LxABqZ zXKX;P-yqPT>v5NL(Dj6ue6d~GNdeP17eX!F@{VTp*D-X2CWeOAaUA|>uVAPL*-rya zE3S)Ocp5a{i#@(Vrf{;1b;g+^bVl7fBzv4m@0V~ch@|mqiD|B+ zMV77dgiC4K`)X6+$XuH0~fW)Z=2V`Y|L%TdOxv9EG;HUHb}D2kW**`DqC z=}C>|d%^P~cb3lS11=GU>TpMDJ*q-Rr9(!WZ_2%X(^?X0m*{ZOSB^Nqm^{A&b7?_N zt{|<7utq4K<%dm@$@A0!%xsfCG|;d6P8?ZgZ93<5;b<}!d87|tTZngtU2QEoagCr8 zCe;26&^A6By3Xc%pt!SJL)-;6jW|R2H}CyS-XN*itIFoz=I6VdQs~%Hn7({UUL69{ zOxvAnUtAQC%OFT<6UU0S1*z1@4hZ|)ouzd*!TbCfP#n1>Vp4|erNIiRRn;GTrl>WQ8)Y5&EFBDvQbvuxUAczWqcL^L(*Bc=fxyg-6Y0l-l_`CMd zv5~L6;hfN~erE2Wiy$S}6mHx3h!x@c_}8==ZC^?JbyX`%q_US2gY{(EOd!6$NGygEU@{{{bPJ+%S7yGIag9w3i2& z`$U;cm9d`^jqH!yc+zN($;0F>M$c`9I573G1Qks^uB}J&{0WyeHoXYOyf5Wv=iC43 zFMzEcC4a~wa89`_v?fnf1A=xww>wYOB<94=0m91w(taqs}*Jc)tFZ ziPUr+PXFZHnc_c`O2`4V!6$ze*Z#xS|8;+pIv-;RpnyNwa_TJg;pJW@k$YeKIUz|| z$%EDyER^>D+>8H#`|}lug=3eauCVF@h~*zd`c7cIQca{m zGSmLn_y23*xS=p%GJ>h*wrRguF>O!SFPfBX3J3Zu4nAbPb$jhTR3!F9NiENisGHWc zeNLTvZD#!!TXAQraIEsNi>wJJMjFSo;R9ONw3Rc_gbeHF>`7Pl_|IKnh*nvx*F5ZA z`Q3dv<6qsePnQ4ecFOhtcee$`lB0$V)b|fCwYdSIQ^rB5RxD3xEEP#A3hy6exdPX@ zf~}fj=f3%sr611Q^}%Y~ntbdW??1pwlpp*{b>icNKP{K6{JT5(!Q7wS{WrP)C7rDC zuWtFv(Q?nG`)p-oMjESsIV%6_8;7(i%t9!4-V2@;S0n{7fzvtT+U{N@PsdEx%m09} z(8xXdvsG^@LD55hzM(zG^wXS!>#@O0KqcA42g98kXq6Tm+aCYp$|l5>n3AA=Gmip9_RGjZl2_jqOK@f}u|Lh;@$7k)yfVaE6D|%Re%OX+Og0 zGS*q8D=6}$PDZbE&X_|ea#|&h>q9xFZ(lmPwhUaN;rM)zesd?gc%6MsA(oo(1# zuK&EM85=z`Rmo`W#Q0Q?F@&}N`m4i0QtonQx7XQ|A9CF zIIk2!Ca9K$rt%)CQz4)8bDIocEwcERHK)G@DXQ#;CbR7E(-wqjvF!Nb2ukgMW9g9A zwBhqAW>)~C%A*qQHbDH9OUYYz18$0P|910(p5l#Ak9$O)B@mg@^;9u0u$m3n)5MmJ zY+-Bu_$DCK(2wjsu-ikD$_3ke&S{uO-$<;gF{PH$@AJ5mIHo$hx;Cd;hBMY10=|V~ z&R$2Kc?GmqWry#3|9?>To?%Td-J>X0EE`c#K@h<$f{1|BfRv~xN>@>kt{@;aK!8X| zVna{_6r`7kG?6BqfFV)@=~6=pgd#PN7$77e?fek-e*f=t&-rxkhjYIqc_uTn%FL`a zGi%LSZ>(PwL>?f|T=qPPU8{qea_O>&8f2v+a_x5P`Z=uM?sFr@t4v2Y^(ulZpZRUf zzPc~8E>^BDS+i}S3bQiCm)@0M3=BSugme3tDp=O5nbC-6^q|(hV=cjybWEj0>&y8 z1`TQlDqUL_eA6Ny->K3}`gR|cEYTH8~0#3Q4W|Sr`%$l5f$k$d*gA(bu^#O{;p#XDvqqK@h0W%M*tW}_irTvU_XeOjE! zGRxhfLwV%1z?Q?zEU02j7R{!%@FaE-td-f0!rXb4>tBQJSr0)FFx~;`%u6A{tX&by zFah^m!pn6nzyv5-pdE$vM#QTRtZ(?JThrXDbBrZn8QqFX*0)O+5vMp6AV%6{PPe+4 zMrF+TQ#@yc4G5H=m70z>>st60D^U#4e%dB(ER6{Ri#cJVu>PV?M(r-bF2}h_0Bu7F z&Jh_(pc3V`J@mk#56)xEbw|Ou2gaidk18x!e%U1;-5|G;7F^U)Je@c*W=E)r@-)wq zJk3$XWhcKtDS^tA-$ol!{nJ%&5V62__TDpQ*ULL5At_Pw3~@s#P{D*YCopCV8>sIA z0!-R{Zz0DJkq~jH?%vnklF4g7u7g^o3GUE3Y)WPSEzeSVO{O05I8+STGVcGR8bz`e zKu26mhK)|&mYU8|SaPiGVj~|Vf`fA1Yu(<`tZE&}%kQ;5C{~%o0cW2#+Ry;uz`BoK z17_DTOOss{*hGqenEP^8b{TjHUamokpCN_WB?*hbRrjo%gW2dHx$hh~i&-Dh4Fw}& z?cSmX*52xkA6W1t+;zjVtS{1;8ZLPmgqnbxL%&+%x$!86#5f%n2;be?$|T>I_yPW( za;yfAwDnwl9gl{tuI4l{xNu`o760*N^C{f^;#W07ue3qLWEVB)1+?O#)CUFrFlaEbC0J`-6y*h z2HnrmI3n}f*CUHBTIE57H$+m6Pbhm0g2wL=I>7UCylIStT;9eNea#x_YOzdR`MDwL zTB&?%8s~j;6g-82=zAy2hh-dkdU25B=O?@R7gEAD;2di>1>Zsb@=^#i?cKX<|H*EW zpYRBfXF%vNUR4WKS9QJR;?~nJJ^$;ThEI2nV1u&FX76keFe}OmVSg)>Ows9ua1hWn z*8b_M1)b=Pwq5GMu%isvC!emNl&H7J8g-LAsNx@#ce><1663$K1fH1CSb)jrxcGJI zZm{cV(z)U0hO$%Hf2UL;LMNCJI;mOx>Y^KR)TZeIU#y#0A{X{Ve14V)(wb&rB@$~# z)%x@RGP?mXQ5phfZ;Og|tYk#pcR1VbN;0;@dNA2d>7CsRDQ`E3+;Aduul$9{yE74B zT9x{?0ulZ*9H~9~$j|tO!^x{+$o|W-fdQElEL_e0{z}-nP9z3nT6WPnQ-R+;3QMA2 ze!A&LR`~gYfT52A5Hw`mz4PZnN_3NEm?o#`Chtop)283?HX&?(XyWY!n48oizfL`% z=aWc_@$Pv~Yu!0NFZ-w1QTn=8|HPZY>M*CWjw{4m%teGe*EZqz;86H z1HC|Nwho<=0?$GGi~(J9RTPPNk{+-tG}OO$=ZS%`bK4JKhQ^{2NzEp3bHYMOEP#YM zC;QH~#)pSO)N*dsr!~JC9U18vTS$rf^Ox%((ISp@P4mu{Yi~3j(AFx-Dlg__De(Ut zi9NOW>093Notoxfb2}H;efK%+%>3l& zombmt0Qav#k3a5T!IAVGp1#efH{S9}x>)z3BPDKsJib`%s{=a8rlSkv%69-?wNFs- zyaMgeTYaF{&(=rZ%mC3h(l2scZ#7UJsth=b6Zh|;)`)f#9O%@6vRW2cdTs^Om^e@$ z+385-MYTg66sf!wsUq}%u_gXZbreU-+qJZz6OmojU-#-!B=(NkY*#r>O6bJdCJEBW z3(`7#vT-0Zm1*gn_gvuC31q>SP89j%1sX)D0xJJ`WtJCtu8<%l=jJ;Y8?_1qQV{q~ ziEl|5HaWB=Or091=)j0#G)=LNJ)1W3E3O`&Y${RxV?R5!029y$A~(DlpbON*+;Xq;ZE*zl zlK^&Q{dFymBCuB%<^{9-t$g2&v;a7N*eE?4fwlcrW#W9qcb6>&a5<{TC3wua`+2GA zPZdS0&BEi7&iNi9#U}nd!GPs%IUgl+8XzLy2BV6wk7Tw2i?lg9wqx`Ma&P!G+3B)9 zrz`nBaLnHbAj6*a&;^1X{fN@zvw0z}oK4tpNIG4B;I6jq{@q0%3Cg=wvCsSVuy&gg zI+UZPRu*Ern2ufPgNXV_6}Fo3=42>wT!DX9MNb^Q@tD0&)R*->z148Q&{Nd>T2dd! zO*dxtdwP7y&7p;HU*9IwT#Na~{W&_P{030;dbTv^+Vpkjeo2J@*XV9OW&IKdeyL9Y zaThO6$;aU9K4-nL{F;VY26Yy_pFPxRro|I@xJ`8_sw9yb3*tN7?z}_=3=D$-0TY>C+KImQAU9#KT z(^b2bw!7}M9cysUaZCX*)-AH?MMs=Ol>6a3SY+TGvPYV*Lon+u;(?;b%c)*@LfBTM znZTi}W23O{OOLtwP_}B*dm9vk4%JA=K{gU1uj1rCo~lV_g#6Bs{}^THD*hF5O)Qbs zpy}C+Ut+a;NL1_W)j^J*^oao0)O8ouD{YEyVO7A92fYG$tA1E-iZTvDa`Gr^;^Z4m z=78VF*YUv<4`^M=FYd2ne0l?}%h80Er5-a#F(Sg4^P@>boTOcBQvHmbhvDsM&RI97 zo{Jj3su_PURpwkpvg3ZLvlZMg_x_IGXhDy;iAAnw)#jX2uz*P~m#7vP)%A$2xj|!8 zioSN_C=Qj4L!T>~*4#9B5P-we+`&gOydw?$A_; zMwF{aANhS!^-9BuQH8>bFeo*<{g)~axbVIOl~`M%WqhgbfAoRE>OH6)NKGyEZKw^M z)=cd<7V7sw1>k~sf+VYtYfC?$u`MzxXA^BUVU$gs1 zksL|WB#FWYi`E(UI-GA2+mFRF-uG*sE9?Hcj7S{Q>Vn8D38os2ncZV2$z0oe)@9e< zwjZ@yx&*5U<2KdP?dgT#Z`b!P{-sy)J^g@1N5&PaMOOaI4SJw|jK}0vt<>8w> z`>uYy3Xeu9dk7#y5^0YIvPD_j(xpdc42Pe}4_I=>p37Fw;Ypt1>je=m>jwTX&o;an zU3E&$9k}6I3ADz|La^N6`dJee5Nm6@r%lHQM0Fn^XIjT~0|(9O*>{ws7EA5|8`QJG z_z2HSFGPgyxhtcd(V6YXsKlHD#!<$k{sC0yGvH{Z%Q}@$A)P+GE^!X*aR})UZ5V@M z-*@P;+gUKYpqQNwi!_30N&MJNCGLh-i+-41zm<&S=ys=Ea1N)p*_eOpLU{3RvqB!tIuFxE5Pwo~ z#&%OjhKCKKn-HZd<|-zJ3q`J;%Y22da(M`p#%~<&c_T4{e>_nKfide^wP+&`ec=oR3*vX{ae#prJ3z zH;Q>depZvA#QlXTR!datA3%e{h|5|QRHpe_1lucd3bGLAOkKJFR;tII zUDgg{Xw;ae&3cb^QWGSZ<7jCV?0?yVIItn$$6dz{lslj^wiJCvGE@iV9$RDVS< zxS#`4t|6K2emJJ4=wSe^TDs6QdiA;30V^i+u+rFQb=BU}9l>_&C=ll$@WyihgBl^N zYkbQVY@A;&2p9bQOwGg^rCS#pRrR5Nq_*F4s&EnnJik3`x4hPaBh5L_?C2ad?})Fp z@$}=AAPdA4thiGV;H#`tRgUCYNhl_&aoV#hVturgstLhxqemDwh|EG`_M00pJ2!I8 zXvv{L8b;($M=?0XDho=;nJXd)Qc?vOfWOYibxhi<$4SOK@3*S`L1$X1zFwFpEOo%A zUmm!i1kK@T(u?D5{Pp}cEmB_*rdre>9Mft?p(3fVg5>MfL9LyU<5-i6$UrgL&zbPdurT$ zVCs;NC?OwV^j3?Lm4hHqKHQc9HKebc2IHQ{7^zax-e9ptJF?x|q_tZtkIH`F5pR(> zn%oQL9>Y8G^}zyM9#;Pz;s?Bd?3vk7=gRermN7ZbWW6QOkI7ps8&~c#)&nx6ra*U2 z_^S2c?#s(E-Pdv3MbUvSwk5yOWWZ(Um6jr->b4bF7Lm_vTZZPp_WxSu7gR$g-J(5f zO+A^7;!bpb8)H(Zs=f3#bf9W#XLfa5aGYnijiBfY z97Ldc&%z<(Tk%6$yC*@>&AM+s@>|azq(7_UUS)eep+Dc5HRv>hQmt|wv9(NP##~F> zDHffL0DU{xD5#rt)6L+wOYxPIlFaro`mvRP@B`7eu=46(DpS^gzg`-%|zL^Kv( z(Su&~InDjPhj{$==k`^M$cKcQ7^SGTo8LP%h04UKi_RH8bNRPp{daw;kK$(5HysH2 zn`3+0SE_H~d*EZ?Nd>*$hrSVh^A~S(!t38kG{nfqi^rg8N->o~|J9;*!=vcrm^3{2 zP*PL7&hzNTiox1~q>|84>0)zj*Efy#wQCXFV;}Z`IWb#!bpXCb$h-Sp%G{KSQd@ne z@DZ(92V&VFjUV2bVuz(S*>Noka5Tz&W8;r$Q3PvwC|{eF@3d?=JGg%z;Efd0R}{F_ zS+}^C*Hx*hE90iFFY7W3QPIhLSvTBvq9&({jmO74PEqqvTeQWtXfW*MGMaBQ+WeU@ zMdcjv@kfStHPQ{%USG8}F}T5A$oW#-kQRnDZxLbntWrMP>5BV8PisZ_QFc(7$h3^R zbHC&N_#+Kdetb}&wTPJy4n8nxB(yb(hN-W6{Suh!s*xIa_p^mR=ACMl9)4oxziPQY z7{z*5t_=e!JbQ*b)NHffxHl=w7%m^eS9&6%th@BG8z}}{xQRo&QGMirC7g8)J1$D# zcNeZ@*oqd-t~LI8-SUJ?_GTSi%K~R<&{F<7$=3K=^}J3|tjUj75f_*I#0`q~i2@C9aYEJ{+%$ zsp*?F!aV-89$U~HhZyig#w+M)fTcTDTrk|9ijYV8vj!q5)d!J|gu$BE{uGd3Tj{!g zf>@$l403uYPR6UZ#%kXr=)%!}i*o;t>tlWvml{R%#THXw=-=3~1Sh=%3FO zTxMM{#WeJFNTnRyWrlnSh*kM(s7rWT4S2u8X)nzO)Ky(?3FQ+90hmzPWIcSWy)L$9 zMv+lG-0x$f$(6m>!rH~Nd`37q5X~J_gnT}Ln81$0BH%!%1P$DmWYM2h+R^O4-@+

Pk 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