enables sharepoint to use lists restore handler for lists ops (#4923)

enables sharepoint to use lists restore handler for lists ops
Changes previously approved in: 
- https://github.com/alcionai/corso/pull/4854
- https://github.com/alcionai/corso/pull/4910

#### Does this PR need a docs update or release note?
- [x]  No

#### Type of change

<!--- Please check the type of change your PR introduces: --->
- [x] 🌻 Feature

#### Issue(s)
#4754 

#### Test Plan

<!-- How will this be tested prior to merging.-->
- [x] 💪 Manual
- [x]  Unit test
- [x] 💚 E2E
This commit is contained in:
Hitesh Pattanayak 2023-12-22 22:48:24 +05:30 committed by GitHub
parent 98e8cac374
commit a1590e0d2f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 311 additions and 164 deletions

View File

@ -17,7 +17,6 @@ import (
"github.com/alcionai/corso/src/internal/m365/collection/site/mock" "github.com/alcionai/corso/src/internal/m365/collection/site/mock"
betaAPI "github.com/alcionai/corso/src/internal/m365/service/sharepoint/api" betaAPI "github.com/alcionai/corso/src/internal/m365/service/sharepoint/api"
spMock "github.com/alcionai/corso/src/internal/m365/service/sharepoint/mock" spMock "github.com/alcionai/corso/src/internal/m365/service/sharepoint/mock"
"github.com/alcionai/corso/src/internal/m365/support"
"github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/internal/tester/tconfig" "github.com/alcionai/corso/src/internal/tester/tconfig"
"github.com/alcionai/corso/src/pkg/account" "github.com/alcionai/corso/src/pkg/account"
@ -241,96 +240,3 @@ func (suite *SharePointCollectionSuite) TestCollection_Items() {
}) })
} }
} }
func (suite *SharePointCollectionSuite) TestCollection_streamItems() {
var (
t = suite.T()
statusUpdater = func(*support.ControllerOperationStatus) {}
tenant = "some"
resource = "siteid"
list = "list"
)
table := []struct {
name string
category path.CategoryType
items []string
getDir func(t *testing.T) path.Path
}{
{
name: "no items",
items: []string{},
category: path.ListsCategory,
getDir: func(t *testing.T) path.Path {
dir, err := path.Build(
tenant,
resource,
path.SharePointService,
path.ListsCategory,
false,
list)
require.NoError(t, err, clues.ToCore(err))
return dir
},
},
{
name: "with items",
items: []string{"list1", "list2", "list3"},
category: path.ListsCategory,
getDir: func(t *testing.T) path.Path {
dir, err := path.Build(
tenant,
resource,
path.SharePointService,
path.ListsCategory,
false,
list)
require.NoError(t, err, clues.ToCore(err))
return dir
},
},
}
for _, test := range table {
suite.Run(test.name, func() {
t.Log("running test", test)
var (
errs = fault.New(true)
itemCount int
)
ctx, flush := tester.NewContext(t)
defer flush()
col := &Collection{
fullPath: test.getDir(t),
category: test.category,
items: test.items,
getter: &mock.ListHandler{},
stream: make(chan data.Item),
statusUpdater: statusUpdater,
}
itemMap := func(js []string) map[string]struct{} {
m := make(map[string]struct{})
for _, j := range js {
m[j] = struct{}{}
}
return m
}(test.items)
go col.streamItems(ctx, errs)
for item := range col.stream {
itemCount++
_, ok := itemMap[item.ID()]
assert.True(t, ok, "should fetch item")
}
assert.NoError(t, errs.Failure())
assert.Equal(t, len(test.items), itemCount, "should see all expected items")
})
}
}

View File

