From 8cc3069a6a2ef8b099dc2f9c8a0c6e7e81de33ed Mon Sep 17 00:00:00 2001 From: Keepers Date: Wed, 5 Jul 2023 15:44:55 -0600 Subject: [PATCH] ensure od and exchange can restore in-place (#3703) Adds changes (mostly to exchange) that allow in- place item restoration. Onedrive basically already supported in-place restores, however we weren't attempting them. Exchange needed full support of GetContainerByName across its categories. Next steps: add CLI integration to simplify manual testing. Then add automation testing to exercise all in-place restore conditions and configurations. --- #### Does this PR need a docs update or release note? - [x] :clock1: Yes, but in a later PR #### Type of change - [x] :sunflower: Feature #### Issue(s) * #3562 #### Test Plan - [x] :zap: Unit test - [x] :green_heart: E2E --- .../m365/exchange/contacts_restore.go | 11 +- src/internal/m365/exchange/events_restore.go | 11 +- src/internal/m365/exchange/handlers.go | 13 +- src/internal/m365/exchange/mail_restore.go | 11 +- src/internal/m365/exchange/restore.go | 12 +- src/internal/m365/exchange/restore_test.go | 34 ++-- src/pkg/services/m365/api/contacts.go | 54 ++++++- src/pkg/services/m365/api/contacts_test.go | 73 ++++++++- src/pkg/services/m365/api/events.go | 35 ++-- src/pkg/services/m365/api/events_test.go | 95 ++++++++++- src/pkg/services/m365/api/mail.go | 71 +++++++- src/pkg/services/m365/api/mail_test.go | 153 +++++++++++++++--- 12 files changed, 486 insertions(+), 87 deletions(-) diff --git a/src/internal/m365/exchange/contacts_restore.go b/src/internal/m365/exchange/contacts_restore.go index c4b2c7681..3fd2e7ffe 100644 --- a/src/internal/m365/exchange/contacts_restore.go +++ b/src/internal/m365/exchange/contacts_restore.go @@ -47,13 +47,16 @@ func (h contactRestoreHandler) formatRestoreDestination( func (h contactRestoreHandler) CreateContainer( ctx context.Context, - userID, containerName, _ string, // parent container not used + userID, _, containerName string, // parent container not used ) (graph.Container, error) { - return h.ac.CreateContainer(ctx, userID, containerName, "") + return h.ac.CreateContainer(ctx, userID, "", containerName) } -func (h contactRestoreHandler) containerSearcher() containerByNamer { - return nil +func (h contactRestoreHandler) GetContainerByName( + ctx context.Context, + userID, _, containerName string, // parent container not used +) (graph.Container, error) { + return h.ac.GetContainerByName(ctx, userID, "", containerName) } // always returns the provided value diff --git a/src/internal/m365/exchange/events_restore.go b/src/internal/m365/exchange/events_restore.go index c538eccb0..87dd8cfae 100644 --- a/src/internal/m365/exchange/events_restore.go +++ b/src/internal/m365/exchange/events_restore.go @@ -48,13 +48,16 @@ func (h eventRestoreHandler) formatRestoreDestination( func (h eventRestoreHandler) CreateContainer( ctx context.Context, - userID, containerName, _ string, // parent container not used + userID, _, containerName string, // parent container not used ) (graph.Container, error) { - return h.ac.CreateContainer(ctx, userID, containerName, "") + return h.ac.CreateContainer(ctx, userID, "", containerName) } -func (h eventRestoreHandler) containerSearcher() containerByNamer { - return h.ac +func (h eventRestoreHandler) GetContainerByName( + ctx context.Context, + userID, _, containerName string, // parent container not used +) (graph.Container, error) { + return h.ac.GetContainerByName(ctx, userID, "", containerName) } // always returns the provided value diff --git a/src/internal/m365/exchange/handlers.go b/src/internal/m365/exchange/handlers.go index ccc8b2c2e..aae57ee86 100644 --- a/src/internal/m365/exchange/handlers.go +++ b/src/internal/m365/exchange/handlers.go @@ -86,19 +86,14 @@ type itemRestorer interface { // produces structs that interface with the graph/cache_container // CachedContainer interface. type containerAPI interface { + containerByNamer + // POSTs the creation of a new container CreateContainer( ctx context.Context, - userID, containerName, parentContainerID string, + userID, parentContainerID, containerName string, ) (graph.Container, error) - // GETs a container by name. - // if containerByNamer is nil, this functionality is not supported - // and should be skipped by the caller. - // normally, we'd alias the func directly. The indirection here - // is because not all types comply with GetContainerByName. - containerSearcher() containerByNamer - // returns either the provided value (assumed to be the root // folder for that cache tree), or the default root container // (if the category uses a root folder that exists above the @@ -110,7 +105,7 @@ type containerByNamer interface { // searches for a container by name. GetContainerByName( ctx context.Context, - userID, containerName string, + userID, parentContainerID, containerName string, ) (graph.Container, error) } diff --git a/src/internal/m365/exchange/mail_restore.go b/src/internal/m365/exchange/mail_restore.go index d3a87d60e..80004a76e 100644 --- a/src/internal/m365/exchange/mail_restore.go +++ b/src/internal/m365/exchange/mail_restore.go @@ -48,17 +48,20 @@ func (h mailRestoreHandler) formatRestoreDestination( func (h mailRestoreHandler) CreateContainer( ctx context.Context, - userID, containerName, parentContainerID string, + userID, parentContainerID, containerName string, ) (graph.Container, error) { if len(parentContainerID) == 0 { parentContainerID = rootFolderAlias } - return h.ac.CreateContainer(ctx, userID, containerName, parentContainerID) + return h.ac.CreateContainer(ctx, userID, parentContainerID, containerName) } -func (h mailRestoreHandler) containerSearcher() containerByNamer { - return nil +func (h mailRestoreHandler) GetContainerByName( + ctx context.Context, + userID, parentContainerID, containerName string, +) (graph.Container, error) { + return h.ac.GetContainerByName(ctx, userID, parentContainerID, containerName) } // always returns rootFolderAlias diff --git a/src/internal/m365/exchange/restore.go b/src/internal/m365/exchange/restore.go index accc78d27..5385af176 100644 --- a/src/internal/m365/exchange/restore.go +++ b/src/internal/m365/exchange/restore.go @@ -44,6 +44,7 @@ func ConsumeRestoreCollections( el = errs.Local() ) + // FIXME: should be user name ctx = clues.Add(ctx, "resource_owner", clues.Hide(userID)) for _, dc := range dcs { @@ -289,7 +290,7 @@ func getOrPopulateContainer( return cached, nil } - c, err := ca.CreateContainer(ctx, userID, containerName, containerParentID) + c, err := ca.CreateContainer(ctx, userID, containerParentID, containerName) // 409 handling case: // attempt to fetch the container by name and add that result to the cache. @@ -297,11 +298,12 @@ func getOrPopulateContainer( // sometimes the backend will create the folder despite the 5xx response, // leaving our local containerResolver with inconsistent state. if graph.IsErrFolderExists(err) { - cs := ca.containerSearcher() - if cs != nil { - cc, e := cs.GetContainerByName(ctx, userID, containerName) - c = cc + cc, e := ca.GetContainerByName(ctx, userID, containerParentID, containerName) + if e != nil { err = clues.Stack(err, e) + } else { + c = cc + err = nil } } diff --git a/src/internal/m365/exchange/restore_test.go b/src/internal/m365/exchange/restore_test.go index bc483de7c..41e81d8e9 100644 --- a/src/internal/m365/exchange/restore_test.go +++ b/src/internal/m365/exchange/restore_test.go @@ -60,7 +60,7 @@ func (suite *RestoreIntgSuite) TestRestoreContact() { handler = newContactRestoreHandler(suite.ac) ) - aFolder, err := handler.ac.CreateContainer(ctx, userID, folderName, "") + aFolder, err := handler.ac.CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) folderID := ptr.Val(aFolder.GetId()) @@ -96,7 +96,7 @@ func (suite *RestoreIntgSuite) TestRestoreEvent() { handler = newEventRestoreHandler(suite.ac) ) - calendar, err := handler.ac.CreateContainer(ctx, userID, subject, "") + calendar, err := handler.ac.CreateContainer(ctx, userID, "", subject) require.NoError(t, err, clues.ToCore(err)) calendarID := ptr.Val(calendar.GetId()) @@ -179,7 +179,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("mailobj").Location folder, err := handlers[path.EmailCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(folder.GetId()) @@ -192,7 +192,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("mailwattch").Location folder, err := handlers[path.EmailCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(folder.GetId()) @@ -205,7 +205,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("eventwattch").Location folder, err := handlers[path.EmailCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(folder.GetId()) @@ -218,7 +218,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("mailitemattch").Location folder, err := handlers[path.EmailCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(folder.GetId()) @@ -234,7 +234,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("mailbasicattch").Location folder, err := handlers[path.EmailCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(folder.GetId()) @@ -250,7 +250,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("mailnestattch").Location folder, err := handlers[path.EmailCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(folder.GetId()) @@ -266,7 +266,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("mailcontactattch").Location folder, err := handlers[path.EmailCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(folder.GetId()) @@ -279,7 +279,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("nestedattch").Location folder, err := handlers[path.EmailCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(folder.GetId()) @@ -292,7 +292,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("maillargeattch").Location folder, err := handlers[path.EmailCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(folder.GetId()) @@ -305,7 +305,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("mailtwoattch").Location folder, err := handlers[path.EmailCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(folder.GetId()) @@ -318,7 +318,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("mailrefattch").Location folder, err := handlers[path.EmailCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(folder.GetId()) @@ -331,7 +331,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("contact").Location folder, err := handlers[path.ContactsCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(folder.GetId()) @@ -344,7 +344,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("event").Location calendar, err := handlers[path.EventsCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(calendar.GetId()) @@ -357,7 +357,7 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { destination: func(t *testing.T, ctx context.Context) string { folderName := testdata.DefaultRestoreConfig("eventobj").Location calendar, err := handlers[path.EventsCategory]. - CreateContainer(ctx, userID, folderName, "") + CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) return ptr.Val(calendar.GetId()) @@ -400,7 +400,7 @@ func (suite *RestoreIntgSuite) TestRestoreAndBackupEvent_recurringInstancesWithA handler = newEventRestoreHandler(suite.ac) ) - calendar, err := handler.ac.CreateContainer(ctx, userID, subject, "") + calendar, err := handler.ac.CreateContainer(ctx, userID, "", subject) require.NoError(t, err, clues.ToCore(err)) calendarID := ptr.Val(calendar.GetId()) diff --git a/src/pkg/services/m365/api/contacts.go b/src/pkg/services/m365/api/contacts.go index 80f4d583e..5b59056fe 100644 --- a/src/pkg/services/m365/api/contacts.go +++ b/src/pkg/services/m365/api/contacts.go @@ -37,8 +37,8 @@ type Contacts struct { // If successful, returns the created folder object. func (c Contacts) CreateContainer( ctx context.Context, - userID, containerName string, - _ string, // parentContainerID needed for iface, doesn't apply to contacts + // parentContainerID needed for iface, doesn't apply to contacts + userID, _, containerName string, ) (graph.Container, error) { body := models.NewContactFolder() body.SetDisplayName(ptr.To(containerName)) @@ -117,6 +117,56 @@ func (c Contacts) GetContainerByID( return c.GetFolder(ctx, userID, containerID) } +// GetContainerByName fetches a folder by name +func (c Contacts) GetContainerByName( + ctx context.Context, + // parentContainerID needed for iface, doesn't apply to contacts + userID, _, containerName string, +) (graph.Container, error) { + filter := fmt.Sprintf("displayName eq '%s'", containerName) + options := &users.ItemContactFoldersRequestBuilderGetRequestConfiguration{ + QueryParameters: &users.ItemContactFoldersRequestBuilderGetQueryParameters{ + Filter: &filter, + }, + } + + ctx = clues.Add(ctx, "container_name", containerName) + + resp, err := c.Stable. + Client(). + Users(). + ByUserId(userID). + ContactFolders(). + Get(ctx, options) + if err != nil { + return nil, graph.Stack(ctx, err).WithClues(ctx) + } + + gv := resp.GetValue() + + if len(gv) == 0 { + return nil, clues.New("container not found").WithClues(ctx) + } + + // We only allow the api to match one container with the provided name. + // Return an error if multiple container exist (unlikely) or if no container + // is found. + if len(gv) != 1 { + return nil, clues.New("unexpected number of folders returned"). + With("returned_container_count", len(gv)). + WithClues(ctx) + } + + // Sanity check ID and name + container := gv[0] + + if err := graph.CheckIDAndName(container); err != nil { + return nil, clues.Stack(err).WithClues(ctx) + } + + return container, nil +} + func (c Contacts) PatchFolder( ctx context.Context, userID, containerID string, diff --git a/src/pkg/services/m365/api/contacts_test.go b/src/pkg/services/m365/api/contacts_test.go index ddba9da87..6b530a14e 100644 --- a/src/pkg/services/m365/api/contacts_test.go +++ b/src/pkg/services/m365/api/contacts_test.go @@ -1,4 +1,4 @@ -package api +package api_test import ( "testing" @@ -7,11 +7,15 @@ import ( "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" exchMock "github.com/alcionai/corso/src/internal/m365/exchange/mock" "github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/pkg/backup/details" + "github.com/alcionai/corso/src/pkg/control/testdata" + "github.com/alcionai/corso/src/pkg/services/m365/api" ) type ContactsAPIUnitSuite struct { @@ -64,7 +68,7 @@ func (suite *ContactsAPIUnitSuite) TestContactInfo() { for _, test := range tests { suite.Run(test.name, func() { contact, expected := test.contactAndRP() - assert.Equal(suite.T(), expected, ContactInfo(contact)) + assert.Equal(suite.T(), expected, api.ContactInfo(contact)) }) } } @@ -99,9 +103,72 @@ func (suite *ContactsAPIUnitSuite) TestBytesToContactable() { suite.Run(test.name, func() { t := suite.T() - result, err := BytesToContactable(test.byteArray) + result, err := api.BytesToContactable(test.byteArray) test.checkError(t, err, clues.ToCore(err)) test.isNil(t, result) }) } } + +type ContactsAPIIntgSuite struct { + tester.Suite + its intgTesterSetup +} + +func TestContactsAPIntgSuite(t *testing.T) { + suite.Run(t, &ContactsAPIIntgSuite{ + Suite: tester.NewIntegrationSuite( + t, + [][]string{tester.M365AcctCredEnvs}), + }) +} + +func (suite *ContactsAPIIntgSuite) SetupSuite() { + suite.its = newIntegrationTesterSetup(suite.T()) +} + +func (suite *ContactsAPIIntgSuite) TestContacts_GetContainerByName() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + // contacts cannot filter for the parent "contacts" folder, so we + // have to hack this by creating a folder to match beforehand. + + rc := testdata.DefaultRestoreConfig("contacts_api") + + cc, err := suite.its.ac.Contacts().CreateContainer( + ctx, + suite.its.userID, + "", + rc.Location) + require.NoError(t, err, clues.ToCore(err)) + + table := []struct { + name string + expectErr assert.ErrorAssertionFunc + }{ + { + name: ptr.Val(cc.GetDisplayName()), + expectErr: assert.NoError, + }, + { + name: "smarfs", + expectErr: assert.Error, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + _, err := suite.its.ac. + Contacts(). + GetContainerByName(ctx, suite.its.userID, "", test.name) + test.expectErr(t, err, clues.ToCore(err)) + }) + } +} diff --git a/src/pkg/services/m365/api/events.go b/src/pkg/services/m365/api/events.go index 801f9abfa..29c794693 100644 --- a/src/pkg/services/m365/api/events.go +++ b/src/pkg/services/m365/api/events.go @@ -21,6 +21,7 @@ import ( "github.com/alcionai/corso/src/internal/m365/graph" "github.com/alcionai/corso/src/pkg/backup/details" "github.com/alcionai/corso/src/pkg/fault" + "github.com/alcionai/corso/src/pkg/logger" ) // --------------------------------------------------------------------------- @@ -44,8 +45,8 @@ type Events struct { // Reference: https://docs.microsoft.com/en-us/graph/api/user-post-calendars?view=graph-rest-1.0&tabs=go func (c Events) CreateContainer( ctx context.Context, - userID, containerName string, - _ string, // parentContainerID needed for iface, doesn't apply to contacts + // parentContainerID needed for iface, doesn't apply to events + userID, _, containerName string, ) (graph.Container, error) { body := models.NewCalendar() body.SetName(&containerName) @@ -132,7 +133,8 @@ func (c Events) GetContainerByID( // GetContainerByName fetches a calendar by name func (c Events) GetContainerByName( ctx context.Context, - userID, containerName string, + // parentContainerID needed for iface, doesn't apply to events + userID, _, containerName string, ) (graph.Container, error) { filter := fmt.Sprintf("name eq '%s'", containerName) options := &users.ItemCalendarsRequestBuilderGetRequestConfiguration{ @@ -141,7 +143,7 @@ func (c Events) GetContainerByName( }, } - ctx = clues.Add(ctx, "calendar_name", containerName) + ctx = clues.Add(ctx, "container_name", containerName) resp, err := c.Stable. Client(). @@ -153,24 +155,25 @@ func (c Events) GetContainerByName( return nil, graph.Stack(ctx, err).WithClues(ctx) } - // We only allow the api to match one calendar with provided name. - // Return an error if multiple calendars exist (unlikely) or if no calendar - // is found. - if len(resp.GetValue()) != 1 { - err = clues.New("unexpected number of calendars returned"). - With("returned_calendar_count", len(resp.GetValue())) - return nil, err + gv := resp.GetValue() + + if len(gv) == 0 { + return nil, clues.New("container not found").WithClues(ctx) } + // We only allow the api to match one calendar with the provided name. + // If we match multiples, we'll eagerly return the first one. + logger.Ctx(ctx).Debugw("calendars matched the name search", "calendar_count", len(gv)) + // Sanity check ID and name - cal := resp.GetValue()[0] - cd := CalendarDisplayable{Calendarable: cal} + cal := gv[0] + container := graph.CalendarDisplayable{Calendarable: cal} - if err := graph.CheckIDAndName(cd); err != nil { - return nil, err + if err := graph.CheckIDAndName(container); err != nil { + return nil, clues.Stack(err).WithClues(ctx) } - return graph.CalendarDisplayable{Calendarable: cal}, nil + return container, nil } func (c Events) PatchCalendar( diff --git a/src/pkg/services/m365/api/events_test.go b/src/pkg/services/m365/api/events_test.go index b39f7488a..06009f52a 100644 --- a/src/pkg/services/m365/api/events_test.go +++ b/src/pkg/services/m365/api/events_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/alcionai/clues" + "github.com/h2non/gock" "github.com/microsoftgraph/msgraph-sdk-go/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -235,7 +236,7 @@ func (suite *EventsAPIIntgSuite) SetupSuite() { suite.its = newIntegrationTesterSetup(suite.T()) } -func (suite *EventsAPIIntgSuite) TestRestoreLargeAttachment() { +func (suite *EventsAPIIntgSuite) TestEvents_RestoreLargeAttachment() { t := suite.T() ctx, flush := tester.NewContext(t) @@ -245,7 +246,7 @@ func (suite *EventsAPIIntgSuite) TestRestoreLargeAttachment() { folderName := testdata.DefaultRestoreConfig("eventlargeattachmenttest").Location evts := suite.its.ac.Events() - calendar, err := evts.CreateContainer(ctx, userID, folderName, "") + calendar, err := evts.CreateContainer(ctx, userID, "", folderName) require.NoError(t, err, clues.ToCore(err)) tomorrow := time.Now().Add(24 * time.Hour) @@ -287,7 +288,7 @@ func (suite *EventsAPIIntgSuite) TestEvents_canFindNonStandardFolder() { ac := suite.its.ac.Events() rc := testdata.DefaultRestoreConfig("api_calendar_discovery") - cal, err := ac.CreateContainer(ctx, suite.its.userID, rc.Location, "") + cal, err := ac.CreateContainer(ctx, suite.its.userID, "", rc.Location) require.NoError(t, err, clues.ToCore(err)) var ( @@ -316,3 +317,91 @@ func (suite *EventsAPIIntgSuite) TestEvents_canFindNonStandardFolder() { "If this fails, the user's calendars have probably broken, "+ "and the user will need to be rotated") } + +func (suite *EventsAPIIntgSuite) TestEvents_GetContainerByName() { + table := []struct { + name string + expectErr assert.ErrorAssertionFunc + }{ + { + name: "Calendar", + expectErr: assert.NoError, + }, + { + name: "smarfs", + expectErr: assert.Error, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + _, err := suite.its.ac. + Events(). + GetContainerByName(ctx, suite.its.userID, "", test.name) + test.expectErr(t, err, clues.ToCore(err)) + }) + } +} + +func (suite *EventsAPIIntgSuite) TestEvents_GetContainerByName_mocked() { + c := models.NewCalendar() + c.SetId(ptr.To("id")) + c.SetName(ptr.To("display name")) + + table := []struct { + name string + results func(*testing.T) map[string]any + expectErr assert.ErrorAssertionFunc + }{ + { + name: "zero", + results: func(t *testing.T) map[string]any { + return parseableToMap(t, models.NewCalendarCollectionResponse()) + }, + expectErr: assert.Error, + }, + { + name: "one", + results: func(t *testing.T) map[string]any { + mfcr := models.NewCalendarCollectionResponse() + mfcr.SetValue([]models.Calendarable{c}) + + return parseableToMap(t, mfcr) + }, + expectErr: assert.NoError, + }, + { + name: "two", + results: func(t *testing.T) map[string]any { + mfcr := models.NewCalendarCollectionResponse() + mfcr.SetValue([]models.Calendarable{c, c}) + + return parseableToMap(t, mfcr) + }, + expectErr: assert.NoError, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + ctx, flush := tester.NewContext(t) + + defer flush() + defer gock.Off() + + interceptV1Path("users", "u", "calendars"). + Reply(200). + JSON(test.results(t)) + + _, err := suite.its.gockAC. + Events(). + GetContainerByName(ctx, "u", "", test.name) + test.expectErr(t, err, clues.ToCore(err)) + assert.True(t, gock.IsDone()) + }) + } +} diff --git a/src/pkg/services/m365/api/mail.go b/src/pkg/services/m365/api/mail.go index ab371074b..1426e05d7 100644 --- a/src/pkg/services/m365/api/mail.go +++ b/src/pkg/services/m365/api/mail.go @@ -82,7 +82,7 @@ func (c Mail) DeleteMailFolder( func (c Mail) CreateContainer( ctx context.Context, - userID, containerName, parentContainerID string, + userID, parentContainerID, containerName string, ) (graph.Container, error) { isHidden := false body := models.NewMailFolder() @@ -165,6 +165,75 @@ func (c Mail) GetContainerByID( return c.GetFolder(ctx, userID, containerID) } +// GetContainerByName fetches a folder by name +func (c Mail) GetContainerByName( + ctx context.Context, + userID, parentContainerID, containerName string, +) (graph.Container, error) { + filter := fmt.Sprintf("displayName eq '%s'", containerName) + + ctx = clues.Add(ctx, "container_name", containerName) + + var ( + builder = c.Stable. + Client(). + Users(). + ByUserId(userID). + MailFolders() + resp models.MailFolderCollectionResponseable + err error + ) + + if len(parentContainerID) > 0 { + options := &users.ItemMailFoldersItemChildFoldersRequestBuilderGetRequestConfiguration{ + QueryParameters: &users.ItemMailFoldersItemChildFoldersRequestBuilderGetQueryParameters{ + Filter: &filter, + }, + } + + resp, err = builder. + ByMailFolderId(parentContainerID). + ChildFolders(). + Get(ctx, options) + } else { + options := &users.ItemMailFoldersRequestBuilderGetRequestConfiguration{ + QueryParameters: &users.ItemMailFoldersRequestBuilderGetQueryParameters{ + Filter: &filter, + }, + } + + resp, err = builder.Get(ctx, options) + } + + if err != nil { + return nil, graph.Stack(ctx, err).WithClues(ctx) + } + + gv := resp.GetValue() + + if len(gv) == 0 { + return nil, clues.New("container not found").WithClues(ctx) + } + + // We only allow the api to match one container with the provided name. + // Return an error if multiple container exist (unlikely) or if no container + // is found. + if len(gv) != 1 { + return nil, clues.New("unexpected number of folders returned"). + With("returned_container_count", len(gv)). + WithClues(ctx) + } + + // Sanity check ID and name + container := gv[0] + + if err := graph.CheckIDAndName(container); err != nil { + return nil, clues.Stack(err).WithClues(ctx) + } + + return container, nil +} + func (c Mail) MoveContainer( ctx context.Context, userID, containerID string, diff --git a/src/pkg/services/m365/api/mail_test.go b/src/pkg/services/m365/api/mail_test.go index 3ad0ced28..618433a23 100644 --- a/src/pkg/services/m365/api/mail_test.go +++ b/src/pkg/services/m365/api/mail_test.go @@ -14,12 +14,10 @@ import ( "github.com/alcionai/corso/src/internal/common/ptr" exchMock "github.com/alcionai/corso/src/internal/m365/exchange/mock" "github.com/alcionai/corso/src/internal/tester" - "github.com/alcionai/corso/src/pkg/account" "github.com/alcionai/corso/src/pkg/backup/details" "github.com/alcionai/corso/src/pkg/control/testdata" "github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/services/m365/api" - "github.com/alcionai/corso/src/pkg/services/m365/api/mock" ) type MailAPIUnitSuite struct { @@ -189,9 +187,7 @@ func (suite *MailAPIUnitSuite) TestBytesToMessagable() { type MailAPIIntgSuite struct { tester.Suite - credentials account.M365Config - ac api.Client - user string + its intgTesterSetup } // We do end up mocking the actual request, but creating the rest @@ -205,17 +201,7 @@ func TestMailAPIIntgSuite(t *testing.T) { } func (suite *MailAPIIntgSuite) SetupSuite() { - t := suite.T() - - a := tester.NewM365Account(t) - m365, err := a.M365Config() - require.NoError(t, err, clues.ToCore(err)) - - suite.credentials = m365 - suite.ac, err = mock.NewClient(m365) - require.NoError(t, err, clues.ToCore(err)) - - suite.user = tester.M365UserID(t) + suite.its = newIntegrationTesterSetup(suite.T()) } func (suite *MailAPIIntgSuite) TestHugeAttachmentListDownload() { @@ -353,7 +339,12 @@ func (suite *MailAPIIntgSuite) TestHugeAttachmentListDownload() { defer gock.Off() tt.setupf() - item, _, err := suite.ac.Mail().GetItem(ctx, "user", mid, false, fault.New(true)) + item, _, err := suite.its.gockAC.Mail().GetItem( + ctx, + "user", + mid, + false, + fault.New(true)) tt.expect(t, err) it, ok := item.(models.Messageable) @@ -381,7 +372,7 @@ func (suite *MailAPIIntgSuite) TestHugeAttachmentListDownload() { } } -func (suite *MailAPIIntgSuite) TestRestoreLargeAttachment() { +func (suite *MailAPIIntgSuite) TestMail_RestoreLargeAttachment() { t := suite.T() ctx, flush := tester.NewContext(t) @@ -390,7 +381,7 @@ func (suite *MailAPIIntgSuite) TestRestoreLargeAttachment() { userID := tester.M365UserID(suite.T()) folderName := testdata.DefaultRestoreConfig("maillargeattachmenttest").Location - msgs := suite.ac.Mail() + msgs := suite.its.ac.Mail() mailfolder, err := msgs.CreateMailFolder(ctx, userID, folderName) require.NoError(t, err, clues.ToCore(err)) @@ -411,3 +402,127 @@ func (suite *MailAPIIntgSuite) TestRestoreLargeAttachment() { require.NoError(t, err, clues.ToCore(err)) require.NotEmpty(t, id, "empty id for large attachment") } + +func (suite *MailAPIIntgSuite) TestMail_GetContainerByName() { + var ( + t = suite.T() + acm = suite.its.ac.Mail() + rc = testdata.DefaultRestoreConfig("mail_get_container_by_name") + ) + + ctx, flush := tester.NewContext(t) + defer flush() + + parent, err := acm.CreateContainer(ctx, suite.its.userID, "msgfolderroot", rc.Location) + require.NoError(t, err, clues.ToCore(err)) + + table := []struct { + name string + parentContainerID string + expectErr assert.ErrorAssertionFunc + }{ + { + name: "Inbox", + expectErr: assert.NoError, + }, + { + name: "smarfs", + expectErr: assert.Error, + }, + { + name: rc.Location, + parentContainerID: ptr.Val(parent.GetId()), + expectErr: assert.Error, + }, + { + name: "Inbox", + parentContainerID: ptr.Val(parent.GetId()), + expectErr: assert.Error, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + _, err := acm.GetContainerByName(ctx, suite.its.userID, test.parentContainerID, test.name) + test.expectErr(t, err, clues.ToCore(err)) + }) + } + + suite.Run("child folder with same name", func() { + pid := ptr.Val(parent.GetId()) + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + child, err := acm.CreateContainer(ctx, suite.its.userID, pid, rc.Location) + require.NoError(t, err, clues.ToCore(err)) + + result, err := acm.GetContainerByName(ctx, suite.its.userID, pid, rc.Location) + assert.NoError(t, err, clues.ToCore(err)) + assert.Equal(t, ptr.Val(child.GetId()), ptr.Val(result.GetId())) + }) +} + +func (suite *MailAPIIntgSuite) TestMail_GetContainerByName_mocked() { + mf := models.NewMailFolder() + mf.SetId(ptr.To("id")) + mf.SetDisplayName(ptr.To("display name")) + + table := []struct { + name string + results func(*testing.T) map[string]any + expectErr assert.ErrorAssertionFunc + }{ + { + name: "zero", + results: func(t *testing.T) map[string]any { + return parseableToMap(t, models.NewMailFolderCollectionResponse()) + }, + expectErr: assert.Error, + }, + { + name: "one", + results: func(t *testing.T) map[string]any { + mfcr := models.NewMailFolderCollectionResponse() + mfcr.SetValue([]models.MailFolderable{mf}) + + return parseableToMap(t, mfcr) + }, + expectErr: assert.NoError, + }, + { + name: "two", + results: func(t *testing.T) map[string]any { + mfcr := models.NewMailFolderCollectionResponse() + mfcr.SetValue([]models.MailFolderable{mf, mf}) + + return parseableToMap(t, mfcr) + }, + expectErr: assert.Error, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + ctx, flush := tester.NewContext(t) + + defer flush() + defer gock.Off() + + interceptV1Path("users", "u", "mailFolders"). + Reply(200). + JSON(test.results(t)) + + _, err := suite.its.gockAC. + Mail(). + GetContainerByName(ctx, "u", "", test.name) + test.expectErr(t, err, clues.ToCore(err)) + assert.True(t, gock.IsDone()) + }) + } +}