ProduceBackupCollections for SharePoint
This commit is contained in:
parent
2ba349797f
commit
203f7bd18d
@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/alcionai/corso/src/internal/diagnostics"
|
"github.com/alcionai/corso/src/internal/diagnostics"
|
||||||
"github.com/alcionai/corso/src/internal/m365/graph"
|
"github.com/alcionai/corso/src/internal/m365/graph"
|
||||||
"github.com/alcionai/corso/src/internal/m365/service/exchange"
|
"github.com/alcionai/corso/src/internal/m365/service/exchange"
|
||||||
|
"github.com/alcionai/corso/src/internal/m365/service/groups"
|
||||||
"github.com/alcionai/corso/src/internal/m365/service/onedrive"
|
"github.com/alcionai/corso/src/internal/m365/service/onedrive"
|
||||||
"github.com/alcionai/corso/src/internal/m365/service/sharepoint"
|
"github.com/alcionai/corso/src/internal/m365/service/sharepoint"
|
||||||
"github.com/alcionai/corso/src/internal/operations/inject"
|
"github.com/alcionai/corso/src/internal/operations/inject"
|
||||||
@ -116,6 +117,18 @@ func (ctrl *Controller) ProduceBackupCollections(
|
|||||||
return nil, nil, false, err
|
return nil, nil, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case path.GroupsService:
|
||||||
|
colls, ssmb, canUsePreviousBackup, err = groups.ProduceBackupCollections(
|
||||||
|
ctx,
|
||||||
|
bpc,
|
||||||
|
ctrl.AC,
|
||||||
|
ctrl.credentials,
|
||||||
|
ctrl.UpdateStatus,
|
||||||
|
errs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, nil, false, clues.Wrap(clues.New(service.String()), "service not supported").WithClues(ctx)
|
return nil, nil, false, clues.Wrap(clues.New(service.String()), "service not supported").WithClues(ctx)
|
||||||
}
|
}
|
||||||
@ -176,6 +189,10 @@ func verifyBackupInputs(sels selectors.Selector, siteIDs []string) error {
|
|||||||
// Exchange and OneDrive user existence now checked in checkServiceEnabled.
|
// Exchange and OneDrive user existence now checked in checkServiceEnabled.
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case selectors.ServiceGroups:
|
||||||
|
// TODO(meain): can use a proper check in checkServiceEnabled.
|
||||||
|
return nil
|
||||||
|
|
||||||
case selectors.ServiceSharePoint:
|
case selectors.ServiceSharePoint:
|
||||||
ids = siteIDs
|
ids = siteIDs
|
||||||
}
|
}
|
||||||
@ -197,7 +214,8 @@ func checkServiceEnabled(
|
|||||||
service path.ServiceType,
|
service path.ServiceType,
|
||||||
resource string,
|
resource string,
|
||||||
) (bool, bool, error) {
|
) (bool, bool, error) {
|
||||||
if service == path.SharePointService {
|
if service == path.SharePointService || service == path.GroupsService {
|
||||||
|
// TODO(meain): use proper check for groups
|
||||||
// No "enabled" check required for sharepoint
|
// No "enabled" check required for sharepoint
|
||||||
return true, true, nil
|
return true, true, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -465,3 +465,86 @@ func (suite *SPCollectionIntgSuite) TestCreateSharePointCollection_Lists() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CreateGroupsCollection tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type GroupsCollectionIntgSuite struct {
|
||||||
|
tester.Suite
|
||||||
|
connector *Controller
|
||||||
|
user string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupsCollectionIntgSuite(t *testing.T) {
|
||||||
|
suite.Run(t, &GroupsCollectionIntgSuite{
|
||||||
|
Suite: tester.NewIntegrationSuite(
|
||||||
|
t,
|
||||||
|
[][]string{tconfig.M365AcctCredEnvs},
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GroupsCollectionIntgSuite) SetupSuite() {
|
||||||
|
ctx, flush := tester.NewContext(suite.T())
|
||||||
|
defer flush()
|
||||||
|
|
||||||
|
suite.connector = newController(ctx, suite.T(), resource.Sites, path.GroupsService)
|
||||||
|
suite.user = tconfig.M365UserID(suite.T())
|
||||||
|
|
||||||
|
tester.LogTimeOfTest(suite.T())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GroupsCollectionIntgSuite) TestCreateGroupsCollection_SharePoint() {
|
||||||
|
t := suite.T()
|
||||||
|
|
||||||
|
ctx, flush := tester.NewContext(t)
|
||||||
|
defer flush()
|
||||||
|
|
||||||
|
var (
|
||||||
|
groupID = tconfig.M365GroupID(t)
|
||||||
|
ctrl = newController(ctx, t, resource.Groups, path.GroupsService)
|
||||||
|
groupIDs = []string{groupID}
|
||||||
|
)
|
||||||
|
|
||||||
|
id, name, err := ctrl.PopulateProtectedResourceIDAndName(ctx, groupID, nil)
|
||||||
|
require.NoError(t, err, clues.ToCore(err))
|
||||||
|
|
||||||
|
sel := selectors.NewGroupsBackup(groupIDs)
|
||||||
|
// TODO(meain): make use of selectors
|
||||||
|
sel.Include(sel.LibraryFolders([]string{"test"}, selectors.PrefixMatch()))
|
||||||
|
|
||||||
|
sel.SetDiscreteOwnerIDName(id, name)
|
||||||
|
|
||||||
|
bpc := inject.BackupProducerConfig{
|
||||||
|
LastBackupVersion: version.NoBackup,
|
||||||
|
Options: control.DefaultOptions(),
|
||||||
|
ProtectedResource: inMock.NewProvider(id, name),
|
||||||
|
Selector: sel.Selector,
|
||||||
|
}
|
||||||
|
|
||||||
|
collections, excludes, canUsePreviousBackup, err := ctrl.ProduceBackupCollections(
|
||||||
|
ctx,
|
||||||
|
bpc,
|
||||||
|
fault.New(true))
|
||||||
|
require.NoError(t, err, clues.ToCore(err))
|
||||||
|
assert.True(t, canUsePreviousBackup, "can use previous backup")
|
||||||
|
// No excludes yet as this isn't an incremental backup.
|
||||||
|
assert.True(t, excludes.Empty())
|
||||||
|
|
||||||
|
// we don't know an exact count of drives this will produce,
|
||||||
|
// but it should be more than one.
|
||||||
|
assert.Greater(t, len(collections), 1)
|
||||||
|
|
||||||
|
for _, coll := range collections {
|
||||||
|
for object := range coll.Items(ctx, fault.New(true)) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
_, err := buf.ReadFrom(object.ToReader())
|
||||||
|
assert.NoError(t, err, "reading item", clues.ToCore(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := ctrl.Wait()
|
||||||
|
assert.NotZero(t, status.Successes)
|
||||||
|
t.Log(status.String())
|
||||||
|
}
|
||||||
|
|||||||
@ -439,7 +439,7 @@ func (c *Collections) Get(
|
|||||||
service, category := c.handler.ServiceCat()
|
service, category := c.handler.ServiceCat()
|
||||||
md, err := graph.MakeMetadataCollection(
|
md, err := graph.MakeMetadataCollection(
|
||||||
c.tenantID,
|
c.tenantID,
|
||||||
c.resourceOwner,
|
c.resourceOwner, // TODO(meain): path fixes: group id
|
||||||
service,
|
service,
|
||||||
category,
|
category,
|
||||||
[]graph.MetadataCollectionEntry{
|
[]graph.MetadataCollectionEntry{
|
||||||
|
|||||||
192
src/internal/m365/collection/drive/group_handler.go
Normal file
192
src/internal/m365/collection/drive/group_handler.go
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
package drive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
||||||
|
|
||||||
|
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||||
|
odConsts "github.com/alcionai/corso/src/internal/m365/service/onedrive/consts"
|
||||||
|
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||||
|
"github.com/alcionai/corso/src/pkg/path"
|
||||||
|
"github.com/alcionai/corso/src/pkg/selectors"
|
||||||
|
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ BackupHandler = &groupBackupHandler{}
|
||||||
|
|
||||||
|
type groupBackupHandler struct {
|
||||||
|
groupID string
|
||||||
|
ac api.Drives
|
||||||
|
scope selectors.GroupsScope
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGroupBackupHandler(groupID string, ac api.Drives, scope selectors.GroupsScope) groupBackupHandler {
|
||||||
|
return groupBackupHandler{groupID, ac, scope}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h groupBackupHandler) Get(
|
||||||
|
ctx context.Context,
|
||||||
|
url string,
|
||||||
|
headers map[string]string,
|
||||||
|
) (*http.Response, error) {
|
||||||
|
return h.ac.Get(ctx, url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h groupBackupHandler) PathPrefix(
|
||||||
|
tenantID, resourceOwner, driveID string,
|
||||||
|
) (path.Path, error) {
|
||||||
|
return path.Build(
|
||||||
|
tenantID,
|
||||||
|
resourceOwner,
|
||||||
|
path.GroupsService,
|
||||||
|
path.LibrariesCategory, // TODO(meain)
|
||||||
|
false,
|
||||||
|
odConsts.DrivesPathDir,
|
||||||
|
driveID,
|
||||||
|
odConsts.RootPathDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h groupBackupHandler) CanonicalPath(
|
||||||
|
folders *path.Builder,
|
||||||
|
tenantID, resourceOwner string,
|
||||||
|
) (path.Path, error) {
|
||||||
|
// TODO(meain): resourceOwner
|
||||||
|
return folders.ToDataLayerGroupPath(tenantID, h.groupID, path.LibrariesCategory, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h groupBackupHandler) ServiceCat() (path.ServiceType, path.CategoryType) {
|
||||||
|
return path.GroupsService, path.LibrariesCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h groupBackupHandler) NewDrivePager(
|
||||||
|
resourceOwner string,
|
||||||
|
fields []string,
|
||||||
|
) api.DrivePager {
|
||||||
|
return h.ac.NewSiteDrivePager(resourceOwner, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h groupBackupHandler) NewItemPager(
|
||||||
|
driveID, link string,
|
||||||
|
fields []string,
|
||||||
|
) api.DriveItemDeltaEnumerator {
|
||||||
|
return h.ac.NewDriveItemDeltaPager(driveID, link, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h groupBackupHandler) AugmentItemInfo(
|
||||||
|
dii details.ItemInfo,
|
||||||
|
item models.DriveItemable,
|
||||||
|
size int64,
|
||||||
|
parentPath *path.Builder,
|
||||||
|
) details.ItemInfo {
|
||||||
|
return augmentGroupItemInfo(dii, item, size, parentPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h groupBackupHandler) FormatDisplayPath(
|
||||||
|
driveName string,
|
||||||
|
pb *path.Builder,
|
||||||
|
) string {
|
||||||
|
return "/" + driveName + "/" + pb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h groupBackupHandler) NewLocationIDer(
|
||||||
|
driveID string,
|
||||||
|
elems ...string,
|
||||||
|
) details.LocationIDer {
|
||||||
|
return details.NewSharePointLocationIDer(driveID, elems...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h groupBackupHandler) GetItemPermission(
|
||||||
|
ctx context.Context,
|
||||||
|
driveID, itemID string,
|
||||||
|
) (models.PermissionCollectionResponseable, error) {
|
||||||
|
return h.ac.GetItemPermission(ctx, driveID, itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h groupBackupHandler) GetItem(
|
||||||
|
ctx context.Context,
|
||||||
|
driveID, itemID string,
|
||||||
|
) (models.DriveItemable, error) {
|
||||||
|
return h.ac.GetItem(ctx, driveID, itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h groupBackupHandler) IsAllPass() bool {
|
||||||
|
// TODO(meain)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h groupBackupHandler) IncludesDir(dir string) bool {
|
||||||
|
// TODO(meain)
|
||||||
|
// return h.scope.Matches(selectors.SharePointGroupFolder, dir)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Common
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func augmentGroupItemInfo(
|
||||||
|
dii details.ItemInfo,
|
||||||
|
item models.DriveItemable,
|
||||||
|
size int64,
|
||||||
|
parentPath *path.Builder,
|
||||||
|
) details.ItemInfo {
|
||||||
|
var driveName, driveID, creatorEmail string
|
||||||
|
|
||||||
|
// TODO: we rely on this info for details/restore lookups,
|
||||||
|
// so if it's nil we have an issue, and will need an alternative
|
||||||
|
// way to source the data.
|
||||||
|
|
||||||
|
if item.GetCreatedBy() != nil && item.GetCreatedBy().GetUser() != nil {
|
||||||
|
// User is sometimes not available when created via some
|
||||||
|
// external applications (like backup/restore solutions)
|
||||||
|
additionalData := item.GetCreatedBy().GetUser().GetAdditionalData()
|
||||||
|
|
||||||
|
ed, ok := additionalData["email"]
|
||||||
|
if !ok {
|
||||||
|
ed = additionalData["displayName"]
|
||||||
|
}
|
||||||
|
|
||||||
|
if ed != nil {
|
||||||
|
creatorEmail = *ed.(*string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gsi := item.GetSharepointIds()
|
||||||
|
// if gsi != nil {
|
||||||
|
// siteID = ptr.Val(gsi.GetSiteId())
|
||||||
|
// weburl = ptr.Val(gsi.GetSiteUrl())
|
||||||
|
|
||||||
|
// if len(weburl) == 0 {
|
||||||
|
// weburl = constructWebURL(item.GetAdditionalData())
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
if item.GetParentReference() != nil {
|
||||||
|
driveID = ptr.Val(item.GetParentReference().GetDriveId())
|
||||||
|
driveName = strings.TrimSpace(ptr.Val(item.GetParentReference().GetName()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var pps string
|
||||||
|
if parentPath != nil {
|
||||||
|
pps = parentPath.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
dii.Groups = &details.GroupsInfo{
|
||||||
|
Created: ptr.Val(item.GetCreatedDateTime()),
|
||||||
|
DriveID: driveID,
|
||||||
|
DriveName: driveName,
|
||||||
|
ItemName: ptr.Val(item.GetName()),
|
||||||
|
ItemType: details.SharePointLibrary,
|
||||||
|
Modified: ptr.Val(item.GetLastModifiedDateTime()),
|
||||||
|
Owner: creatorEmail,
|
||||||
|
ParentPath: pps,
|
||||||
|
Size: size,
|
||||||
|
}
|
||||||
|
|
||||||
|
dii.Extension = &details.ExtensionData{}
|
||||||
|
|
||||||
|
return dii
|
||||||
|
}
|
||||||
@ -25,10 +25,9 @@ import (
|
|||||||
func CollectLibraries(
|
func CollectLibraries(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
bpc inject.BackupProducerConfig,
|
bpc inject.BackupProducerConfig,
|
||||||
ad api.Drives,
|
bh drive.BackupHandler,
|
||||||
tenantID string,
|
tenantID string,
|
||||||
ssmb *prefixmatcher.StringSetMatchBuilder,
|
ssmb *prefixmatcher.StringSetMatchBuilder,
|
||||||
scope selectors.SharePointScope,
|
|
||||||
su support.StatusUpdater,
|
su support.StatusUpdater,
|
||||||
errs *fault.Bus,
|
errs *fault.Bus,
|
||||||
) ([]data.BackupCollection, bool, error) {
|
) ([]data.BackupCollection, bool, error) {
|
||||||
@ -37,7 +36,7 @@ func CollectLibraries(
|
|||||||
var (
|
var (
|
||||||
collections = []data.BackupCollection{}
|
collections = []data.BackupCollection{}
|
||||||
colls = drive.NewCollections(
|
colls = drive.NewCollections(
|
||||||
drive.NewLibraryBackupHandler(ad, scope),
|
bh,
|
||||||
tenantID,
|
tenantID,
|
||||||
bpc.ProtectedResource.ID(),
|
bpc.ProtectedResource.ID(),
|
||||||
su,
|
su,
|
||||||
|
|||||||
@ -170,6 +170,8 @@ func getResourceClient(rc resource.Category, ac api.Client) (*resourceClient, er
|
|||||||
return &resourceClient{enum: rc, getter: ac.Users()}, nil
|
return &resourceClient{enum: rc, getter: ac.Users()}, nil
|
||||||
case resource.Sites:
|
case resource.Sites:
|
||||||
return &resourceClient{enum: rc, getter: ac.Sites()}, nil
|
return &resourceClient{enum: rc, getter: ac.Sites()}, nil
|
||||||
|
case resource.Groups:
|
||||||
|
return &resourceClient{enum: rc, getter: ac.Groups()}, nil
|
||||||
default:
|
default:
|
||||||
return nil, clues.New("unrecognized owner resource enum").With("resource_enum", rc)
|
return nil, clues.New("unrecognized owner resource enum").With("resource_enum", rc)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,4 +6,5 @@ const (
|
|||||||
UnknownResource Category = ""
|
UnknownResource Category = ""
|
||||||
Users Category = "users"
|
Users Category = "users"
|
||||||
Sites Category = "sites"
|
Sites Category = "sites"
|
||||||
|
Groups Category = "groups"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,8 +5,12 @@ import (
|
|||||||
|
|
||||||
"github.com/alcionai/clues"
|
"github.com/alcionai/clues"
|
||||||
|
|
||||||
|
"github.com/alcionai/corso/src/internal/common/idname"
|
||||||
"github.com/alcionai/corso/src/internal/common/prefixmatcher"
|
"github.com/alcionai/corso/src/internal/common/prefixmatcher"
|
||||||
|
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||||
"github.com/alcionai/corso/src/internal/data"
|
"github.com/alcionai/corso/src/internal/data"
|
||||||
|
"github.com/alcionai/corso/src/internal/m365/collection/drive"
|
||||||
|
"github.com/alcionai/corso/src/internal/m365/collection/site"
|
||||||
"github.com/alcionai/corso/src/internal/m365/graph"
|
"github.com/alcionai/corso/src/internal/m365/graph"
|
||||||
"github.com/alcionai/corso/src/internal/m365/support"
|
"github.com/alcionai/corso/src/internal/m365/support"
|
||||||
"github.com/alcionai/corso/src/internal/observe"
|
"github.com/alcionai/corso/src/internal/observe"
|
||||||
@ -56,7 +60,41 @@ func ProduceBackupCollections(
|
|||||||
var dbcs []data.BackupCollection
|
var dbcs []data.BackupCollection
|
||||||
|
|
||||||
switch scope.Category().PathType() {
|
switch scope.Category().PathType() {
|
||||||
case path.LibrariesCategory: // TODO
|
case path.LibrariesCategory:
|
||||||
|
// TODO(meain): Private channels get a separate SharePoint
|
||||||
|
// site. We should also back those up and not just the
|
||||||
|
// default one.
|
||||||
|
resp, err := ac.Stable.
|
||||||
|
Client().
|
||||||
|
Groups().
|
||||||
|
ByGroupId(bpc.ProtectedResource.ID()).
|
||||||
|
Sites().
|
||||||
|
BySiteId("root").
|
||||||
|
Get(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, false, clues.Wrap(err, "getting root site for group")
|
||||||
|
}
|
||||||
|
|
||||||
|
pr := idname.NewProvider(ptr.Val(resp.GetId()), ptr.Val(resp.GetName()))
|
||||||
|
sbpc := inject.BackupProducerConfig{
|
||||||
|
LastBackupVersion: bpc.LastBackupVersion,
|
||||||
|
Options: bpc.Options,
|
||||||
|
ProtectedResource: pr,
|
||||||
|
Selector: bpc.Selector,
|
||||||
|
}
|
||||||
|
|
||||||
|
dbcs, canUsePreviousBackup, err = site.CollectLibraries(
|
||||||
|
ctx,
|
||||||
|
sbpc,
|
||||||
|
drive.NewGroupBackupHandler(bpc.ProtectedResource.ID(), ac.Drives(), scope),
|
||||||
|
creds.AzureTenantID,
|
||||||
|
ssmb,
|
||||||
|
su,
|
||||||
|
errs)
|
||||||
|
if err != nil {
|
||||||
|
el.AddRecoverable(ctx, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
collections = append(collections, dbcs...)
|
collections = append(collections, dbcs...)
|
||||||
@ -70,7 +108,7 @@ func ProduceBackupCollections(
|
|||||||
collections,
|
collections,
|
||||||
creds.AzureTenantID,
|
creds.AzureTenantID,
|
||||||
bpc.ProtectedResource.ID(),
|
bpc.ProtectedResource.ID(),
|
||||||
path.UnknownService, // path.GroupsService
|
path.GroupsService,
|
||||||
categories,
|
categories,
|
||||||
su,
|
su,
|
||||||
errs)
|
errs)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/alcionai/corso/src/internal/common/prefixmatcher"
|
"github.com/alcionai/corso/src/internal/common/prefixmatcher"
|
||||||
"github.com/alcionai/corso/src/internal/data"
|
"github.com/alcionai/corso/src/internal/data"
|
||||||
|
"github.com/alcionai/corso/src/internal/m365/collection/drive"
|
||||||
"github.com/alcionai/corso/src/internal/m365/collection/site"
|
"github.com/alcionai/corso/src/internal/m365/collection/site"
|
||||||
"github.com/alcionai/corso/src/internal/m365/graph"
|
"github.com/alcionai/corso/src/internal/m365/graph"
|
||||||
"github.com/alcionai/corso/src/internal/m365/support"
|
"github.com/alcionai/corso/src/internal/m365/support"
|
||||||
@ -79,10 +80,9 @@ func ProduceBackupCollections(
|
|||||||
spcs, canUsePreviousBackup, err = site.CollectLibraries(
|
spcs, canUsePreviousBackup, err = site.CollectLibraries(
|
||||||
ctx,
|
ctx,
|
||||||
bpc,
|
bpc,
|
||||||
ac.Drives(),
|
drive.NewLibraryBackupHandler(ac.Drives(), scope),
|
||||||
creds.AzureTenantID,
|
creds.AzureTenantID,
|
||||||
ssmb,
|
ssmb,
|
||||||
scope,
|
|
||||||
su,
|
su,
|
||||||
errs)
|
errs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -30,6 +30,7 @@ const (
|
|||||||
TestCfgGroupID = "m365groupid"
|
TestCfgGroupID = "m365groupid"
|
||||||
TestCfgUserID = "m365userid"
|
TestCfgUserID = "m365userid"
|
||||||
TestCfgSecondaryUserID = "secondarym365userid"
|
TestCfgSecondaryUserID = "secondarym365userid"
|
||||||
|
TestCfgSecondaryGroupID = "secondarym365groupid"
|
||||||
TestCfgTertiaryUserID = "tertiarym365userid"
|
TestCfgTertiaryUserID = "tertiarym365userid"
|
||||||
TestCfgLoadTestUserID = "loadtestm365userid"
|
TestCfgLoadTestUserID = "loadtestm365userid"
|
||||||
TestCfgLoadTestOrgUsers = "loadtestm365orgusers"
|
TestCfgLoadTestOrgUsers = "loadtestm365orgusers"
|
||||||
|
|||||||
@ -241,6 +241,8 @@ func (pb Builder) ToStreamStorePath(
|
|||||||
metadataService = OneDriveMetadataService
|
metadataService = OneDriveMetadataService
|
||||||
case SharePointService:
|
case SharePointService:
|
||||||
metadataService = SharePointMetadataService
|
metadataService = SharePointMetadataService
|
||||||
|
case GroupsService:
|
||||||
|
metadataService = GroupsMetadataService
|
||||||
}
|
}
|
||||||
|
|
||||||
return &dataLayerResourcePath{
|
return &dataLayerResourcePath{
|
||||||
@ -282,6 +284,8 @@ func (pb Builder) ToServiceCategoryMetadataPath(
|
|||||||
metadataService = OneDriveMetadataService
|
metadataService = OneDriveMetadataService
|
||||||
case SharePointService:
|
case SharePointService:
|
||||||
metadataService = SharePointMetadataService
|
metadataService = SharePointMetadataService
|
||||||
|
case GroupsService:
|
||||||
|
metadataService = GroupsMetadataService
|
||||||
}
|
}
|
||||||
|
|
||||||
return &dataLayerResourcePath{
|
return &dataLayerResourcePath{
|
||||||
@ -346,6 +350,14 @@ func (pb Builder) ToDataLayerSharePointPath(
|
|||||||
return pb.ToDataLayerPath(tenant, site, SharePointService, category, isItem)
|
return pb.ToDataLayerPath(tenant, site, SharePointService, category, isItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pb Builder) ToDataLayerGroupPath(
|
||||||
|
tenant, group string,
|
||||||
|
category CategoryType,
|
||||||
|
isItem bool,
|
||||||
|
) (Path, error) {
|
||||||
|
return pb.ToDataLayerPath(tenant, group, GroupsService, category, isItem)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Stringers and PII Concealer Compliance
|
// Stringers and PII Concealer Compliance
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -73,6 +73,9 @@ var serviceCategories = map[ServiceType]map[CategoryType]struct{}{
|
|||||||
ListsCategory: {},
|
ListsCategory: {},
|
||||||
PagesCategory: {},
|
PagesCategory: {},
|
||||||
},
|
},
|
||||||
|
GroupsService: {
|
||||||
|
LibrariesCategory: {}, // TODO(meain)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateServiceAndCategoryStrings(s, c string) (ServiceType, CategoryType, error) {
|
func validateServiceAndCategoryStrings(s, c string) (ServiceType, CategoryType, error) {
|
||||||
|
|||||||
@ -13,10 +13,12 @@ var piiSafePathElems = pii.MapWithPlurals(
|
|||||||
UnknownService.String(),
|
UnknownService.String(),
|
||||||
ExchangeService.String(),
|
ExchangeService.String(),
|
||||||
OneDriveService.String(),
|
OneDriveService.String(),
|
||||||
|
GroupsService.String(),
|
||||||
SharePointService.String(),
|
SharePointService.String(),
|
||||||
ExchangeMetadataService.String(),
|
ExchangeMetadataService.String(),
|
||||||
OneDriveMetadataService.String(),
|
OneDriveMetadataService.String(),
|
||||||
SharePointMetadataService.String(),
|
SharePointMetadataService.String(),
|
||||||
|
GroupsMetadataService.String(),
|
||||||
|
|
||||||
// categories
|
// categories
|
||||||
UnknownCategory.String(),
|
UnknownCategory.String(),
|
||||||
|
|||||||
@ -328,6 +328,13 @@ func (suite *DataLayerResourcePath) TestToServiceCategoryMetadataPath() {
|
|||||||
expectedService: path.SharePointMetadataService,
|
expectedService: path.SharePointMetadataService,
|
||||||
check: assert.NoError,
|
check: assert.NoError,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Passes",
|
||||||
|
service: path.GroupsService,
|
||||||
|
category: path.LibrariesCategory,
|
||||||
|
expectedService: path.GroupsMetadataService,
|
||||||
|
check: assert.NoError,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range table {
|
for _, test := range table {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||||
"github.com/alcionai/corso/src/pkg/backup/identity"
|
"github.com/alcionai/corso/src/pkg/backup/identity"
|
||||||
"github.com/alcionai/corso/src/pkg/fault"
|
"github.com/alcionai/corso/src/pkg/fault"
|
||||||
|
"github.com/alcionai/corso/src/pkg/filters"
|
||||||
"github.com/alcionai/corso/src/pkg/path"
|
"github.com/alcionai/corso/src/pkg/path"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -204,8 +205,8 @@ func (s *groups) Scopes() []GroupsScope {
|
|||||||
// -------------------
|
// -------------------
|
||||||
// Scope Factories
|
// Scope Factories
|
||||||
|
|
||||||
// Produces one or more Groups site scopes.
|
// Produces one or more Groups scopes.
|
||||||
// One scope is created per site entry.
|
// One scope is created per group entry.
|
||||||
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
|
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
|
||||||
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
|
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
|
||||||
// If any slice is empty, it defaults to [selectors.None]
|
// If any slice is empty, it defaults to [selectors.None]
|
||||||
@ -214,38 +215,57 @@ func (s *groups) AllData() []GroupsScope {
|
|||||||
|
|
||||||
scopes = append(
|
scopes = append(
|
||||||
scopes,
|
scopes,
|
||||||
makeScope[GroupsScope](GroupsTODOContainer, Any()))
|
makeScope[GroupsScope](GroupsLibraryFolder, Any()))
|
||||||
|
|
||||||
return scopes
|
return scopes
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO produces one or more Groups TODO scopes.
|
// Library produces one or more Group library scopes, where the library
|
||||||
|
// matches upon a given drive by ID or Name. In order to ensure library selection
|
||||||
|
// this should always be embedded within the Filter() set; include(Library()) will
|
||||||
|
// select all items in the library without further filtering.
|
||||||
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
|
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
|
||||||
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
|
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
|
||||||
// Any empty slice defaults to [selectors.None]
|
// If any slice is empty, it defaults to [selectors.None]
|
||||||
func (s *groups) TODO(lists []string, opts ...option) []GroupsScope {
|
func (s *groups) Library(library string) []GroupsScope {
|
||||||
|
return []GroupsScope{
|
||||||
|
makeInfoScope[GroupsScope](
|
||||||
|
GroupsLibraryItem,
|
||||||
|
SharePointInfoLibraryDrive, // TODO(meain)
|
||||||
|
[]string{library},
|
||||||
|
filters.Equal),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryFolders produces one or more SharePoint libraryFolder scopes.
|
||||||
|
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
|
||||||
|
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
|
||||||
|
// If any slice is empty, it defaults to [selectors.None]
|
||||||
|
func (s *groups) LibraryFolders(libraryFolders []string, opts ...option) []GroupsScope {
|
||||||
var (
|
var (
|
||||||
scopes = []GroupsScope{}
|
scopes = []GroupsScope{}
|
||||||
os = append([]option{pathComparator()}, opts...)
|
os = append([]option{pathComparator()}, opts...)
|
||||||
)
|
)
|
||||||
|
|
||||||
scopes = append(scopes, makeScope[GroupsScope](GroupsTODOContainer, lists, os...))
|
scopes = append(
|
||||||
|
scopes,
|
||||||
|
makeScope[GroupsScope](GroupsLibraryFolder, libraryFolders, os...))
|
||||||
|
|
||||||
return scopes
|
return scopes
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTODOItemsItems produces one or more Groups TODO item scopes.
|
// LibraryItems produces one or more Groups library item scopes.
|
||||||
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
|
// If any slice contains selectors.Any, that slice is reduced to [selectors.Any]
|
||||||
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
|
// If any slice contains selectors.None, that slice is reduced to [selectors.None]
|
||||||
// If any slice is empty, it defaults to [selectors.None]
|
// If any slice is empty, it defaults to [selectors.None]
|
||||||
// options are only applied to the list scopes.
|
// options are only applied to the library scopes.
|
||||||
func (s *groups) TODOItems(lists, items []string, opts ...option) []GroupsScope {
|
func (s *groups) LibraryItems(libraries, items []string, opts ...option) []GroupsScope {
|
||||||
scopes := []GroupsScope{}
|
scopes := []GroupsScope{}
|
||||||
|
|
||||||
scopes = append(
|
scopes = append(
|
||||||
scopes,
|
scopes,
|
||||||
makeScope[GroupsScope](GroupsTODOItem, items, defaultItemOptions(s.Cfg)...).
|
makeScope[GroupsScope](GroupsLibraryItem, items, defaultItemOptions(s.Cfg)...).
|
||||||
set(GroupsTODOContainer, lists, opts...))
|
set(GroupsLibraryFolder, libraries, opts...))
|
||||||
|
|
||||||
return scopes
|
return scopes
|
||||||
}
|
}
|
||||||
@ -270,21 +290,27 @@ const (
|
|||||||
GroupsCategoryUnknown groupsCategory = ""
|
GroupsCategoryUnknown groupsCategory = ""
|
||||||
|
|
||||||
// types of data in Groups
|
// types of data in Groups
|
||||||
GroupsGroup groupsCategory = "GroupsGroup"
|
GroupsGroup groupsCategory = "GroupsGroup"
|
||||||
GroupsTODOContainer groupsCategory = "GroupsTODOContainer"
|
|
||||||
GroupsTODOItem groupsCategory = "GroupsTODOItem"
|
// sharepoint
|
||||||
|
GroupsLibraryFolder groupsCategory = "GroupsLibraryFolder"
|
||||||
|
GroupsLibraryItem groupsCategory = "GroupsLibraryItem"
|
||||||
|
|
||||||
|
// messages
|
||||||
|
// GroupsTeamChannel groupsCategory = "GroupsTeamChannel"
|
||||||
|
// GroupsTeamChannelMessages groupsCategory = "GroupsTeamChannelMessages"
|
||||||
|
|
||||||
// details.itemInfo comparables
|
// details.itemInfo comparables
|
||||||
|
|
||||||
// library drive selection
|
// library drive selection
|
||||||
GroupsInfoSiteLibraryDrive groupsCategory = "GroupsInfoSiteLibraryDrive"
|
GroupsInfoSiteLibraryDrive groupsCategory = "GroupsInfoSiteLibraryDrive" // TODO(meain)
|
||||||
)
|
)
|
||||||
|
|
||||||
// groupsLeafProperties describes common metadata of the leaf categories
|
// groupsLeafProperties describes common metadata of the leaf categories
|
||||||
var groupsLeafProperties = map[categorizer]leafProperty{
|
var groupsLeafProperties = map[categorizer]leafProperty{
|
||||||
GroupsTODOItem: { // the root category must be represented, even though it isn't a leaf
|
GroupsLibraryItem: {
|
||||||
pathKeys: []categorizer{GroupsTODOContainer, GroupsTODOItem},
|
pathKeys: []categorizer{GroupsLibraryFolder, GroupsLibraryItem},
|
||||||
pathType: path.UnknownCategory,
|
pathType: path.LibrariesCategory,
|
||||||
},
|
},
|
||||||
GroupsGroup: { // the root category must be represented, even though it isn't a leaf
|
GroupsGroup: { // the root category must be represented, even though it isn't a leaf
|
||||||
pathKeys: []categorizer{GroupsGroup},
|
pathKeys: []categorizer{GroupsGroup},
|
||||||
@ -303,8 +329,8 @@ func (c groupsCategory) String() string {
|
|||||||
// Ex: ServiceUser.leafCat() => ServiceUser
|
// Ex: ServiceUser.leafCat() => ServiceUser
|
||||||
func (c groupsCategory) leafCat() categorizer {
|
func (c groupsCategory) leafCat() categorizer {
|
||||||
switch c {
|
switch c {
|
||||||
case GroupsTODOContainer, GroupsInfoSiteLibraryDrive:
|
case GroupsLibraryFolder, GroupsLibraryItem, GroupsInfoSiteLibraryDrive:
|
||||||
return GroupsTODOItem
|
return GroupsLibraryItem
|
||||||
}
|
}
|
||||||
|
|
||||||
return c
|
return c
|
||||||
@ -334,7 +360,7 @@ func (c groupsCategory) isLeaf() bool {
|
|||||||
// pathValues transforms the two paths to maps of identified properties.
|
// pathValues transforms the two paths to maps of identified properties.
|
||||||
//
|
//
|
||||||
// Example:
|
// Example:
|
||||||
// [tenantID, service, siteID, category, folder, itemID]
|
// [tenantID, service, groupID, site, siteID, category, folder, itemID]
|
||||||
// => {spFolder: folder, spItemID: itemID}
|
// => {spFolder: folder, spItemID: itemID}
|
||||||
func (c groupsCategory) pathValues(
|
func (c groupsCategory) pathValues(
|
||||||
repo path.Path,
|
repo path.Path,
|
||||||
@ -348,13 +374,13 @@ func (c groupsCategory) pathValues(
|
|||||||
)
|
)
|
||||||
|
|
||||||
switch c {
|
switch c {
|
||||||
case GroupsTODOContainer, GroupsTODOItem:
|
case GroupsLibraryFolder, GroupsLibraryItem:
|
||||||
if ent.Groups == nil {
|
if ent.Groups == nil {
|
||||||
return nil, clues.New("no Groups ItemInfo in details")
|
return nil, clues.New("no Groups ItemInfo in details")
|
||||||
}
|
}
|
||||||
|
|
||||||
folderCat, itemCat = GroupsTODOContainer, GroupsTODOItem
|
folderCat, itemCat = GroupsLibraryFolder, GroupsLibraryItem
|
||||||
rFld = ent.Groups.ParentPath
|
rFld = ent.Groups.ParentPath // TODO(meain)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, clues.New("unrecognized groupsCategory").With("category", c)
|
return nil, clues.New("unrecognized groupsCategory").With("category", c)
|
||||||
@ -451,7 +477,7 @@ func (s GroupsScope) set(cat groupsCategory, v []string, opts ...option) GroupsS
|
|||||||
os := []option{}
|
os := []option{}
|
||||||
|
|
||||||
switch cat {
|
switch cat {
|
||||||
case GroupsTODOContainer:
|
case GroupsLibraryFolder:
|
||||||
os = append(os, pathComparator())
|
os = append(os, pathComparator())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -462,10 +488,10 @@ func (s GroupsScope) set(cat groupsCategory, v []string, opts ...option) GroupsS
|
|||||||
func (s GroupsScope) setDefaults() {
|
func (s GroupsScope) setDefaults() {
|
||||||
switch s.Category() {
|
switch s.Category() {
|
||||||
case GroupsGroup:
|
case GroupsGroup:
|
||||||
s[GroupsTODOContainer.String()] = passAny
|
s[GroupsLibraryFolder.String()] = passAny
|
||||||
s[GroupsTODOItem.String()] = passAny
|
s[GroupsLibraryItem.String()] = passAny
|
||||||
case GroupsTODOContainer:
|
case GroupsLibraryFolder:
|
||||||
s[GroupsTODOItem.String()] = passAny
|
s[GroupsLibraryItem.String()] = passAny
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -485,7 +511,7 @@ func (s groups) Reduce(
|
|||||||
deets,
|
deets,
|
||||||
s.Selector,
|
s.Selector,
|
||||||
map[path.CategoryType]groupsCategory{
|
map[path.CategoryType]groupsCategory{
|
||||||
path.UnknownCategory: GroupsTODOItem,
|
path.LibrariesCategory: GroupsLibraryItem,
|
||||||
},
|
},
|
||||||
errs)
|
errs)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,7 @@ var serviceToPathType = map[service]path.ServiceType{
|
|||||||
ServiceExchange: path.ExchangeService,
|
ServiceExchange: path.ExchangeService,
|
||||||
ServiceOneDrive: path.OneDriveService,
|
ServiceOneDrive: path.OneDriveService,
|
||||||
ServiceSharePoint: path.SharePointService,
|
ServiceSharePoint: path.SharePointService,
|
||||||
|
ServiceGroups: path.GroupsService,
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
msgraphgocore "github.com/microsoftgraph/msgraph-sdk-go-core"
|
msgraphgocore "github.com/microsoftgraph/msgraph-sdk-go-core"
|
||||||
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
||||||
|
|
||||||
|
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||||
"github.com/alcionai/corso/src/internal/common/str"
|
"github.com/alcionai/corso/src/internal/common/str"
|
||||||
"github.com/alcionai/corso/src/internal/common/tform"
|
"github.com/alcionai/corso/src/internal/common/tform"
|
||||||
"github.com/alcionai/corso/src/internal/m365/graph"
|
"github.com/alcionai/corso/src/internal/m365/graph"
|
||||||
@ -27,7 +28,7 @@ func (c Client) Groups() Groups {
|
|||||||
return Groups{c}
|
return Groups{c}
|
||||||
}
|
}
|
||||||
|
|
||||||
// On creation of each Teams team a corrsponding group gets created.
|
// On creation of each Teams team a corresponding group gets created.
|
||||||
// The group acts as the protected resource, and all teams data like events,
|
// The group acts as the protected resource, and all teams data like events,
|
||||||
// drive and mail messages are owned by that group.
|
// drive and mail messages are owned by that group.
|
||||||
|
|
||||||
@ -167,3 +168,14 @@ func IsTeam(ctx context.Context, mg models.Groupable) bool {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetIDAndName looks up the group matching the given ID, and returns
|
||||||
|
// its canonical ID and the name.
|
||||||
|
func (c Groups) GetIDAndName(ctx context.Context, groupID string) (string, string, error) {
|
||||||
|
s, err := c.GetByID(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ptr.Val(s.GetId()), ptr.Val(s.GetDisplayName()), nil
|
||||||
|
}
|
||||||
|
|||||||
@ -107,7 +107,7 @@ func (suite *GroupsIntgSuite) TestGetAll() {
|
|||||||
Groups().
|
Groups().
|
||||||
GetAll(ctx, fault.New(true))
|
GetAll(ctx, fault.New(true))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotZero(t, len(groups), "must find at least one group")
|
require.NotZero(t, len(groups), "must have at least one group")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *GroupsIntgSuite) TestGroups_GetByID() {
|
func (suite *GroupsIntgSuite) TestGroups_GetByID() {
|
||||||
@ -122,34 +122,19 @@ func (suite *GroupsIntgSuite) TestGroups_GetByID() {
|
|||||||
expectErr func(*testing.T, error)
|
expectErr func(*testing.T, error)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "3 part id",
|
name: "valid id",
|
||||||
id: groupID,
|
id: groupID,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.NoError(t, err, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "malformed id",
|
name: "invalid id",
|
||||||
id: uuid.NewString(),
|
id: uuid.NewString(),
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.Error(t, err, clues.ToCore(err))
|
assert.Error(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "random id",
|
|
||||||
id: uuid.NewString() + "," + uuid.NewString(),
|
|
||||||
expectErr: func(t *testing.T, err error) {
|
|
||||||
assert.Error(t, err, clues.ToCore(err))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name: "malformed url",
|
|
||||||
id: "barunihlda",
|
|
||||||
expectErr: func(t *testing.T, err error) {
|
|
||||||
assert.Error(t, err, clues.ToCore(err))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, test := range table {
|
for _, test := range table {
|
||||||
suite.Run(test.name, func() {
|
suite.Run(test.name, func() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user