@ -1,9 +1,16 @@
package site package site
import ( import (
"testing"
"github.com/alcionai/clues"
msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go" msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go"
"github.com/stretchr/testify/require"
"github.com/alcionai/corso/src/internal/m365/support" "github.com/alcionai/corso/src/internal/m365/support"
"github.com/alcionai/corso/src/pkg/account"
"github.com/alcionai/corso/src/pkg/count"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
) )
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -35,3 +42,18 @@ func (ms *MockGraphService) Adapter() *msgraphsdk.GraphRequestAdapter {
func (ms *MockGraphService) UpdateStatus(*support.ControllerOperationStatus) { func (ms *MockGraphService) UpdateStatus(*support.ControllerOperationStatus) {
} }
// ---------------------------------------------------------------------------
// Helper functions
// ---------------------------------------------------------------------------
func createTestService(t *testing.T, credentials account.M365Config) *graph.Service {
adapter, err := graph.CreateAdapter(
credentials.AzureTenantID,
credentials.AzureClientID,
credentials.AzureClientSecret,
count.New())
require.NoError(t, err, "creating microsoft graph service for exchange", clues.ToCore(err))
return graph.NewService(adapter)
}

View File

@ -8,10 +8,8 @@ import (
"runtime/trace" "runtime/trace"
"github.com/alcionai/clues" "github.com/alcionai/clues"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/alcionai/corso/src/internal/common/idname" "github.com/alcionai/corso/src/internal/common/idname"
"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/diagnostics" "github.com/alcionai/corso/src/internal/diagnostics"
"github.com/alcionai/corso/src/internal/m365/collection/drive" "github.com/alcionai/corso/src/internal/m365/collection/drive"
@ -42,6 +40,7 @@ func ConsumeRestoreCollections(
) (*support.ControllerOperationStatus, error) { ) (*support.ControllerOperationStatus, error) {
var ( var (
lrh = drive.NewSiteRestoreHandler(ac, rcc.Selector.PathService()) lrh = drive.NewSiteRestoreHandler(ac, rcc.Selector.PathService())
listsRh = NewListsRestoreHandler(rcc.ProtectedResource.ID(), ac.Lists())
restoreMetrics support.CollectionMetrics restoreMetrics support.CollectionMetrics
caches = drive.NewRestoreCaches(backupDriveIDNames) caches = drive.NewRestoreCaches(backupDriveIDNames)
el = errs.Local() el = errs.Local()
@ -89,7 +88,7 @@ func ConsumeRestoreCollections(
case path.ListsCategory: case path.ListsCategory:
metrics, err = RestoreListCollection( metrics, err = RestoreListCollection(
ictx, ictx,
ac.Stable, listsRh,
dc, dc,
rcc.RestoreConfig.Location, rcc.RestoreConfig.Location,
deets, deets,
@ -135,7 +134,7 @@ func ConsumeRestoreCollections(
// Restored List can be verified within the Site contents. // Restored List can be verified within the Site contents.
func restoreListItem( func restoreListItem(
ctx context.Context, ctx context.Context,
service graph.Servicer, rh restoreHandler,
itemData data.Item, itemData data.Item,
siteID, destName string, siteID, destName string,
) (details.ItemInfo, error) { ) (details.ItemInfo, error) {
@ -149,57 +148,19 @@ func restoreListItem(
listName = itemData.ID() listName = itemData.ID()
) )
byteArray, err := io.ReadAll(itemData.ToReader()) bytes, err := io.ReadAll(itemData.ToReader())
if err != nil { if err != nil {
return dii, clues.WrapWC(ctx, err, "reading backup data") return dii, clues.WrapWC(ctx, err, "reading backup data")
} }
oldList, err := api.BytesToListable(byteArray) newName := fmt.Sprintf("%s_%s", destName, listName)
if err != nil {
return dii, clues.WrapWC(ctx, err, "creating item")
}
if name, ok := ptr.ValOK(oldList.GetDisplayName()); ok {
listName = name
}
var (
newName = fmt.Sprintf("%s_%s", destName, listName)
newList = api.ToListable(oldList, newName)
contents = make([]models.ListItemable, 0)
)
for _, itm := range oldList.GetItems() {
temp := api.CloneListItem(itm)
contents = append(contents, temp)
}
newList.SetItems(contents)
// Restore to List base to M365 back store // Restore to List base to M365 back store
restoredList, err := service.Client().Sites().BySiteId(siteID).Lists().Post(ctx, newList, nil) restoredList, err := rh.PostList(ctx, newName, bytes)
if err != nil { if err != nil {
return dii, graph.Wrap(ctx, err, "restoring list") return dii, graph.Wrap(ctx, err, "restoring list")
} }
// Uploading of ListItems is conducted after the List is restored
// Reference: https://learn.microsoft.com/en-us/graph/api/listitem-create?view=graph-rest-1.0&tabs=http
if len(contents) > 0 {
for _, lItem := range contents {
_, err := service.Client().
Sites().
BySiteId(siteID).
Lists().
ByListId(ptr.Val(restoredList.GetId())).
Items().
Post(ctx, lItem, nil)
if err != nil {
return dii, graph.Wrap(ctx, err, "restoring list items").
With("restored_list_id", ptr.Val(restoredList.GetId()))
}
}
}
dii.SharePoint = api.ListToSPInfo(restoredList) dii.SharePoint = api.ListToSPInfo(restoredList)
return dii, nil return dii, nil
@ -207,7 +168,7 @@ func restoreListItem(
func RestoreListCollection( func RestoreListCollection(
ctx context.Context, ctx context.Context,
service graph.Servicer, rh restoreHandler,
dc data.RestoreCollection, dc data.RestoreCollection,
restoreContainerName string, restoreContainerName string,
deets *details.Builder, deets *details.Builder,
@ -243,10 +204,15 @@ func RestoreListCollection(
itemInfo, err := restoreListItem( itemInfo, err := restoreListItem(
ctx, ctx,
service, rh,
itemData, itemData,
siteID, siteID,
restoreContainerName) restoreContainerName)
if err != nil &&
errors.Is(err, api.ErrCannotCreateWebTemplateExtension) {
continue
}
if err != nil { if err != nil {
el.AddRecoverable(ctx, err) el.AddRecoverable(ctx, err)
continue continue

View File

@ -0,0 +1,184 @@
package site
import (
"bytes"
"context"
"fmt"
"io"
"testing"
"github.com/alcionai/clues"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/common/readers"
"github.com/alcionai/corso/src/internal/data"
dataMock "github.com/alcionai/corso/src/internal/data/mock"
spMock "github.com/alcionai/corso/src/internal/m365/service/sharepoint/mock"
"github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/internal/tester/tconfig"
"github.com/alcionai/corso/src/pkg/account"
"github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/control/testdata"
"github.com/alcionai/corso/src/pkg/count"
"github.com/alcionai/corso/src/pkg/services/m365/api"
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
)
type SharePointRestoreSuite struct {
tester.Suite
siteID string
creds account.M365Config
ac api.Client
}
func (suite *SharePointRestoreSuite) SetupSuite() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
graph.InitializeConcurrencyLimiter(ctx, false, 4)
suite.siteID = tconfig.M365SiteID(t)
a := tconfig.NewM365Account(t)
m365, err := a.M365Config()
require.NoError(t, err, clues.ToCore(err))
suite.creds = m365
ac, err := api.NewClient(
m365,
control.DefaultOptions(),
count.New())
require.NoError(t, err, clues.ToCore(err))
suite.ac = ac
}
func TestSharePointRestoreSuite(t *testing.T) {
suite.Run(t, &SharePointRestoreSuite{
Suite: tester.NewIntegrationSuite(
t,
[][]string{tconfig.M365AcctCredEnvs}),
})
}
// TestRestoreListCollection verifies Graph Restore API for the List Collection
func (suite *SharePointRestoreSuite) TestListCollection_Restore() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
testName, lrh, destName, mockData := setupDependencies(
suite,
suite.ac,
suite.siteID,
suite.creds,
"genericList")
deets, err := restoreListItem(ctx, lrh, mockData, suite.siteID, destName)
require.NoError(t, err, clues.ToCore(err))
assert.Equal(t, fmt.Sprintf("%s_%s", destName, testName), deets.SharePoint.List.Name)
// Clean-Up
deleteList(ctx, t, suite.siteID, lrh, deets)
}
func (suite *SharePointRestoreSuite) TestListCollection_Restore_invalidListTemplate() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
_, lrh, destName, mockData := setupDependencies(
suite,
suite.ac,
suite.siteID,
suite.creds,
api.WebTemplateExtensionsListTemplateName)
_, err := restoreListItem(ctx, lrh, mockData, suite.siteID, destName)
require.Error(t, err)
assert.Contains(t, err.Error(), api.ErrCannotCreateWebTemplateExtension.Error())
}
func deleteList(
ctx context.Context,
t *testing.T,
siteID string,
lrh listsRestoreHandler,
deets details.ItemInfo,
) {
var (
isFound bool
deleteID string
)
lists, err := lrh.ac.Client.
Lists().
GetLists(ctx, siteID, api.CallConfig{})
assert.NoError(t, err, "getting site lists", clues.ToCore(err))
for _, l := range lists {
if ptr.Val(l.GetDisplayName()) == deets.SharePoint.ItemName {
isFound = true
deleteID = ptr.Val(l.GetId())
break
}
}
if isFound {
err := lrh.DeleteList(ctx, deleteID)
assert.NoError(t, err, clues.ToCore(err))
}
}
func setupDependencies(
suite tester.Suite,
ac api.Client,
siteID string,
creds account.M365Config,
listTemplate string) (
string, listsRestoreHandler, string, *dataMock.Item,
) {
t := suite.T()
testName := "MockListing"
lrh := NewListsRestoreHandler(siteID, ac.Lists())
service := createTestService(t, creds)
listInfo := models.NewListInfo()
listInfo.SetTemplate(ptr.To(listTemplate))
listing := spMock.ListDefault("Mock List")
listing.SetDisplayName(&testName)
listing.SetList(listInfo)
byteArray, err := service.Serialize(listing)
require.NoError(t, err, clues.ToCore(err))
destName := testdata.DefaultRestoreConfig("").Location
listData, err := data.NewPrefetchedItemWithInfo(
io.NopCloser(bytes.NewReader(byteArray)),
testName,
details.ItemInfo{SharePoint: api.ListToSPInfo(listing)})
require.NoError(t, err, clues.ToCore(err))
r, err := readers.NewVersionedRestoreReader(listData.ToReader())
require.NoError(t, err)
mockData := &dataMock.Item{
ItemID: testName,
Reader: r,
}
return testName, lrh, destName, mockData
}

View File

@ -48,15 +48,15 @@ func (h *sharepointHandler) ConsumeRestoreCollections(
lrh = drive.NewSiteRestoreHandler( lrh = drive.NewSiteRestoreHandler(
h.apiClient, h.apiClient,
rcc.Selector.PathService()) rcc.Selector.PathService())
listsRh = site.NewListsRestoreHandler(
rcc.ProtectedResource.ID(),
h.apiClient.Lists())
restoreMetrics support.CollectionMetrics restoreMetrics support.CollectionMetrics
caches = drive.NewRestoreCaches(h.backupDriveIDNames)
el = errs.Local()
)
err := caches.Populate(ctx, lrh, rcc.ProtectedResource.ID()) caches = drive.NewRestoreCaches(h.backupDriveIDNames)
if err != nil {
return nil, nil, clues.Wrap(err, "initializing restore caches") el = errs.Local()
} )
// Reorder collections so that the parents directories are created // Reorder collections so that the parents directories are created
// before the child directories; a requirement for permissions. // before the child directories; a requirement for permissions.
@ -81,6 +81,11 @@ func (h *sharepointHandler) ConsumeRestoreCollections(
switch dc.FullPath().Category() { switch dc.FullPath().Category() {
case path.LibrariesCategory: case path.LibrariesCategory:
err = caches.Populate(ctx, lrh, rcc.ProtectedResource.ID())
if err != nil {
return nil, nil, clues.Wrap(err, "initializing restore caches")
}
metrics, err = drive.RestoreCollection( metrics, err = drive.RestoreCollection(
ictx, ictx,
lrh, lrh,
@ -95,7 +100,7 @@ func (h *sharepointHandler) ConsumeRestoreCollections(
case path.ListsCategory: case path.ListsCategory:
metrics, err = site.RestoreListCollection( metrics, err = site.RestoreListCollection(
ictx, ictx,
h.apiClient.Stable, listsRh,
dc, dc,
rcc.RestoreConfig.Location, rcc.RestoreConfig.Location,
deets, deets,

View File

@ -0,0 +1,62 @@
package sharepoint
import (
"testing"
"github.com/alcionai/clues"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/common/idname"
"github.com/alcionai/corso/src/internal/data"
"github.com/alcionai/corso/src/internal/data/mock"
"github.com/alcionai/corso/src/internal/operations/inject"
"github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
type SharepointRestoreUnitSuite struct {
tester.Suite
}
func TestSharepointRestoreUnitSuite(t *testing.T) {
suite.Run(t, &SharepointRestoreUnitSuite{Suite: tester.NewUnitSuite(t)})
}
func (suite *SharepointRestoreUnitSuite) TestSharePointHandler_ConsumeRestoreCollections_noErrorOnLists() {
t := suite.T()
siteID := "site-id"
ctx, flush := tester.NewContext(t)
defer flush()
pr := idname.NewProvider(siteID, siteID)
rcc := inject.RestoreConsumerConfig{
ProtectedResource: pr,
}
pth, err := path.Builder{}.
Append("lists").
ToDataLayerPath(
"tenant",
siteID,
path.SharePointService,
path.ListsCategory,
false)
require.NoError(t, err, clues.ToCore(err))
dcs := []data.RestoreCollection{
mock.Collection{Path: pth},
}
sh := NewSharePointHandler(api.Client{}, nil)
_, _, err = sh.ConsumeRestoreCollections(
ctx,
rcc,
dcs,
fault.New(false),
nil)
require.NoError(t, err, "Sharepoint lists restore")
}

View File

@ -16,13 +16,14 @@ import (
var ErrCannotCreateWebTemplateExtension = clues.New("unable to create webTemplateExtension type lists") var ErrCannotCreateWebTemplateExtension = clues.New("unable to create webTemplateExtension type lists")
const ( const (
AttachmentsColumnName = "Attachments" AttachmentsColumnName = "Attachments"
EditColumnName = "Edit" EditColumnName = "Edit"
ContentTypeColumnName = "ContentType" ContentTypeColumnName = "ContentType"
CreatedColumnName = "Created" CreatedColumnName = "Created"
ModifiedColumnName = "Modified" ModifiedColumnName = "Modified"
AuthorLookupIDColumnName = "AuthorLookupId" AuthorLookupIDColumnName = "AuthorLookupId"
EditorLookupIDColumnName = "EditorLookupId" EditorLookupIDColumnName = "EditorLookupId"
AppAuthorLookupIDColumnName = "AppAuthorLookupId"
ContentTypeColumnDisplayName = "Content Type" ContentTypeColumnDisplayName = "Content Type"
@ -41,6 +42,7 @@ const (
DispNameFieldName = "DispName" DispNameFieldName = "DispName"
LinkTitleFieldNamePart = "LinkTitle" LinkTitleFieldNamePart = "LinkTitle"
ChildCountFieldNamePart = "ChildCount" ChildCountFieldNamePart = "ChildCount"
LookupIDFieldNamePart = "LookupId"
ReadOnlyOrHiddenFieldNamePrefix = "_" ReadOnlyOrHiddenFieldNamePrefix = "_"
DescoratorFieldNamePrefix = "@" DescoratorFieldNamePrefix = "@"
@ -73,13 +75,11 @@ var legacyColumns = keys.Set{
} }
var readOnlyFieldNames = keys.Set{ var readOnlyFieldNames = keys.Set{
AttachmentsColumnName: {}, AttachmentsColumnName: {},
EditColumnName: {}, EditColumnName: {},
ContentTypeColumnName: {}, ContentTypeColumnName: {},
CreatedColumnName: {}, CreatedColumnName: {},
ModifiedColumnName: {}, ModifiedColumnName: {},
AuthorLookupIDColumnName: {},
EditorLookupIDColumnName: {},
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -546,7 +546,8 @@ func shouldFilterField(key string, value any) bool {
strings.HasPrefix(key, ReadOnlyOrHiddenFieldNamePrefix) || strings.HasPrefix(key, ReadOnlyOrHiddenFieldNamePrefix) ||
strings.HasPrefix(key, DescoratorFieldNamePrefix) || strings.HasPrefix(key, DescoratorFieldNamePrefix) ||
strings.Contains(key, LinkTitleFieldNamePart) || strings.Contains(key, LinkTitleFieldNamePart) ||
strings.Contains(key, ChildCountFieldNamePart) strings.Contains(key, ChildCountFieldNamePart) ||
strings.Contains(key, LookupIDFieldNamePart)
} }
func retainPrimaryAddressField(additionalData map[string]any) { func retainPrimaryAddressField(additionalData map[string]any) {

View File

@ -409,6 +409,7 @@ func (suite *ListsUnitSuite) TestFieldValueSetable() {
ReadOnlyOrHiddenFieldNamePrefix + "UIVersionString": "1.0", ReadOnlyOrHiddenFieldNamePrefix + "UIVersionString": "1.0",
AuthorLookupIDColumnName: "6", AuthorLookupIDColumnName: "6",
EditorLookupIDColumnName: "6", EditorLookupIDColumnName: "6",
AppAuthorLookupIDColumnName: "6",
"Item" + ChildCountFieldNamePart: "0", "Item" + ChildCountFieldNamePart: "0",
"Folder" + ChildCountFieldNamePart: "0", "Folder" + ChildCountFieldNamePart: "0",
ModifiedColumnName: "2023-12-13T15:47:51Z", ModifiedColumnName: "2023-12-13T15:47:51Z",