[MM-44168] Add feature to restore archived user groups (#20369)

* Add undelete feature, add mocks, tests

* Rename undelete->restore

* make store-layers

* make app-layers

* Store -> Store()

* Lint fixes

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Этот коммит содержится в:
Shivashis Padhi
2022-11-17 14:53:54 +05:30
коммит произвёл GitHub
родитель 17332035fa
Коммит 5be8557247
14 изменённых файлов: 301 добавлений и 0 удалений

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

@@ -84,6 +84,10 @@ func (api *API) InitGroup() {
api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}",
api.APISessionRequired(deleteGroup)).Methods("DELETE")
// GET /api/v4/groups/:group_id
api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/restore",
api.APISessionRequired(restoreGroup)).Methods("POST")
// POST /api/v4/groups/:group_id/members
api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/members",
api.APISessionRequired(addGroupMembers)).Methods("POST")
@@ -1125,6 +1129,55 @@ func deleteGroup(c *Context, w http.ResponseWriter, r *http.Request) {
ReturnStatusOK(w)
}
func restoreGroup(c *Context, w http.ResponseWriter, r *http.Request) {
permissionErr := requireLicense(c)
if permissionErr != nil {
c.Err = permissionErr
return
}
c.RequireGroupId()
if c.Err != nil {
return
}
group, err := c.App.GetGroup(c.Params.GroupId, nil, nil)
if err != nil {
c.Err = err
return
}
if group.Source != model.GroupSourceCustom {
c.Err = model.NewAppError("Api4.restoreGroup", "app.group.crud_permission", nil, "", http.StatusNotImplemented)
return
}
if lcErr := licensedAndConfiguredForGroupBySource(c.App, model.GroupSourceCustom); lcErr != nil {
lcErr.Where = "Api4.restoreGroup"
c.Err = lcErr
return
}
if !c.App.SessionHasPermissionToGroup(*c.AppContext.Session(), c.Params.GroupId, model.PermissionDeleteCustomGroup) {
c.SetPermissionError(model.PermissionDeleteCustomGroup)
return
}
auditRec := c.MakeAuditRecord("restoreGroup", audit.Fail)
defer c.LogAuditRec(auditRec)
auditRec.AddMeta("group_id", c.Params.GroupId)
_, err = c.App.RestoreGroup(c.Params.GroupId)
if err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func addGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) {
permissionErr := requireLicense(c)
if permissionErr != nil {

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

@@ -214,6 +214,33 @@ func TestDeleteGroup(t *testing.T) {
require.NoError(t, err)
CheckOKStatus(t, response)
}
func TestUndeleteGroup(t *testing.T) {
th := Setup(t)
defer th.TearDown()
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
validGroup, appErr := th.App.CreateGroup(&model.Group{
DisplayName: "dn_" + model.NewId(),
Name: model.NewString("name" + model.NewId()),
Source: model.GroupSourceCustom,
})
assert.Nil(t, appErr)
_, response, err := th.Client.DeleteGroup(validGroup.Id)
require.NoError(t, err)
CheckOKStatus(t, response)
_, response, err = th.Client.RestoreGroup(validGroup.Id, "")
require.NoError(t, err)
CheckOKStatus(t, response)
_, response, err = th.Client.RestoreGroup(validGroup.Id, "")
require.Error(t, err)
CheckNotFoundStatus(t, response)
}
func TestPatchGroup(t *testing.T) {
th := Setup(t)
defer th.TearDown()

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

@@ -968,6 +968,7 @@ type AppIface interface {
ResetPermissionsSystem() *model.AppError
ResetSamlAuthDataToEmail(includeDeleted bool, dryRun bool, userIDs []string) (numAffected int, appErr *model.AppError)
RestoreChannel(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError)
RestoreGroup(groupID string) (*model.Group, *model.AppError)
RestoreTeam(teamID string) *model.AppError
RestrictUsersGetByPermissions(userID string, options *model.UserGetOptions) (*model.UserGetOptions, *model.AppError)
RestrictUsersSearchByPermissions(userID string, options *model.UserSearchOptions) (*model.UserSearchOptions, *model.AppError)

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

@@ -217,6 +217,21 @@ func (a *App) DeleteGroup(groupID string) (*model.Group, *model.AppError) {
return deletedGroup, nil
}
func (a *App) RestoreGroup(groupID string) (*model.Group, *model.AppError) {
restoredGroup, err := a.Srv().Store().Group().Restore(groupID)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("RestoreGroup", "app.group.no_rows", nil, nfErr.Error(), http.StatusNotFound)
default:
return nil, model.NewAppError("RestoreGroup", "app.update_error", nil, err.Error(), http.StatusInternalServerError)
}
}
return restoredGroup, nil
}
func (a *App) GetGroupMemberCount(groupID string, viewRestrictions *model.ViewUsersRestrictions) (int64, *model.AppError) {
count, err := a.Srv().Store().Group().GetMemberCountWithRestrictions(groupID, viewRestrictions)
if err != nil {

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

@@ -127,6 +127,24 @@ func TestDeleteGroup(t *testing.T) {
require.Nil(t, g)
}
func TestUndeleteGroup(t *testing.T) {
th := Setup(t)
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.RestoreGroup(group.Id)
require.Nil(t, err)
require.NotNil(t, g)
g, err = th.App.RestoreGroup(group.Id)
require.NotNil(t, err)
require.Nil(t, g)
}
func TestCreateOrRestoreGroupMember(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()

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

@@ -13945,6 +13945,28 @@ func (a *OpenTracingAppLayer) RestoreChannel(c request.CTX, channel *model.Chann
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) RestoreGroup(groupID string) (*model.Group, *model.AppError) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RestoreGroup")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0, resultVar1 := a.app.RestoreGroup(groupID)
if resultVar1 != nil {
span.LogFields(spanlog.Error(resultVar1))
ext.Error.Set(span, true)
}
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) RestoreTeam(teamID string) *model.AppError {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RestoreTeam")

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

@@ -7433,6 +7433,19 @@ func (c *Client4) DeleteGroup(groupID string) (*Group, *Response, error) {
return &p, BuildResponse(r), nil
}
func (c *Client4) RestoreGroup(groupID string, etag string) (*Group, *Response, error) {
r, err := c.DoAPIPost(c.groupRoute(groupID)+"/restore", "")
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var p Group
if jsonErr := json.NewDecoder(r.Body).Decode(&p); jsonErr != nil {
return nil, nil, NewAppError("DeleteGroup", "api.unmarshal_error", nil, jsonErr.Error(), http.StatusInternalServerError)
}
return &p, BuildResponse(r), nil
}
func (c *Client4) PatchGroup(groupID string, patch *GroupPatch) (*Group, *Response, error) {
payload, err := json.Marshal(patch)
if err != nil {

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

@@ -4512,6 +4512,24 @@ func (s *OpenTracingLayerGroupStore) PermittedSyncableAdmins(syncableID string,
return result, err
}
func (s *OpenTracingLayerGroupStore) Restore(groupID string) (*model.Group, error) {
origCtx := s.Root.Store.Context()
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "GroupStore.Restore")
s.Root.Store.SetContext(newCtx)
defer func() {
s.Root.Store.SetContext(origCtx)
}()
defer span.Finish()
result, err := s.GroupStore.Restore(groupID)
if err != nil {
span.LogFields(spanlog.Error(err))
ext.Error.Set(span, true)
}
return result, err
}
func (s *OpenTracingLayerGroupStore) TeamMembersMinusGroupMembers(teamID string, groupIDs []string, page int, perPage int) ([]*model.UserWithGroups, error) {
origCtx := s.Root.Store.Context()
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "GroupStore.TeamMembersMinusGroupMembers")

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

@@ -5097,6 +5097,27 @@ func (s *RetryLayerGroupStore) PermittedSyncableAdmins(syncableID string, syncab
}
func (s *RetryLayerGroupStore) Restore(groupID string) (*model.Group, error) {
tries := 0
for {
result, err := s.GroupStore.Restore(groupID)
if err == nil {
return result, nil
}
if !isRepeatableError(err) {
return result, err
}
tries++
if tries >= 3 {
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
return result, err
}
timepkg.Sleep(100 * timepkg.Millisecond)
}
}
func (s *RetryLayerGroupStore) TeamMembersMinusGroupMembers(teamID string, groupIDs []string, page int, perPage int) ([]*model.UserWithGroups, error) {
tries := 0

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

@@ -360,6 +360,25 @@ func (s *SqlGroupStore) Delete(groupID string) (*model.Group, error) {
return &group, nil
}
func (s *SqlGroupStore) Restore(groupID string) (*model.Group, error) {
var group model.Group
if err := s.GetReplicaX().Get(&group, "SELECT * from UserGroups WHERE Id = ? AND DeleteAt != 0", groupID); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("Group", groupID)
}
return nil, errors.Wrapf(err, "failed to get Group with id=%s", groupID)
}
time := model.GetMillis()
if _, err := s.GetMasterX().Exec(`UPDATE UserGroups
SET DeleteAt=0, UpdateAt=?
WHERE Id=? AND DeleteAt!=0`, time, groupID); err != nil {
return nil, errors.Wrapf(err, "failed to update Group with id=%s", groupID)
}
return &group, nil
}
func (s *SqlGroupStore) GetMember(groupID, userID string) (*model.GroupMember, error) {
query, args, err := s.getQueryBuilder().
Select("*").

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

@@ -832,6 +832,7 @@ type GroupStore interface {
GetByUser(userID string) ([]*model.Group, error)
Update(group *model.Group) (*model.Group, error)
Delete(groupID string) (*model.Group, error)
Restore(groupID string) (*model.Group, error)
GetMemberUsers(groupID string) ([]*model.User, error)
GetMemberUsersPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, error)

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

@@ -32,6 +32,7 @@ func TestGroupStore(t *testing.T, ss store.Store) {
t.Run("GetByUser", func(t *testing.T) { testGroupStoreGetByUser(t, ss) })
t.Run("Update", func(t *testing.T) { testGroupStoreUpdate(t, ss) })
t.Run("Delete", func(t *testing.T) { testGroupStoreDelete(t, ss) })
t.Run("Restore", func(t *testing.T) { testGroupStoreRestore(t, ss) })
t.Run("GetMemberUsers", func(t *testing.T) { testGroupGetMemberUsers(t, ss) })
t.Run("GetMemberUsersPage", func(t *testing.T) { testGroupGetMemberUsersPage(t, ss) })
@@ -741,6 +742,59 @@ func testGroupStoreDelete(t *testing.T, ss store.Store) {
require.True(t, errors.As(err, &nfErr))
}
func testGroupStoreRestore(t *testing.T, ss store.Store) {
// Save a group
g1 := &model.Group{
Name: model.NewString(model.NewId()),
DisplayName: model.NewId(),
Description: model.NewId(),
Source: model.GroupSourceLdap,
RemoteId: model.NewString(model.NewId()),
}
d1, err := ss.Group().Create(g1)
require.NoError(t, err)
require.Len(t, d1.Id, 26)
// Check the group is retrievable
_, err = ss.Group().Get(d1.Id)
require.NoError(t, err)
// Delete the group
_, err = ss.Group().Delete(d1.Id)
require.NoError(t, err)
// Get the before count
d7, err := ss.Group().GetAllBySource(model.GroupSourceLdap)
require.NoError(t, err)
beforeCount := len(d7)
// restore the group
_, err = ss.Group().Restore(d1.Id)
require.NoError(t, err)
// Check the group is restored
d4, err := ss.Group().Get(d1.Id)
require.NoError(t, err)
require.Zero(t, d4.DeleteAt)
// Check the after count
d5, err := ss.Group().GetAllBySource(model.GroupSourceLdap)
require.NoError(t, err)
afterCount := len(d5)
require.Condition(t, func() bool { return beforeCount == afterCount-1 })
// Try and restore a nonexistent group
_, err = ss.Group().Delete(model.NewId())
require.Error(t, err)
var nfErr *store.ErrNotFound
require.True(t, errors.As(err, &nfErr))
// Cannot restore again
_, err = ss.Group().Restore(d1.Id)
require.True(t, errors.As(err, &nfErr))
}
func testGroupGetMemberUsers(t *testing.T, ss store.Store) {
// Save a group
g1 := &model.Group{

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

@@ -1012,6 +1012,29 @@ func (_m *GroupStore) PermittedSyncableAdmins(syncableID string, syncableType mo
return r0, r1
}
// Restore provides a mock function with given fields: groupID
func (_m *GroupStore) Restore(groupID string) (*model.Group, error) {
ret := _m.Called(groupID)
var r0 *model.Group
if rf, ok := ret.Get(0).(func(string) *model.Group); ok {
r0 = rf(groupID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Group)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(groupID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// TeamMembersMinusGroupMembers provides a mock function with given fields: teamID, groupIDs, page, perPage
func (_m *GroupStore) TeamMembersMinusGroupMembers(teamID string, groupIDs []string, page int, perPage int) ([]*model.UserWithGroups, error) {
ret := _m.Called(teamID, groupIDs, page, perPage)

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

@@ -4101,6 +4101,22 @@ func (s *TimerLayerGroupStore) PermittedSyncableAdmins(syncableID string, syncab
return result, err
}
func (s *TimerLayerGroupStore) Restore(groupID string) (*model.Group, error) {
start := time.Now()
result, err := s.GroupStore.Restore(groupID)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
success := "false"
if err == nil {
success = "true"
}
s.Root.Metrics.ObserveStoreMethodDuration("GroupStore.Restore", success, elapsed)
}
return result, err
}
func (s *TimerLayerGroupStore) TeamMembersMinusGroupMembers(teamID string, groupIDs []string, page int, perPage int) ([]*model.UserWithGroups, error) {
start := time.Now()