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() {