From 0e6ef90e413555efcb6e4e114b58dab886cbf9ee Mon Sep 17 00:00:00 2001 From: Abin Simon Date: Tue, 22 Aug 2023 13:28:03 +0530 Subject: [PATCH] Create backup collections for Group's default SharePoint site (#4030) This commit has the initial rough set of changes needed to create collections from the group's default SharePoint site. This still does not have all the functionality that we need, but the idea was that we could get this in and iterate over time. --- #### Does this PR need a docs update or release note? - [ ] :white_check_mark: Yes, it's included - [x] :clock1: Yes, but in a later PR - [ ] :no_entry: No #### Type of change - [x] :sunflower: Feature - [ ] :bug: Bugfix - [ ] :world_map: Documentation - [ ] :robot: Supportability/Tests - [ ] :computer: CI/Deployment - [ ] :broom: Tech Debt/Cleanup #### Issue(s) * https://github.com/alcionai/corso/issues/3990 #### Test Plan - [ ] :muscle: Manual - [x] :zap: Unit test - [x] :green_heart: E2E --- src/internal/m365/backup.go | 21 +- src/internal/m365/backup_test.go | 82 ++++++++ .../m365/collection/drive/group_handler.go | 197 ++++++++++++++++++ src/internal/m365/collection/site/backup.go | 8 +- src/internal/m365/controller.go | 2 + src/internal/m365/resource/resource.go | 1 + src/internal/m365/service/groups/backup.go | 36 +++- .../m365/service/sharepoint/backup.go | 4 +- src/internal/tester/tconfig/config.go | 1 + src/pkg/path/builder.go | 4 + src/pkg/path/category_type.go | 2 + src/pkg/path/elements.go | 2 + src/pkg/path/resource_path_test.go | 19 +- src/pkg/selectors/groups.go | 79 ++++++- src/pkg/selectors/selectors.go | 1 + src/pkg/services/m365/api/groups.go | 38 +++- src/pkg/services/m365/api/groups_test.go | 21 +- 17 files changed, 478 insertions(+), 40 deletions(-) create mode 100644 src/internal/m365/collection/drive/group_handler.go diff --git a/src/internal/m365/backup.go b/src/internal/m365/backup.go index 9e7194511..805dcebd1 100644 --- a/src/internal/m365/backup.go +++ b/src/internal/m365/backup.go @@ -10,6 +10,7 @@ import ( "github.com/alcionai/corso/src/internal/diagnostics" "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/groups" "github.com/alcionai/corso/src/internal/m365/service/onedrive" "github.com/alcionai/corso/src/internal/m365/service/sharepoint" "github.com/alcionai/corso/src/internal/operations/inject" @@ -116,6 +117,18 @@ func (ctrl *Controller) ProduceBackupCollections( 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: 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. return nil + case selectors.ServiceGroups: + // TODO(meain): check for group existence. + return nil + case selectors.ServiceSharePoint: ids = siteIDs } @@ -197,8 +214,8 @@ func checkServiceEnabled( service path.ServiceType, resource string, ) (bool, bool, error) { - if service == path.SharePointService { - // No "enabled" check required for sharepoint + if service == path.SharePointService || service == path.GroupsService { + // No "enabled" check required for sharepoint or groups. return true, true, nil } diff --git a/src/internal/m365/backup_test.go b/src/internal/m365/backup_test.go index 5c19a182c..c2938a36b 100644 --- a/src/internal/m365/backup_test.go +++ b/src/internal/m365/backup_test.go @@ -465,3 +465,85 @@ 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()) +} diff --git a/src/internal/m365/collection/drive/group_handler.go b/src/internal/m365/collection/drive/group_handler.go new file mode 100644 index 000000000..81bbf36af --- /dev/null +++ b/src/internal/m365/collection/drive/group_handler.go @@ -0,0 +1,197 @@ +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): path fixes: sharepoint site ids should be in the path + return folders.ToDataLayerPath( + tenantID, + h.groupID, + path.GroupsService, + 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 +} diff --git a/src/internal/m365/collection/site/backup.go b/src/internal/m365/collection/site/backup.go index 8357d9512..f574ee4b5 100644 --- a/src/internal/m365/collection/site/backup.go +++ b/src/internal/m365/collection/site/backup.go @@ -25,10 +25,9 @@ import ( func CollectLibraries( ctx context.Context, bpc inject.BackupProducerConfig, - ad api.Drives, + bh drive.BackupHandler, tenantID string, ssmb *prefixmatcher.StringSetMatchBuilder, - scope selectors.SharePointScope, su support.StatusUpdater, errs *fault.Bus, ) ([]data.BackupCollection, bool, error) { @@ -37,13 +36,16 @@ func CollectLibraries( var ( collections = []data.BackupCollection{} colls = drive.NewCollections( - drive.NewLibraryBackupHandler(ad, scope), + bh, tenantID, bpc.ProtectedResource.ID(), su, bpc.Options) ) + // TODO(meain): backup resource owner should be group id in case + // of group sharepoint site backup. As of now, we always use + // sharepoint site ids. odcs, canUsePreviousBackup, err := colls.Get(ctx, bpc.MetadataCollections, ssmb, errs) if err != nil { return nil, false, graph.Wrap(ctx, err, "getting library") diff --git a/src/internal/m365/controller.go b/src/internal/m365/controller.go index 174148a76..0b8854be2 100644 --- a/src/internal/m365/controller.go +++ b/src/internal/m365/controller.go @@ -170,6 +170,8 @@ func getResourceClient(rc resource.Category, ac api.Client) (*resourceClient, er return &resourceClient{enum: rc, getter: ac.Users()}, nil case resource.Sites: return &resourceClient{enum: rc, getter: ac.Sites()}, nil + case resource.Groups: + return &resourceClient{enum: rc, getter: ac.Groups()}, nil default: return nil, clues.New("unrecognized owner resource enum").With("resource_enum", rc) } diff --git a/src/internal/m365/resource/resource.go b/src/internal/m365/resource/resource.go index f91a853a6..6aca21924 100644 --- a/src/internal/m365/resource/resource.go +++ b/src/internal/m365/resource/resource.go @@ -6,4 +6,5 @@ const ( UnknownResource Category = "" Users Category = "users" Sites Category = "sites" + Groups Category = "groups" ) diff --git a/src/internal/m365/service/groups/backup.go b/src/internal/m365/service/groups/backup.go index 3bb779507..b74b5fde0 100644 --- a/src/internal/m365/service/groups/backup.go +++ b/src/internal/m365/service/groups/backup.go @@ -5,8 +5,12 @@ import ( "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/ptr" "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/support" "github.com/alcionai/corso/src/internal/observe" @@ -56,7 +60,35 @@ func ProduceBackupCollections( var dbcs []data.BackupCollection 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.Groups().GetRootSite(ctx, bpc.ProtectedResource.ID()) + if err != nil { + return nil, nil, false, err + } + + 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...) @@ -70,7 +102,7 @@ func ProduceBackupCollections( collections, creds.AzureTenantID, bpc.ProtectedResource.ID(), - path.UnknownService, // path.GroupsService + path.GroupsService, categories, su, errs) diff --git a/src/internal/m365/service/sharepoint/backup.go b/src/internal/m365/service/sharepoint/backup.go index c4604e609..ce7789b64 100644 --- a/src/internal/m365/service/sharepoint/backup.go +++ b/src/internal/m365/service/sharepoint/backup.go @@ -7,6 +7,7 @@ import ( "github.com/alcionai/corso/src/internal/common/prefixmatcher" "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/support" @@ -79,10 +80,9 @@ func ProduceBackupCollections( spcs, canUsePreviousBackup, err = site.CollectLibraries( ctx, bpc, - ac.Drives(), + drive.NewLibraryBackupHandler(ac.Drives(), scope), creds.AzureTenantID, ssmb, - scope, su, errs) if err != nil { diff --git a/src/internal/tester/tconfig/config.go b/src/internal/tester/tconfig/config.go index a900f26f2..d92918dbb 100644 --- a/src/internal/tester/tconfig/config.go +++ b/src/internal/tester/tconfig/config.go @@ -30,6 +30,7 @@ const ( TestCfgGroupID = "m365groupid" TestCfgUserID = "m365userid" TestCfgSecondaryUserID = "secondarym365userid" + TestCfgSecondaryGroupID = "secondarym365groupid" TestCfgTertiaryUserID = "tertiarym365userid" TestCfgLoadTestUserID = "loadtestm365userid" TestCfgLoadTestOrgUsers = "loadtestm365orgusers" diff --git a/src/pkg/path/builder.go b/src/pkg/path/builder.go index 1cf502079..ec1f71ee3 100644 --- a/src/pkg/path/builder.go +++ b/src/pkg/path/builder.go @@ -241,6 +241,8 @@ func (pb Builder) ToStreamStorePath( metadataService = OneDriveMetadataService case SharePointService: metadataService = SharePointMetadataService + case GroupsService: + metadataService = GroupsMetadataService } return &dataLayerResourcePath{ @@ -282,6 +284,8 @@ func (pb Builder) ToServiceCategoryMetadataPath( metadataService = OneDriveMetadataService case SharePointService: metadataService = SharePointMetadataService + case GroupsService: + metadataService = GroupsMetadataService } return &dataLayerResourcePath{ diff --git a/src/pkg/path/category_type.go b/src/pkg/path/category_type.go index 40f511692..918435b70 100644 --- a/src/pkg/path/category_type.go +++ b/src/pkg/path/category_type.go @@ -78,9 +78,11 @@ var serviceCategories = map[ServiceType]map[CategoryType]struct{}{ }, GroupsService: { ChannelMessagesCategory: {}, + LibrariesCategory: {}, }, TeamsService: { ChannelMessagesCategory: {}, + LibrariesCategory: {}, }, } diff --git a/src/pkg/path/elements.go b/src/pkg/path/elements.go index 838cea114..e2f3f493e 100644 --- a/src/pkg/path/elements.go +++ b/src/pkg/path/elements.go @@ -13,10 +13,12 @@ var piiSafePathElems = pii.MapWithPlurals( UnknownService.String(), ExchangeService.String(), OneDriveService.String(), + GroupsService.String(), SharePointService.String(), ExchangeMetadataService.String(), OneDriveMetadataService.String(), SharePointMetadataService.String(), + GroupsMetadataService.String(), // categories UnknownCategory.String(), diff --git a/src/pkg/path/resource_path_test.go b/src/pkg/path/resource_path_test.go index e49f797e2..492dcb970 100644 --- a/src/pkg/path/resource_path_test.go +++ b/src/pkg/path/resource_path_test.go @@ -287,47 +287,54 @@ func (suite *DataLayerResourcePath) TestToServiceCategoryMetadataPath() { check: assert.Error, }, { - name: "Passes", + name: "Exchange Contacts", service: path.ExchangeService, category: path.ContactsCategory, expectedService: path.ExchangeMetadataService, check: assert.NoError, }, { - name: "Passes", + name: "Exchange Events", service: path.ExchangeService, category: path.EventsCategory, expectedService: path.ExchangeMetadataService, check: assert.NoError, }, { - name: "Passes", + name: "OneDrive Files", service: path.OneDriveService, category: path.FilesCategory, expectedService: path.OneDriveMetadataService, check: assert.NoError, }, { - name: "Passes", + name: "SharePoint Libraries", service: path.SharePointService, category: path.LibrariesCategory, expectedService: path.SharePointMetadataService, check: assert.NoError, }, { - name: "Passes", + name: "SharePoint Lists", service: path.SharePointService, category: path.ListsCategory, expectedService: path.SharePointMetadataService, check: assert.NoError, }, { - name: "Passes", + name: "SharePoint Pages", service: path.SharePointService, category: path.PagesCategory, expectedService: path.SharePointMetadataService, check: assert.NoError, }, + { + name: "Groups Libraries", + service: path.GroupsService, + category: path.LibrariesCategory, + expectedService: path.GroupsMetadataService, + check: assert.NoError, + }, } for _, test := range table { diff --git a/src/pkg/selectors/groups.go b/src/pkg/selectors/groups.go index 30d93698c..50aa3db74 100644 --- a/src/pkg/selectors/groups.go +++ b/src/pkg/selectors/groups.go @@ -205,8 +205,8 @@ func (s *groups) Scopes() []GroupsScope { // ------------------- // Scope Factories -// Produces one or more Groups site scopes. -// One scope is created per site entry. +// Produces one or more Groups scopes. +// 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.None, that slice is reduced to [selectors.None] // If any slice is empty, it defaults to [selectors.None] @@ -215,6 +215,7 @@ func (s *groups) AllData() []GroupsScope { scopes = append( scopes, + makeScope[GroupsScope](GroupsLibraryFolder, Any()), makeScope[GroupsScope](GroupsChannel, Any())) return scopes @@ -255,6 +256,56 @@ func (s *sharePoint) ChannelMessages(channels, messages []string, opts ...option return 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.None, that slice is reduced to [selectors.None] +// If any slice is empty, it defaults to [selectors.None] +func (s *groups) Library(library string) []GroupsScope { + return []GroupsScope{ + makeInfoScope[GroupsScope]( + GroupsLibraryItem, + GroupsInfoSiteLibraryDrive, + []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 ( + scopes = []GroupsScope{} + os = append([]option{pathComparator()}, opts...) + ) + + scopes = append( + scopes, + makeScope[GroupsScope](GroupsLibraryFolder, libraryFolders, os...)) + + return 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.None, that slice is reduced to [selectors.None] +// If any slice is empty, it defaults to [selectors.None] +// options are only applied to the library scopes. +func (s *groups) LibraryItems(libraries, items []string, opts ...option) []GroupsScope { + scopes := []GroupsScope{} + + scopes = append( + scopes, + makeScope[GroupsScope](GroupsLibraryItem, items, defaultItemOptions(s.Cfg)...). + set(GroupsLibraryFolder, libraries, opts...)) + + return scopes +} + // ------------------- // ItemInfo Factories @@ -278,6 +329,8 @@ const ( GroupsGroup groupsCategory = "GroupsGroup" GroupsChannel groupsCategory = "GroupsChannel" GroupsChannelMessage groupsCategory = "GroupsChannelMessage" + GroupsLibraryFolder groupsCategory = "GroupsLibraryFolder" + GroupsLibraryItem groupsCategory = "GroupsLibraryItem" // details.itemInfo comparables @@ -292,6 +345,10 @@ var groupsLeafProperties = map[categorizer]leafProperty{ pathKeys: []categorizer{GroupsChannel, GroupsChannelMessage}, pathType: path.ChannelMessagesCategory, }, + GroupsLibraryItem: { + pathKeys: []categorizer{GroupsLibraryFolder, GroupsLibraryItem}, + pathType: path.LibrariesCategory, + }, GroupsGroup: { // the root category must be represented, even though it isn't a leaf pathKeys: []categorizer{GroupsGroup}, pathType: path.UnknownCategory, @@ -311,8 +368,10 @@ func (c groupsCategory) leafCat() categorizer { switch c { // TODO: if channels ever contain more than one type of item, // we'll need to fix this up. - case GroupsChannel, GroupsChannelMessage, GroupsInfoSiteLibraryDrive: + case GroupsChannel, GroupsChannelMessage: return GroupsChannelMessage + case GroupsLibraryFolder, GroupsLibraryItem, GroupsInfoSiteLibraryDrive: + return GroupsLibraryItem } return c @@ -342,7 +401,7 @@ func (c groupsCategory) isLeaf() bool { // pathValues transforms the two paths to maps of identified properties. // // Example: -// [tenantID, service, siteID, category, folder, itemID] +// [tenantID, service, groupID, site, siteID, category, folder, itemID] // => {spFolder: folder, spItemID: itemID} func (c groupsCategory) pathValues( repo path.Path, @@ -357,11 +416,14 @@ func (c groupsCategory) pathValues( switch c { case GroupsChannel, GroupsChannelMessage: + folderCat, itemCat = GroupsChannel, GroupsChannelMessage + rFld = ent.Groups.ParentPath + case GroupsLibraryFolder, GroupsLibraryItem: if ent.Groups == nil { return nil, clues.New("no Groups ItemInfo in details") } - folderCat, itemCat = GroupsChannel, GroupsChannelMessage + folderCat, itemCat = GroupsLibraryFolder, GroupsLibraryItem rFld = ent.Groups.ParentPath default: @@ -459,7 +521,7 @@ func (s GroupsScope) set(cat groupsCategory, v []string, opts ...option) GroupsS os := []option{} switch cat { - case GroupsChannel: + case GroupsChannel, GroupsLibraryFolder: os = append(os, pathComparator()) } @@ -472,8 +534,12 @@ func (s GroupsScope) setDefaults() { case GroupsGroup: s[GroupsChannel.String()] = passAny s[GroupsChannelMessage.String()] = passAny + s[GroupsLibraryFolder.String()] = passAny + s[GroupsLibraryItem.String()] = passAny case GroupsChannel: s[GroupsChannelMessage.String()] = passAny + case GroupsLibraryFolder: + s[GroupsLibraryItem.String()] = passAny } } @@ -494,6 +560,7 @@ func (s groups) Reduce( s.Selector, map[path.CategoryType]groupsCategory{ path.ChannelMessagesCategory: GroupsChannelMessage, + path.LibrariesCategory: GroupsLibraryItem, }, errs) } diff --git a/src/pkg/selectors/selectors.go b/src/pkg/selectors/selectors.go index 3a18c2bd0..860fa5572 100644 --- a/src/pkg/selectors/selectors.go +++ b/src/pkg/selectors/selectors.go @@ -32,6 +32,7 @@ var serviceToPathType = map[service]path.ServiceType{ ServiceExchange: path.ExchangeService, ServiceOneDrive: path.OneDriveService, ServiceSharePoint: path.SharePointService, + ServiceGroups: path.GroupsService, } var ( diff --git a/src/pkg/services/m365/api/groups.go b/src/pkg/services/m365/api/groups.go index 3d036e610..7a3a134f7 100644 --- a/src/pkg/services/m365/api/groups.go +++ b/src/pkg/services/m365/api/groups.go @@ -7,6 +7,7 @@ import ( msgraphgocore "github.com/microsoftgraph/msgraph-sdk-go-core" "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/tform" "github.com/alcionai/corso/src/internal/m365/graph" @@ -27,7 +28,7 @@ func (c Client) Groups() Groups { 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, // drive and mail messages are owned by that group. @@ -115,6 +116,30 @@ func (c Groups) GetByID( return resp, graph.Stack(ctx, err).OrNil() } +// GetRootSite retrieves the root site for the group. +func (c Groups) GetRootSite( + ctx context.Context, + identifier string, +) (models.Siteable, error) { + service, err := c.Service() + if err != nil { + return nil, err + } + + resp, err := service. + Client(). + Groups(). + ByGroupId(identifier). + Sites(). + BySiteId("root"). + Get(ctx, nil) + if err != nil { + return nil, clues.Wrap(err, "getting root site for group") + } + + return resp, graph.Stack(ctx, err).OrNil() +} + // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- @@ -167,3 +192,14 @@ func IsTeam(ctx context.Context, mg models.Groupable) bool { 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 +} diff --git a/src/pkg/services/m365/api/groups_test.go b/src/pkg/services/m365/api/groups_test.go index ae435168a..6a0434196 100644 --- a/src/pkg/services/m365/api/groups_test.go +++ b/src/pkg/services/m365/api/groups_test.go @@ -107,7 +107,7 @@ func (suite *GroupsIntgSuite) TestGetAll() { Groups(). GetAll(ctx, fault.New(true)) 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() { @@ -122,34 +122,19 @@ func (suite *GroupsIntgSuite) TestGroups_GetByID() { expectErr func(*testing.T, error) }{ { - name: "3 part id", + name: "valid id", id: groupID, expectErr: func(t *testing.T, err error) { assert.NoError(t, err, clues.ToCore(err)) }, }, { - name: "malformed id", + name: "invalid id", id: uuid.NewString(), expectErr: func(t *testing.T, err error) { 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 { suite.Run(test.name, func() {