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.

<!-- PR description-->

---

#### Does this PR need a docs update or release note?

- [ ]  Yes, it's included
- [x] 🕐 Yes, but in a later PR
- [ ]  No

#### Type of change

<!--- Please check the type of change your PR introduces: --->
- [x] 🌻 Feature
- [ ] 🐛 Bugfix
- [ ] 🗺️ Documentation
- [ ] 🤖 Supportability/Tests
- [ ] 💻 CI/Deployment
- [ ] 🧹 Tech Debt/Cleanup

#### Issue(s)

<!-- Can reference multiple issues. Use one of the following "magic words" - "closes, fixes" to auto-close the Github issue. -->
* https://github.com/alcionai/corso/issues/3990

#### Test Plan

<!-- How will this be tested prior to merging.-->
- [ ] 💪 Manual
- [x]  Unit test
- [x] 💚 E2E
This commit is contained in:
Abin Simon 2023-08-22 13:28:03 +05:30 committed by GitHub
parent f45aecd5db
commit 0e6ef90e41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 478 additions and 40 deletions

View File

@ -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): check for group existence.
return nil
case selectors.ServiceSharePoint: case selectors.ServiceSharePoint:
ids = siteIDs ids = siteIDs
} }
@ -197,8 +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 {
// No "enabled" check required for sharepoint // No "enabled" check required for sharepoint or groups.
return true, true, nil return true, true, nil
} }

View File

@ -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())
}

View File

@ -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
}

View File

@ -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,13 +36,16 @@ 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,
bpc.Options) 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) odcs, canUsePreviousBackup, err := colls.Get(ctx, bpc.MetadataCollections, ssmb, errs)
if err != nil { if err != nil {
return nil, false, graph.Wrap(ctx, err, "getting library") return nil, false, graph.Wrap(ctx, err, "getting library")

View File

@ -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)
} }

View File

@ -6,4 +6,5 @@ const (
UnknownResource Category = "" UnknownResource Category = ""
Users Category = "users" Users Category = "users"
Sites Category = "sites" Sites Category = "sites"
Groups Category = "groups"
) )

View File

@ -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,35 @@ 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.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...) collections = append(collections, dbcs...)
@ -70,7 +102,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)

View File

@ -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 {

View File

@ -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"

View File

@ -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{

View File

@ -78,9 +78,11 @@ var serviceCategories = map[ServiceType]map[CategoryType]struct{}{
}, },
GroupsService: { GroupsService: {
ChannelMessagesCategory: {}, ChannelMessagesCategory: {},
LibrariesCategory: {},
}, },
TeamsService: { TeamsService: {
ChannelMessagesCategory: {}, ChannelMessagesCategory: {},
LibrariesCategory: {},
}, },
} }

View File

@ -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(),

View File

