[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>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
17332035fa
Коммит
5be8557247
@@ -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)
|
||||
|
||||
15
app/group.go
15
app/group.go
@@ -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()
|
||||
|
||||
|
||||
Ссылка в новой задаче
Block a user