@ -287,47 +287,54 @@ func (suite *DataLayerResourcePath) TestToServiceCategoryMetadataPath() {
check: assert.Error, check: assert.Error,
}, },
{ {
name: "Passes", name: "Exchange Contacts",
service: path.ExchangeService, service: path.ExchangeService,
category: path.ContactsCategory, category: path.ContactsCategory,
expectedService: path.ExchangeMetadataService, expectedService: path.ExchangeMetadataService,
check: assert.NoError, check: assert.NoError,
}, },
{ {
name: "Passes", name: "Exchange Events",
service: path.ExchangeService, service: path.ExchangeService,
category: path.EventsCategory, category: path.EventsCategory,
expectedService: path.ExchangeMetadataService, expectedService: path.ExchangeMetadataService,
check: assert.NoError, check: assert.NoError,
}, },
{ {
name: "Passes", name: "OneDrive Files",
service: path.OneDriveService, service: path.OneDriveService,
category: path.FilesCategory, category: path.FilesCategory,
expectedService: path.OneDriveMetadataService, expectedService: path.OneDriveMetadataService,
check: assert.NoError, check: assert.NoError,
}, },
{ {
name: "Passes", name: "SharePoint Libraries",
service: path.SharePointService, service: path.SharePointService,
category: path.LibrariesCategory, category: path.LibrariesCategory,
expectedService: path.SharePointMetadataService, expectedService: path.SharePointMetadataService,
check: assert.NoError, check: assert.NoError,
}, },
{ {
name: "Passes", name: "SharePoint Lists",
service: path.SharePointService, service: path.SharePointService,
category: path.ListsCategory, category: path.ListsCategory,
expectedService: path.SharePointMetadataService, expectedService: path.SharePointMetadataService,
check: assert.NoError, check: assert.NoError,
}, },
{ {
name: "Passes", name: "SharePoint Pages",
service: path.SharePointService, service: path.SharePointService,
category: path.PagesCategory, category: path.PagesCategory,
expectedService: path.SharePointMetadataService, expectedService: path.SharePointMetadataService,
check: assert.NoError, check: assert.NoError,
}, },
{
name: "Groups Libraries",
service: path.GroupsService,
category: path.LibrariesCategory,
expectedService: path.GroupsMetadataService,
check: assert.NoError,
},
} }
for _, test := range table { for _, test := range table {

View File

@ -205,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]
@ -215,6 +215,7 @@ func (s *groups) AllData() []GroupsScope {
scopes = append( scopes = append(
scopes, scopes,
makeScope[GroupsScope](GroupsLibraryFolder, Any()),
makeScope[GroupsScope](GroupsChannel, Any())) makeScope[GroupsScope](GroupsChannel, Any()))
return scopes return scopes
@ -255,6 +256,56 @@ func (s *sharePoint) ChannelMessages(channels, messages []string, opts ...option
return scopes 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 // ItemInfo Factories
@ -278,6 +329,8 @@ const (
GroupsGroup groupsCategory = "GroupsGroup" GroupsGroup groupsCategory = "GroupsGroup"
GroupsChannel groupsCategory = "GroupsChannel" GroupsChannel groupsCategory = "GroupsChannel"
GroupsChannelMessage groupsCategory = "GroupsChannelMessage" GroupsChannelMessage groupsCategory = "GroupsChannelMessage"
GroupsLibraryFolder groupsCategory = "GroupsLibraryFolder"
GroupsLibraryItem groupsCategory = "GroupsLibraryItem"
// details.itemInfo comparables // details.itemInfo comparables
@ -292,6 +345,10 @@ var groupsLeafProperties = map[categorizer]leafProperty{
pathKeys: []categorizer{GroupsChannel, GroupsChannelMessage}, pathKeys: []categorizer{GroupsChannel, GroupsChannelMessage},
pathType: path.ChannelMessagesCategory, 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 GroupsGroup: { // the root category must be represented, even though it isn't a leaf
pathKeys: []categorizer{GroupsGroup}, pathKeys: []categorizer{GroupsGroup},
pathType: path.UnknownCategory, pathType: path.UnknownCategory,
@ -311,8 +368,10 @@ func (c groupsCategory) leafCat() categorizer {
switch c { switch c {
// TODO: if channels ever contain more than one type of item, // TODO: if channels ever contain more than one type of item,
// we'll need to fix this up. // we'll need to fix this up.
case GroupsChannel, GroupsChannelMessage, GroupsInfoSiteLibraryDrive: case GroupsChannel, GroupsChannelMessage:
return GroupsChannelMessage return GroupsChannelMessage
case GroupsLibraryFolder, GroupsLibraryItem, GroupsInfoSiteLibraryDrive:
return GroupsLibraryItem
} }
return c return c
@ -342,7 +401,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,
@ -357,11 +416,14 @@ func (c groupsCategory) pathValues(
switch c { switch c {
case GroupsChannel, GroupsChannelMessage: case GroupsChannel, GroupsChannelMessage:
folderCat, itemCat = GroupsChannel, GroupsChannelMessage
rFld = ent.Groups.ParentPath
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 = GroupsChannel, GroupsChannelMessage folderCat, itemCat = GroupsLibraryFolder, GroupsLibraryItem
rFld = ent.Groups.ParentPath rFld = ent.Groups.ParentPath
default: default:
@ -459,7 +521,7 @@ func (s GroupsScope) set(cat groupsCategory, v []string, opts ...option) GroupsS
os := []option{} os := []option{}
switch cat { switch cat {
case GroupsChannel: case GroupsChannel, GroupsLibraryFolder:
os = append(os, pathComparator()) os = append(os, pathComparator())
} }
@ -472,8 +534,12 @@ func (s GroupsScope) setDefaults() {
case GroupsGroup: case GroupsGroup:
s[GroupsChannel.String()] = passAny s[GroupsChannel.String()] = passAny
s[GroupsChannelMessage.String()] = passAny s[GroupsChannelMessage.String()] = passAny
s[GroupsLibraryFolder.String()] = passAny
s[GroupsLibraryItem.String()] = passAny
case GroupsChannel: case GroupsChannel:
s[GroupsChannelMessage.String()] = passAny s[GroupsChannelMessage.String()] = passAny
case GroupsLibraryFolder:
s[GroupsLibraryItem.String()] = passAny
} }
} }
@ -494,6 +560,7 @@ func (s groups) Reduce(
s.Selector, s.Selector,
map[path.CategoryType]groupsCategory{ map[path.CategoryType]groupsCategory{
path.ChannelMessagesCategory: GroupsChannelMessage, path.ChannelMessagesCategory: GroupsChannelMessage,
path.LibrariesCategory: GroupsLibraryItem,
}, },
errs) errs)
} }

View File

@ -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 (

View File

@ -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.
@ -115,6 +116,30 @@ func (c Groups) GetByID(
return resp, graph.Stack(ctx, err).OrNil() 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 // helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -167,3 +192,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
}

View File

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