package operations import ( "context" "encoding/json" stdpath "path" "testing" "time" "github.com/alcionai/clues" "github.com/kopia/kopia/snapshot" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "golang.org/x/exp/slices" "github.com/alcionai/corso/src/internal/common/prefixmatcher" "github.com/alcionai/corso/src/internal/common/ptr" strTD "github.com/alcionai/corso/src/internal/common/str/testdata" "github.com/alcionai/corso/src/internal/data" dataMock "github.com/alcionai/corso/src/internal/data/mock" evmock "github.com/alcionai/corso/src/internal/events/mock" "github.com/alcionai/corso/src/internal/kopia" "github.com/alcionai/corso/src/internal/m365/mock" odConsts "github.com/alcionai/corso/src/internal/m365/service/onedrive/consts" odMock "github.com/alcionai/corso/src/internal/m365/service/onedrive/mock" odStub "github.com/alcionai/corso/src/internal/m365/service/onedrive/stub" "github.com/alcionai/corso/src/internal/m365/support" "github.com/alcionai/corso/src/internal/model" "github.com/alcionai/corso/src/internal/operations/inject" opMock "github.com/alcionai/corso/src/internal/operations/inject/mock" "github.com/alcionai/corso/src/internal/streamstore" ssmock "github.com/alcionai/corso/src/internal/streamstore/mock" "github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester/its" "github.com/alcionai/corso/src/internal/tester/tconfig" "github.com/alcionai/corso/src/pkg/account" "github.com/alcionai/corso/src/pkg/backup" "github.com/alcionai/corso/src/pkg/backup/details" deeTD "github.com/alcionai/corso/src/pkg/backup/details/testdata" "github.com/alcionai/corso/src/pkg/backup/identity" "github.com/alcionai/corso/src/pkg/backup/metadata" "github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/control/repository" "github.com/alcionai/corso/src/pkg/count" "github.com/alcionai/corso/src/pkg/extensions" "github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/selectors" selTD "github.com/alcionai/corso/src/pkg/selectors/testdata" "github.com/alcionai/corso/src/pkg/services/m365/api/graph" storeTD "github.com/alcionai/corso/src/pkg/storage/testdata" "github.com/alcionai/corso/src/pkg/store" ) // --------------------------------------------------------------------------- // mocks // --------------------------------------------------------------------------- // ----- restore producer type mockRestoreProducer struct { gotPaths []path.Path colls []data.RestoreCollection collsByID map[string][]data.RestoreCollection // snapshotID: []RestoreCollection err error onRestore restoreFunc } type restoreFunc func( id string, ps []path.RestorePaths, ) ([]data.RestoreCollection, error) func (mr *mockRestoreProducer) buildRestoreFunc( t *testing.T, oid string, ops []path.Path, ) { mr.onRestore = func( id string, ps []path.RestorePaths, ) ([]data.RestoreCollection, error) { gotPaths := make([]path.Path, 0, len(ps)) for _, rp := range ps { gotPaths = append(gotPaths, rp.StoragePath) } assert.Equal(t, oid, id, "manifest id") checkPaths(t, ops, gotPaths) return mr.colls, mr.err } } func (mr *mockRestoreProducer) ProduceRestoreCollections( ctx context.Context, snapshotID string, paths []path.RestorePaths, bc kopia.ByteCounter, errs *fault.Bus, ) ([]data.RestoreCollection, error) { for _, ps := range paths { mr.gotPaths = append(mr.gotPaths, ps.StoragePath) } if mr.onRestore != nil { return mr.onRestore(snapshotID, paths) } if len(mr.collsByID) > 0 { return mr.collsByID[snapshotID], mr.err } return mr.colls, mr.err } func checkPaths(t *testing.T, expected, got []path.Path) { assert.ElementsMatch(t, expected, got) } // ----- backup consumer type mockBackupConsumer struct { checkFunc func( backupReasons []identity.Reasoner, bases kopia.BackupBases, cs []data.BackupCollection, tags map[string]string, buildTreeWithBase bool) } func (mbu mockBackupConsumer) ConsumeBackupCollections( ctx context.Context, backupReasons []identity.Reasoner, bases kopia.BackupBases, cs []data.BackupCollection, excluded prefixmatcher.StringSetReader, tags map[string]string, buildTreeWithBase bool, counter *count.Bus, errs *fault.Bus, ) (*kopia.BackupStats, *details.Builder, kopia.DetailsMergeInfoer, error) { if mbu.checkFunc != nil { mbu.checkFunc(backupReasons, bases, cs, tags, buildTreeWithBase) } return &kopia.BackupStats{}, &details.Builder{}, nil, nil } // ----- model store for backups type mockDetailsMergeInfoer struct { repoRefs map[string]path.Path locs map[string]*path.Builder modTimes map[string]time.Time } func (m *mockDetailsMergeInfoer) add(oldRef, newRef path.Path, newLoc *path.Builder) { oldPB := oldRef.ToBuilder() // Items are indexed individually. m.repoRefs[oldPB.ShortRef()] = newRef // Locations are indexed by directory. m.locs[oldPB.ShortRef()] = newLoc } func (m *mockDetailsMergeInfoer) addWithModTime( oldRef path.Path, modTime time.Time, newRef path.Path, newLoc *path.Builder, ) { oldPB := oldRef.ToBuilder() // Items are indexed individually. m.repoRefs[oldPB.ShortRef()] = newRef m.modTimes[oldPB.ShortRef()] = modTime // Locations are indexed by directory. m.locs[oldPB.ShortRef()] = newLoc } func (m *mockDetailsMergeInfoer) GetNewPathRefs( oldRef *path.Builder, modTime time.Time, _ details.LocationIDer, ) (path.Path, *path.Builder, error) { // Return no match if the modTime was set and it wasn't what was passed in. if mt, ok := m.modTimes[oldRef.ShortRef()]; ok && !mt.Equal(modTime) { return nil, nil, nil } return m.repoRefs[oldRef.ShortRef()], m.locs[oldRef.ShortRef()], nil } func (m *mockDetailsMergeInfoer) ItemsToMerge() int { if m == nil { return 0 } return len(m.repoRefs) } func newMockDetailsMergeInfoer() *mockDetailsMergeInfoer { return &mockDetailsMergeInfoer{ repoRefs: map[string]path.Path{}, locs: map[string]*path.Builder{}, modTimes: map[string]time.Time{}, } } // --------------------------------------------------------------------------- // helper funcs // --------------------------------------------------------------------------- // expects you to Append your own file func makeMetadataBasePath( t *testing.T, tenant string, service path.ServiceType, resourceOwner string, category path.CategoryType, ) path.Path { t.Helper() p, err := path.BuildMetadata( tenant, resourceOwner, service, category, false) require.NoError(t, err, clues.ToCore(err)) return p } func makeFolderEntry( t *testing.T, pb, loc *path.Builder, size int64, modTime time.Time, dt details.ItemType, ) *details.Entry { t.Helper() return &details.Entry{ RepoRef: pb.String(), ShortRef: pb.ShortRef(), ParentRef: pb.Dir().ShortRef(), LocationRef: loc.Dir().String(), ItemInfo: details.ItemInfo{ Folder: &details.FolderInfo{ ItemType: details.FolderItem, DisplayName: pb.LastElem(), Modified: modTime, Size: size, DataType: dt, }, }, } } // TODO(ashmrtn): Really need to factor a function like this out into some // common file that is only compiled for tests. func makePath(t *testing.T, elements []string, isItem bool) path.Path { t.Helper() p, err := path.FromDataLayerPath(stdpath.Join(elements...), isItem) require.NoError(t, err, clues.ToCore(err)) return p } func makeDetailsEntry( t *testing.T, p path.Path, l *path.Builder, size int, updated bool, ) *details.Entry { t.Helper() var lr string if l != nil { lr = l.String() } res := &details.Entry{ RepoRef: p.String(), ShortRef: p.ShortRef(), ParentRef: p.ToBuilder().Dir().ShortRef(), ItemRef: p.Item(), LocationRef: lr, ItemInfo: details.ItemInfo{}, } switch p.Service() { case path.ExchangeService: if p.Category() != path.EmailCategory { assert.FailNowf( t, "category %s not supported in helper function", p.Category().String()) } res.Exchange = &details.ExchangeInfo{ ItemType: details.ExchangeMail, Size: int64(size), ParentPath: l.String(), } case path.OneDriveService: require.NotNil(t, l) res.OneDrive = &details.OneDriveInfo{ ItemType: details.OneDriveItem, ParentPath: l.PopFront().String(), Size: int64(size), DriveID: "drive-id", DriveName: "drive-name", } default: assert.FailNowf( t, "service %s not supported in helper function", p.Service().String()) } return res } func makeDetailsEntryWithModTime( t *testing.T, p path.Path, l *path.Builder, size int, updated bool, modTime time.Time, ) *details.Entry { t.Helper() res := makeDetailsEntry(t, p, l, size, updated) switch { case res.Exchange != nil: res.Exchange.Modified = modTime case res.OneDrive != nil: res.OneDrive.Modified = modTime case res.SharePoint != nil: res.SharePoint.Modified = modTime } return res } // --------------------------------------------------------------------------- // unit tests // --------------------------------------------------------------------------- type BackupOpUnitSuite struct { tester.Suite } func TestBackupOpUnitSuite(t *testing.T) { suite.Run(t, &BackupOpUnitSuite{Suite: tester.NewUnitSuite(t)}) } // checkPopulated ensures that input has no zero-valued fields. That helps // ensure that even as future updates to input happen in other files the changes // are propagated here due to test failures. func checkPopulated(t *testing.T, input control.Options) { err := tester.CheckPopulated(input) require.NoError(t, err, clues.ToCore(err)) } // TestNewBackupOperation_configuredOptionsMatchInputOptions ensures that the // passed in options are properly set in the backup operation during // intialization. This test is mostly expected to be used while transitioning // from passing in backup operation config values during repo connect to during // backup op creation. func (suite *BackupOpUnitSuite) TestNewBackupOperation_configuredOptionsMatchInputOptions() { ext := []extensions.CreateItemExtensioner{ &extensions.MockItemExtensionFactory{}, } opts := control.Options{ DeltaPageSize: 42, DisableMetrics: true, FailureHandling: control.FailAfterRecovery, ItemExtensionFactory: slices.Clone(ext), Parallelism: control.Parallelism{ CollectionBuffer: 4, ItemFetch: 4, }, Repo: repository.Options{ User: "foo", Host: "bar", ViewTimestamp: ptr.To(time.Now()), ReadOnly: true, }, SkipReduce: true, ToggleFeatures: control.Toggles{ DisableIncrementals: true, ForceItemDataDownload: true, DisableDelta: true, ExchangeImmutableIDs: true, RunMigrations: true, DisableSlidingWindowLimiter: true, UseDeltaTree: true, UseOldDeltaProcess: true, DisableLazyItemReader: true, }, PreviewLimits: control.PreviewItemLimits{ MaxItems: 42, MaxItemsPerContainer: 43, MaxContainers: 44, MaxBytes: 45, MaxPages: 46, Enabled: true, }, } t := suite.T() ctx, flush := tester.NewContext(t) defer flush() // This is a sanity check to make sure all fields on the input control.Options // are populated. This helps ensure that this chunk of code stays updated as // options are added to the struct. checkPopulated(t, opts) var ( kw = &kopia.Wrapper{} sw = store.NewWrapper(&kopia.ModelStore{}) ctrl = &mock.Controller{} acct = account.Account{} ) sel := selectors.Selector{} sel.DiscreteOwner = "bombadil" op, err := NewBackupOperation( ctx, opts, kw, sw, ctrl, acct, sel, sel, evmock.NewBus(), count.New()) require.NoError(t, err, clues.ToCore(err)) assert.Equal(t, opts, op.Options) } func (suite *BackupOpUnitSuite) TestBackupOperation_PersistResults() { var ( kw = &kopia.Wrapper{} sw = store.NewWrapper(&kopia.ModelStore{}) ctrl = &mock.Controller{} acct = account.Account{} now = time.Now() ) table := []struct { expectStatus OpStatus expectErr assert.ErrorAssertionFunc stats backupStats fail error }{ { expectStatus: Completed, expectErr: assert.NoError, stats: backupStats{ resourceCount: 1, k: &kopia.BackupStats{ TotalFileCount: 1, TotalHashedBytes: 1, TotalUploadedBytes: 1, }, ctrl: &data.CollectionStats{Successes: 1}, }, }, { expectStatus: Failed, expectErr: assert.Error, fail: assert.AnError, stats: backupStats{ k: &kopia.BackupStats{}, ctrl: &data.CollectionStats{}, }, }, { expectStatus: NoData, expectErr: assert.NoError, stats: backupStats{ k: &kopia.BackupStats{}, ctrl: &data.CollectionStats{}, }, }, } for _, test := range table { suite.Run(test.expectStatus.String(), func() { t := suite.T() ctx, flush := tester.NewContext(t) defer flush() sel := selectors.Selector{} sel.DiscreteOwner = "bombadil" op, err := NewBackupOperation( ctx, control.DefaultOptions(), kw, sw, ctrl, acct, sel, sel, evmock.NewBus(), count.New()) require.NoError(t, err, clues.ToCore(err)) op.Errors.Fail(test.fail) // op.Counter is not incremented in this test. test.expectErr(t, op.persistResults(now, &test.stats, count.New())) assert.Equal(t, test.expectStatus.String(), op.Status.String(), "status") assert.Equal(t, op.Results.ItemsRead, test.stats.ctrl.Successes, "items read") assert.Equal(t, op.Results.ItemsWritten, test.stats.k.TotalFileCount, "items written") assert.Equal(t, op.Results.BytesRead, test.stats.k.TotalHashedBytes, "bytes read") assert.Equal(t, op.Results.BytesUploaded, test.stats.k.TotalUploadedBytes, "bytes written") assert.Equal(t, now, op.Results.StartedAt, "started at") assert.Less(t, now, op.Results.CompletedAt, "completed at") }) } } func (suite *BackupOpUnitSuite) TestBackupOperation_ConsumeBackupDataCollections_Paths() { var ( t = suite.T() tenant = "a-tenant" resourceOwner = "a-user" emailReason = identity.NewReason( tenant, resourceOwner, path.ExchangeService, path.EmailCategory) contactsReason = identity.NewReason( tenant, resourceOwner, path.ExchangeService, path.ContactsCategory) reasons = []identity.Reasoner{ emailReason, contactsReason, } manifest1 = &snapshot.Manifest{ ID: "id1", } manifest2 = &snapshot.Manifest{ ID: "id2", } bases = kopia.NewMockBackupBases().WithMergeBases( kopia.BackupBase{ Backup: &backup.Backup{BaseModel: model.BaseModel{ID: "1"}}, ItemDataSnapshot: manifest1, Reasons: []identity.Reasoner{ emailReason, }, }).WithAssistBases( kopia.BackupBase{ Backup: &backup.Backup{BaseModel: model.BaseModel{ID: "2"}}, ItemDataSnapshot: manifest2, Reasons: []identity.Reasoner{ contactsReason, }, }) backupID = model.StableID("foo") expectedTags = map[string]string{ kopia.TagBackupID: string(backupID), kopia.TagBackupCategory: "", } ) mbu := &mockBackupConsumer{ checkFunc: func( backupReasons []identity.Reasoner, gotBases kopia.BackupBases, cs []data.BackupCollection, gotTags map[string]string, buildTreeWithBase bool, ) { kopia.AssertBackupBasesEqual(t, bases, gotBases) assert.Equal(t, expectedTags, gotTags) assert.ElementsMatch(t, reasons, backupReasons) }, } ctx, flush := tester.NewContext(t) defer flush() //nolint:errcheck consumeBackupCollections( ctx, mbu, tenant, reasons, bases, nil, nil, backupID, true, count.New(), fault.New(true)) } func (suite *BackupOpUnitSuite) TestBackupOperation_MergeBackupDetails_AddsItems() { var ( tenant = "a-tenant" ro = "a-user" itemPath1 = makePath( suite.T(), []string{ tenant, path.OneDriveService.String(), ro, path.FilesCategory.String(), odConsts.DrivesPathDir, "drive-id", odConsts.RootPathDir, "work", "item1", }, true) locationPath1 = path.Builder{}.Append(odConsts.RootPathDir, "work-display-name") itemPath2 = makePath( suite.T(), []string{ tenant, path.OneDriveService.String(), ro, path.FilesCategory.String(), odConsts.DrivesPathDir, "drive-id", odConsts.RootPathDir, "personal", "item2", }, true) locationPath2 = path.Builder{}.Append(odConsts.RootPathDir, "personal-display-name") itemPath3 = makePath( suite.T(), []string{ tenant, path.ExchangeService.String(), ro, path.EmailCategory.String(), "personal", "item3", }, true) locationPath3 = path.Builder{}.Append("personal-display-name") backup1 = backup.Backup{ BaseModel: model.BaseModel{ ID: "bid1", }, DetailsID: "did1", } backup2 = backup.Backup{ BaseModel: model.BaseModel{ ID: "bid2", }, DetailsID: "did2", } pathReason1 = identity.NewReason( "", itemPath1.ProtectedResource(), itemPath1.Service(), itemPath1.Category()) pathReason3 = identity.NewReason( "", itemPath3.ProtectedResource(), itemPath3.Service(), itemPath3.Category()) time1 = time.Now() time2 = time1.Add(time.Hour) exchangeItemPath1 = makePath( suite.T(), []string{ tenant, path.ExchangeService.String(), ro, path.EmailCategory.String(), "work", "item1", }, true) exchangeLocationPath1 = path.Builder{}.Append("work-display-name") exchangePathReason1 = identity.NewReason( "", exchangeItemPath1.ProtectedResource(), exchangeItemPath1.Service(), exchangeItemPath1.Category()) ) itemParents1, err := path.GetDriveFolderPath(itemPath1) require.NoError(suite.T(), err, clues.ToCore(err)) itemParents1String := itemParents1.String() table := []struct { name string populatedDetails map[string]*details.Details inputBackups []kopia.BackupBase inputAssistBackups []kopia.BackupBase mdm *mockDetailsMergeInfoer errCheck assert.ErrorAssertionFunc expectedEntries []*details.Entry }{ { name: "NilShortRefsFromPrevBackup", errCheck: assert.NoError, // Use empty slice so we don't error out on nil != empty. expectedEntries: []*details.Entry{}, }, { name: "EmptyShortRefsFromPrevBackup", mdm: newMockDetailsMergeInfoer(), errCheck: assert.NoError, // Use empty slice so we don't error out on nil != empty. expectedEntries: []*details.Entry{}, }, { name: "DetailsIDNotFound", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.add(itemPath1, itemPath1, locationPath1) return res }(), inputBackups: []kopia.BackupBase{ { Backup: &backup.Backup{ BaseModel: model.BaseModel{ ID: backup1.ID, }, DetailsID: "foo", }, Reasons: []identity.Reasoner{ pathReason1, }, }, }, errCheck: assert.Error, }, { name: "BaseMissingItems", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.add(itemPath1, itemPath1, locationPath1) res.add(itemPath2, itemPath2, locationPath2) return res }(), inputBackups: []kopia.BackupBase{ { Backup: &backup1, Reasons: []identity.Reasoner{ pathReason1, }, }, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntry(suite.T(), itemPath1, locationPath1, 42, false), }, }, }, }, errCheck: assert.Error, }, { name: "BadBaseRepoRef", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.add(itemPath1, itemPath2, locationPath2) return res }(), inputBackups: []kopia.BackupBase{ { Backup: &backup1, Reasons: []identity.Reasoner{ pathReason1, }, }, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ { RepoRef: stdpath.Join( append( []string{ itemPath1.Tenant(), itemPath1.Service().String(), itemPath1.ProtectedResource(), path.UnknownCategory.String(), }, itemPath1.Folders()...)...), ItemInfo: details.ItemInfo{ OneDrive: &details.OneDriveInfo{ ItemType: details.OneDriveItem, ParentPath: itemParents1String, Size: 42, }, }, }, }, }, }, }, errCheck: assert.Error, }, { name: "BadOneDrivePath", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() p := makePath( suite.T(), []string{ itemPath1.Tenant(), path.OneDriveService.String(), itemPath1.ProtectedResource(), path.FilesCategory.String(), "personal", "item1", }, true) res.add(itemPath1, p, nil) return res }(), inputBackups: []kopia.BackupBase{ { Backup: &backup1, Reasons: []identity.Reasoner{ pathReason1, }, }, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntry(suite.T(), itemPath1, locationPath1, 42, false), }, }, }, }, errCheck: assert.Error, }, { name: "ItemMerged", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.add(itemPath1, itemPath1, locationPath1) return res }(), inputBackups: []kopia.BackupBase{ { Backup: &backup1, Reasons: []identity.Reasoner{ pathReason1, }, }, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntry(suite.T(), itemPath1, locationPath1, 42, false), }, }, }, }, errCheck: assert.NoError, expectedEntries: []*details.Entry{ makeDetailsEntry(suite.T(), itemPath1, locationPath1, 42, false), }, }, { name: "ExchangeItemMerged", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.add(exchangeItemPath1, exchangeItemPath1, exchangeLocationPath1) return res }(), inputBackups: []kopia.BackupBase{ { Backup: &backup1, Reasons: []identity.Reasoner{ exchangePathReason1, }, }, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntry(suite.T(), exchangeItemPath1, exchangeLocationPath1, 42, false), }, }, }, }, errCheck: assert.NoError, expectedEntries: []*details.Entry{ makeDetailsEntry(suite.T(), exchangeItemPath1, exchangeLocationPath1, 42, false), }, }, { name: "ItemMergedSameLocation", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.add(itemPath1, itemPath1, locationPath1) return res }(), inputBackups: []kopia.BackupBase{ { Backup: &backup1, Reasons: []identity.Reasoner{ pathReason1, }, }, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntry(suite.T(), itemPath1, locationPath1, 42, false), }, }, }, }, errCheck: assert.NoError, expectedEntries: []*details.Entry{ makeDetailsEntry(suite.T(), itemPath1, locationPath1, 42, false), }, }, { name: "ItemMergedExtraItemsInBase", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.add(itemPath1, itemPath1, locationPath1) return res }(), inputBackups: []kopia.BackupBase{ { Backup: &backup1, Reasons: []identity.Reasoner{ pathReason1, }, }, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntry(suite.T(), itemPath1, locationPath1, 42, false), *makeDetailsEntry(suite.T(), itemPath2, locationPath2, 84, false), }, }, }, }, errCheck: assert.NoError, expectedEntries: []*details.Entry{ makeDetailsEntry(suite.T(), itemPath1, locationPath1, 42, false), }, }, { name: "ItemMoved", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.add(itemPath1, itemPath2, locationPath2) return res }(), inputBackups: []kopia.BackupBase{ { Backup: &backup1, Reasons: []identity.Reasoner{ pathReason1, }, }, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntry(suite.T(), itemPath1, locationPath1, 42, false), }, }, }, }, errCheck: assert.NoError, expectedEntries: []*details.Entry{ makeDetailsEntry(suite.T(), itemPath2, locationPath2, 42, true), }, }, { name: "MultipleBases", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.add(itemPath1, itemPath1, locationPath1) res.add(itemPath3, itemPath3, locationPath3) return res }(), inputBackups: []kopia.BackupBase{ { Backup: &backup1, Reasons: []identity.Reasoner{ pathReason1, }, }, { Backup: &backup2, Reasons: []identity.Reasoner{ pathReason3, }, }, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntry(suite.T(), itemPath1, locationPath1, 42, false), }, }, }, backup2.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ // This entry should not be picked due to a mismatch on Reasons. *makeDetailsEntry(suite.T(), itemPath1, locationPath1, 84, false), // This item should be picked. *makeDetailsEntry(suite.T(), itemPath3, locationPath3, 37, false), }, }, }, }, errCheck: assert.NoError, expectedEntries: []*details.Entry{ makeDetailsEntry(suite.T(), itemPath1, locationPath1, 42, false), makeDetailsEntry(suite.T(), itemPath3, locationPath3, 37, false), }, }, { name: "MergeAndAssistBases SameItems", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.addWithModTime(itemPath1, time1, itemPath1, locationPath1) res.addWithModTime(itemPath3, time2, itemPath3, locationPath3) return res }(), inputBackups: []kopia.BackupBase{ { Backup: &backup1, Reasons: []identity.Reasoner{ pathReason1, pathReason3, }, }, }, inputAssistBackups: []kopia.BackupBase{ {Backup: &backup2}, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 42, false, time1), *makeDetailsEntryWithModTime(suite.T(), itemPath3, locationPath3, 37, false, time2), }, }, }, backup2.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 42, false, time1), *makeDetailsEntryWithModTime(suite.T(), itemPath3, locationPath3, 37, false, time2), }, }, }, }, errCheck: assert.NoError, expectedEntries: []*details.Entry{ makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 42, false, time1), makeDetailsEntryWithModTime(suite.T(), itemPath3, locationPath3, 37, false, time2), }, }, { name: "MergeAndAssistBases AssistBaseHasNewerItems", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.addWithModTime(itemPath1, time2, itemPath1, locationPath1) return res }(), inputBackups: []kopia.BackupBase{ { Backup: &backup1, Reasons: []identity.Reasoner{ pathReason1, }, }, }, inputAssistBackups: []kopia.BackupBase{ {Backup: &backup2}, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 42, false, time1), }, }, }, backup2.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 84, false, time2), }, }, }, }, errCheck: assert.NoError, expectedEntries: []*details.Entry{ makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 84, false, time2), }, }, { name: "AssistBases ConcurrentAssistBasesPicksMatchingVersion1", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.addWithModTime(itemPath1, time2, itemPath1, locationPath1) return res }(), inputAssistBackups: []kopia.BackupBase{ {Backup: &backup1}, {Backup: &backup2}, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 42, false, time1), }, }, }, backup2.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 84, false, time2), }, }, }, }, errCheck: assert.NoError, expectedEntries: []*details.Entry{ makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 84, false, time2), }, }, { name: "AssistBases ConcurrentAssistBasesPicksMatchingVersion2", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.addWithModTime(itemPath1, time1, itemPath1, locationPath1) return res }(), inputAssistBackups: []kopia.BackupBase{ {Backup: &backup1}, {Backup: &backup2}, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 42, false, time1), }, }, }, backup2.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 84, false, time2), }, }, }, }, errCheck: assert.NoError, expectedEntries: []*details.Entry{ makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 42, false, time1), }, }, { name: "AssistBases SameItemVersion", mdm: func() *mockDetailsMergeInfoer { res := newMockDetailsMergeInfoer() res.addWithModTime(itemPath1, time1, itemPath1, locationPath1) return res }(), inputAssistBackups: []kopia.BackupBase{ {Backup: &backup1}, {Backup: &backup2}, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 42, false, time1), }, }, }, backup2.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 42, false, time1), }, }, }, }, errCheck: assert.NoError, expectedEntries: []*details.Entry{ makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 42, false, time1), }, }, { name: "AssistBase ItemDeleted", mdm: func() *mockDetailsMergeInfoer { return newMockDetailsMergeInfoer() }(), inputAssistBackups: []kopia.BackupBase{ {Backup: &backup1}, }, populatedDetails: map[string]*details.Details{ backup1.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{ *makeDetailsEntryWithModTime(suite.T(), itemPath1, locationPath1, 42, false, time1), }, }, }, }, errCheck: assert.NoError, expectedEntries: []*details.Entry{}, }, } for _, test := range table { suite.Run(test.name, func() { t := suite.T() ctx, flush := tester.NewContext(t) defer flush() mds := ssmock.Streamer{Deets: test.populatedDetails} deets := details.Builder{} writeStats := kopia.BackupStats{} bb := kopia.NewMockBackupBases(). WithMergeBases(test.inputBackups...). WithAssistBases(test.inputAssistBackups...) err := mergeDetails( ctx, mds, bb, test.mdm, &deets, &writeStats, path.OneDriveService, fault.New(true)) test.errCheck(t, err, clues.ToCore(err)) if err != nil { return } // Check the JSON output format of things because for some reason it's not // using the proper comparison for time.Time and failing due to that. checkJSONOutputs(t, test.expectedEntries, deets.Details().Items()) }) } } func checkJSONOutputs( t *testing.T, expected []*details.Entry, got []*details.Entry, ) { t.Helper() expectedJSON, err := json.Marshal(expected) require.NoError(t, err, "marshalling expected data") gotJSON, err := json.Marshal(got) require.NoError(t, err, "marshalling got data") assert.JSONEq(t, string(expectedJSON), string(gotJSON)) } func (suite *BackupOpUnitSuite) TestBackupOperation_MergeBackupDetails_AddsFolders() { var ( t = suite.T() tenant = "a-tenant" ro = "a-user" pathElems = []string{ tenant, path.ExchangeService.String(), ro, path.EmailCategory.String(), "work", "project8", "item1", } itemPath1 = makePath( t, pathElems, true) locPath1 = path.Builder{}.Append(itemPath1.Folders()...) pathReason1 = identity.NewReason( "", itemPath1.ProtectedResource(), itemPath1.Service(), itemPath1.Category()) backup1 = kopia.BackupBase{ Backup: &backup.Backup{ BaseModel: model.BaseModel{ ID: "bid1", }, DetailsID: "did1", }, Reasons: []identity.Reasoner{ pathReason1, }, } itemSize = 42 now = time.Now() // later = now.Add(42 * time.Minute) ) mdm := newMockDetailsMergeInfoer() mdm.add(itemPath1, itemPath1, locPath1) itemDetails := makeDetailsEntry(t, itemPath1, locPath1, itemSize, false) // itemDetails.Exchange.Modified = now populatedDetails := map[string]*details.Details{ backup1.Backup.DetailsID: { DetailsModel: details.DetailsModel{ Entries: []details.Entry{*itemDetails}, }, }, } expectedEntries := []details.Entry{*itemDetails} // update the details itemDetails.Exchange.Modified = now for i := 1; i <= len(locPath1.Elements()); i++ { expectedEntries = append(expectedEntries, *makeFolderEntry( t, // Include prefix elements in the RepoRef calculations. path.Builder{}.Append(pathElems[:4+i]...), path.Builder{}.Append(locPath1.Elements()[:i]...), int64(itemSize), itemDetails.Exchange.Modified, details.ExchangeMail)) } ctx, flush := tester.NewContext(t) defer flush() var ( mds = ssmock.Streamer{Deets: populatedDetails} deets = details.Builder{} writeStats = kopia.BackupStats{} ) err := mergeDetails( ctx, mds, kopia.NewMockBackupBases().WithMergeBases(backup1), mdm, &deets, &writeStats, path.ExchangeService, fault.New(true)) assert.NoError(t, err, clues.ToCore(err)) compareDeetEntries(t, expectedEntries, deets.Details().Entries) } // compares two details slices. Useful for tests where serializing the // entries can produce minor variations in the time struct, causing // assert.elementsMatch to fail. func compareDeetEntries( t *testing.T, expect, result []details.Entry, ) { if !assert.Equal(t, len(expect), len(result), "entry slices should be equal len") { require.ElementsMatch(t, expect, result) } var ( // repoRef -> modified time eMods = map[string]time.Time{} es = make([]details.Entry, 0, len(expect)) rs = make([]details.Entry, 0, len(expect)) ) for _, e := range expect { eMods[e.RepoRef] = e.Modified() es = append(es, withoutModified(e)) } for _, r := range result { // this comparison is an artifact of bad comparisons across time.Time // serialization using assert.ElementsMatch. The time struct can produce // differences in its `ext` value across serialization while the actual time // reference remains the same. assert handles this poorly, whereas the time // library provides successful comparison. assert.Truef( t, eMods[r.RepoRef].Equal(r.Modified()), "expected modified time %v, got %v", eMods[r.RepoRef], r.Modified()) rs = append(rs, withoutModified(r)) } assert.ElementsMatch(t, es, rs) } func withoutModified(de details.Entry) details.Entry { switch { case de.Exchange != nil: de.Exchange.Modified = time.Time{} case de.OneDrive != nil: de.OneDrive.Modified = time.Time{} case de.SharePoint != nil: de.SharePoint.Modified = time.Time{} case de.Folder != nil: de.Folder.Modified = time.Time{} } return de } // --------------------------------------------------------------------------- // integration tests // --------------------------------------------------------------------------- type BackupOpIntegrationSuite struct { tester.Suite m365 its.M365IntgTestSetup } func TestBackupOpIntegrationSuite(t *testing.T) { suite.Run(t, &BackupOpIntegrationSuite{ Suite: tester.NewIntegrationSuite( t, [][]string{storeTD.AWSStorageCredEnvs, tconfig.M365AcctCredEnvs}), }) } func (suite *BackupOpIntegrationSuite) SetupSuite() { t := suite.T() suite.m365 = its.GetM365(t) ctx, flush := tester.NewContext(t) defer flush() graph.InitializeConcurrencyLimiter(ctx, true, 4) } func (suite *BackupOpIntegrationSuite) TestNewBackupOperation() { var ( kw = &kopia.Wrapper{} sw = store.NewWrapper(&kopia.ModelStore{}) ctrl = &mock.Controller{} opts = control.DefaultOptions() acct = suite.m365.Acct ) table := []struct { name string kw *kopia.Wrapper sw store.BackupStorer bp inject.BackupProducer acct account.Account targets []string errCheck assert.ErrorAssertionFunc }{ {"good", kw, sw, ctrl, acct, nil, assert.NoError}, {"missing kopia", nil, sw, ctrl, acct, nil, assert.Error}, {"missing modelstore", kw, nil, ctrl, acct, nil, assert.Error}, {"missing backup producer", kw, sw, nil, acct, nil, assert.Error}, } for _, test := range table { suite.Run(test.name, func() { t := suite.T() ctx, flush := tester.NewContext(t) defer flush() sel := selectors.Selector{DiscreteOwner: "test"} _, err := NewBackupOperation( ctx, opts, test.kw, test.sw, test.bp, test.acct, sel, sel, evmock.NewBus(), count.New()) test.errCheck(t, err, clues.ToCore(err)) }) } } type AssistBackupIntegrationSuite struct { tester.Suite kopiaCloser func(ctx context.Context) acct account.Account kw *kopia.Wrapper sw store.BackupStorer ms *kopia.ModelStore } func TestAssistBackupIntegrationSuite(t *testing.T) { suite.Run(t, &AssistBackupIntegrationSuite{ Suite: tester.NewIntegrationSuite( t, [][]string{storeTD.AWSStorageCredEnvs, tconfig.M365AcctCredEnvs}), }) } func (suite *AssistBackupIntegrationSuite) SetupSuite() { t := suite.T() repoNameHash := strTD.NewHashForRepoConfigName() ctx, flush := tester.NewContext(t) defer flush() var ( st = storeTD.NewPrefixedS3Storage(t) k = kopia.NewConn(st) ) suite.acct = tconfig.NewM365Account(t) err := k.Initialize(ctx, repository.Options{}, repository.Retention{}, repoNameHash) require.NoError(t, err, clues.ToCore(err)) suite.kopiaCloser = func(ctx context.Context) { k.Close(ctx) } kw, err := kopia.NewWrapper(k) require.NoError(t, err, clues.ToCore(err)) suite.kw = kw ms, err := kopia.NewModelStore(k) require.NoError(t, err, clues.ToCore(err)) suite.ms = ms sw := store.NewWrapper(ms) suite.sw = sw } func (suite *AssistBackupIntegrationSuite) TearDownSuite() { ctx, flush := tester.NewContext(suite.T()) defer flush() if suite.ms != nil { suite.ms.Close(ctx) } if suite.kw != nil { suite.kw.Close(ctx) } if suite.kopiaCloser != nil { suite.kopiaCloser(ctx) } } func makeBackupCollection( p path.Path, locPath *path.Builder, items []dataMock.Item, ) data.BackupCollection { streams := make([]data.Item, len(items)) for i := range items { streams[i] = &items[i] } return &dataMock.Collection{ Path: p, Loc: locPath, ItemData: streams, } } func makeMetadataCollectionEntries( deltaURL, driveID, folderID string, p path.Path, ) []graph.MetadataCollectionEntry { return []graph.MetadataCollectionEntry{ graph.NewMetadataEntry( metadata.DeltaURLsFileName, map[string]string{driveID: deltaURL}), graph.NewMetadataEntry( metadata.PreviousPathFileName, map[string]map[string]string{ driveID: { folderID: p.PlainString(), }, }), } } const ( userID = "user-id" driveID = "drive-id" driveName = "drive-name" folderID = "folder-id" ) func makeMockItem( fileID string, extData *details.ExtensionData, modTime time.Time, del bool, readErr error, ) dataMock.Item { rc := odMock.FileRespReadCloser(odMock.DriveFilePayloadData) if extData != nil { rc = odMock.FileRespWithExtensions(odMock.DriveFilePayloadData, extData) } dmi := dataMock.Item{ DeletedFlag: del, ItemID: fileID, ItemInfo: odStub.DriveItemInfo(), ItemSize: 100, ModifiedTime: modTime, Reader: rc, ReadErr: readErr, } dmi.ItemInfo.OneDrive.DriveID = driveID dmi.ItemInfo.OneDrive.DriveName = driveName dmi.ItemInfo.OneDrive.Modified = modTime dmi.ItemInfo.Extension = extData return dmi } // Check what kind of backup is produced for a given failurePolicy/observed fault // bus combination. // // It's currently using errors generated during mockBackupProducer phase. // Ideally we would test with errors generated in various phases of backup, but // that needs putting produceManifestsAndMetadata and mergeDetails behind mockable // interfaces. // // Note: Tests are incremental since we are reusing kopia repo between tests, // but this is irrelevant here. func (suite *AssistBackupIntegrationSuite) TestBackupTypesForFailureModes() { var ( acct = tconfig.NewM365Account(suite.T()) tenantID = acct.ID() osel = selectors.NewOneDriveBackup([]string{userID}) ) osel.Include(selTD.OneDriveBackupFolderScope(osel)) pathElements := []string{odConsts.DrivesPathDir, "drive-id", odConsts.RootPathDir, folderID} tmp, err := path.Build(tenantID, userID, path.OneDriveService, path.FilesCategory, false, pathElements...) require.NoError(suite.T(), err, clues.ToCore(err)) locPath := path.Builder{}.Append(tmp.Folders()...) table := []struct { name string collFunc func() []data.BackupCollection injectNonRecoverableErr bool failurePolicy control.FailurePolicy previewBackup bool expectRunErr assert.ErrorAssertionFunc expectBackupTag string expectFaults func(t *testing.T, errs *fault.Bus) }{ { name: "fail fast, no errors", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", nil, time.Now(), false, nil), }), } return bc }, failurePolicy: control.FailFast, expectRunErr: assert.NoError, expectBackupTag: model.MergeBackup, expectFaults: func(t *testing.T, errs *fault.Bus) { assert.NoError(t, errs.Failure(), clues.ToCore(errs.Failure())) assert.Empty(t, errs.Recovered(), "recovered errors") }, }, { name: "fail fast, any errors", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", nil, time.Now(), false, assert.AnError), }), } return bc }, failurePolicy: control.FailFast, expectRunErr: assert.Error, expectBackupTag: "", expectFaults: func(t *testing.T, errs *fault.Bus) { assert.Error(t, errs.Failure(), clues.ToCore(errs.Failure())) }, }, { name: "best effort, no errors", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", nil, time.Now(), false, nil), }), } return bc }, failurePolicy: control.BestEffort, expectRunErr: assert.NoError, expectBackupTag: model.MergeBackup, expectFaults: func(t *testing.T, errs *fault.Bus) { assert.NoError(t, errs.Failure(), clues.ToCore(errs.Failure())) assert.Empty(t, errs.Recovered(), "recovered errors") }, }, { name: "best effort, non-recoverable errors", collFunc: func() []data.BackupCollection { return nil }, injectNonRecoverableErr: true, failurePolicy: control.BestEffort, expectRunErr: assert.Error, expectBackupTag: "", expectFaults: func(t *testing.T, errs *fault.Bus) { assert.Error(t, errs.Failure(), clues.ToCore(errs.Failure())) }, }, { name: "best effort, recoverable errors", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", nil, time.Now(), false, assert.AnError), }), } return bc }, failurePolicy: control.BestEffort, expectRunErr: assert.NoError, expectBackupTag: model.MergeBackup, expectFaults: func(t *testing.T, errs *fault.Bus) { assert.NoError(t, errs.Failure(), clues.ToCore(errs.Failure())) assert.Greater(t, len(errs.Recovered()), 0, "recovered errors") }, }, { name: "fail after recovery, no errors", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", nil, time.Now(), false, nil), makeMockItem("file2", nil, time.Now(), false, nil), }), } return bc }, failurePolicy: control.FailAfterRecovery, expectRunErr: assert.NoError, expectBackupTag: model.MergeBackup, expectFaults: func(t *testing.T, errs *fault.Bus) { assert.NoError(t, errs.Failure(), clues.ToCore(errs.Failure())) assert.Empty(t, errs.Recovered(), "recovered errors") }, }, { name: "fail after recovery, non-recoverable errors", collFunc: func() []data.BackupCollection { return nil }, injectNonRecoverableErr: true, failurePolicy: control.FailAfterRecovery, expectRunErr: assert.Error, expectBackupTag: "", expectFaults: func(t *testing.T, errs *fault.Bus) { assert.Error(t, errs.Failure(), clues.ToCore(errs.Failure())) }, }, { name: "fail after recovery, recoverable errors", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", nil, time.Now(), false, nil), makeMockItem("file2", nil, time.Now(), false, assert.AnError), }), } return bc }, failurePolicy: control.FailAfterRecovery, expectRunErr: assert.Error, expectBackupTag: model.AssistBackup, expectFaults: func(t *testing.T, errs *fault.Bus) { assert.Error(t, errs.Failure(), clues.ToCore(errs.Failure())) assert.Greater(t, len(errs.Recovered()), 0, "recovered errors") }, }, { name: "preview, fail after recovery, no errors", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", nil, time.Now(), false, nil), makeMockItem("file2", nil, time.Now(), false, nil), }), } return bc }, failurePolicy: control.FailAfterRecovery, previewBackup: true, expectRunErr: assert.NoError, expectBackupTag: model.PreviewBackup, expectFaults: func(t *testing.T, errs *fault.Bus) { assert.NoError(t, errs.Failure(), clues.ToCore(errs.Failure())) assert.Empty(t, errs.Recovered(), "recovered errors") }, }, { name: "preview, fail after recovery, non-recoverable errors", collFunc: func() []data.BackupCollection { return nil }, injectNonRecoverableErr: true, failurePolicy: control.FailAfterRecovery, previewBackup: true, expectRunErr: assert.Error, expectFaults: func(t *testing.T, errs *fault.Bus) { assert.Error(t, errs.Failure(), clues.ToCore(errs.Failure())) }, }, { name: "preview, fail after recovery, recoverable errors", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", nil, time.Now(), false, nil), makeMockItem("file2", nil, time.Now(), false, assert.AnError), }), } return bc }, failurePolicy: control.FailAfterRecovery, previewBackup: true, expectRunErr: assert.Error, expectFaults: func(t *testing.T, errs *fault.Bus) { assert.Error(t, errs.Failure(), clues.ToCore(errs.Failure())) assert.Greater(t, len(errs.Recovered()), 0, "recovered errors") }, }, } for _, test := range table { suite.Run(test.name, func() { t := suite.T() ctx, flush := tester.NewContext(t) defer flush() cs := test.collFunc() pathPrefix, err := path.BuildMetadata( tenantID, userID, path.OneDriveService, path.FilesCategory, false) require.NoError(t, err, clues.ToCore(err)) mc, err := graph.MakeMetadataCollection( pathPrefix, makeMetadataCollectionEntries("url/1", driveID, folderID, tmp), func(*support.ControllerOperationStatus) {}, count.New()) require.NoError(t, err, clues.ToCore(err)) cs = append(cs, mc) bp := opMock.NewMockBackupProducer(cs, data.CollectionStats{}, test.injectNonRecoverableErr) opts := control.DefaultOptions() opts.FailureHandling = test.failurePolicy opts.PreviewLimits.Enabled = test.previewBackup bo, err := NewBackupOperation( ctx, opts, suite.kw, suite.sw, &bp, acct, osel.Selector, selectors.Selector{DiscreteOwner: userID}, evmock.NewBus(), count.New()) require.NoError(t, err, clues.ToCore(err)) err = bo.Run(ctx) test.expectRunErr(t, err, clues.ToCore(err)) test.expectFaults(t, bo.Errors) if len(test.expectBackupTag) == 0 { return } bID := bo.Results.BackupID require.NotEmpty(t, bID) bup := backup.Backup{} err = suite.ms.Get(ctx, model.BackupSchema, bID, &bup) require.NoError(t, err, clues.ToCore(err)) require.Equal(t, test.expectBackupTag, bup.Tags[model.BackupTypeTag]) }) } } func selectFilesFromDeets(d details.Details) map[string]details.Entry { files := make(map[string]details.Entry) for _, ent := range d.Entries { if ent.Folder != nil { continue } files[ent.ItemRef] = ent } return files } // TestExtensionsIncrementals tests presence of corso extension data in details // Note that since we are mocking out backup producer here, corso extensions can't be // attached as they would in prod. However, this is fine here, since we are more interested // in testing whether deets get carried over correctly for various scenarios. func (suite *AssistBackupIntegrationSuite) TestExtensionsIncrementals() { var ( acct = tconfig.NewM365Account(suite.T()) tenantID = acct.ID() opts = control.DefaultOptions() osel = selectors.NewOneDriveBackup([]string{userID}) // Default policy used by SDK clients failurePolicy = control.FailAfterRecovery T1 = time.Now().Truncate(0) T2 = T1.Add(time.Hour).Truncate(0) T3 = T2.Add(time.Hour).Truncate(0) extData = make(map[int]*details.ExtensionData) ) for i := 0; i < 3; i++ { d := make(map[string]any) extData[i] = &details.ExtensionData{ Data: d, } } osel.Include(selTD.OneDriveBackupFolderScope(osel)) sss := streamstore.NewStreamer( suite.kw, suite.acct.ID(), osel.PathService()) pathElements := []string{odConsts.DrivesPathDir, "drive-id", odConsts.RootPathDir, folderID} tmp, err := path.Build(tenantID, userID, path.OneDriveService, path.FilesCategory, false, pathElements...) require.NoError(suite.T(), err, clues.ToCore(err)) locPath := path.Builder{}.Append(tmp.Folders()...) table := []struct { name string collFunc func() []data.BackupCollection expectRunErr assert.ErrorAssertionFunc validateDeets func(t *testing.T, gotDeets details.Details) }{ { name: "Assist backup, 1 new deets", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", extData[0], T1, false, nil), makeMockItem("file2", extData[1], T1, false, assert.AnError), }), } return bc }, expectRunErr: assert.Error, validateDeets: func(t *testing.T, d details.Details) { files := selectFilesFromDeets(d) require.Len(t, files, 1) f := files["file1"] require.NotNil(t, f) require.True(t, T1.Equal(f.Modified())) require.NotZero(t, f.Extension.Data[extensions.KNumBytes]) }, }, { name: "Assist backup after assist backup, 1 existing, 1 new deets", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", extData[0], T1, false, nil), makeMockItem("file2", extData[1], T2, false, nil), makeMockItem("file3", extData[2], T2, false, assert.AnError), }), } return bc }, expectRunErr: assert.Error, validateDeets: func(t *testing.T, d details.Details) { files := selectFilesFromDeets(d) require.Len(t, files, 2) for _, f := range files { switch f.ItemRef { case "file1": require.True(t, T1.Equal(f.Modified())) require.NotZero(t, f.Extension.Data[extensions.KNumBytes]) case "file2": require.True(t, T2.Equal(f.Modified())) require.NotZero(t, f.Extension.Data[extensions.KNumBytes]) default: require.Fail(t, "unexpected file", f.ItemRef) } } }, }, { name: "Merge backup, 2 existing deets, 1 new deet", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", extData[0], T1, false, nil), makeMockItem("file2", extData[1], T2, false, nil), makeMockItem("file3", extData[2], T3, false, nil), }), } return bc }, expectRunErr: assert.NoError, validateDeets: func(t *testing.T, d details.Details) { files := selectFilesFromDeets(d) require.Len(t, files, 3) for _, f := range files { switch f.ItemRef { case "file1": require.True(t, T1.Equal(f.Modified())) require.NotZero(t, f.Extension.Data[extensions.KNumBytes]) case "file2": require.True(t, T2.Equal(f.Modified())) require.NotZero(t, f.Extension.Data[extensions.KNumBytes]) case "file3": require.True(t, T3.Equal(f.Modified())) require.NotZero(t, f.Extension.Data[extensions.KNumBytes]) default: require.Fail(t, "unexpected file", f.ItemRef) } } }, }, { // Reset state so we can reuse the same test data name: "All files deleted", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", extData[0], T1, true, nil), makeMockItem("file2", extData[1], T2, true, nil), makeMockItem("file3", extData[2], T3, true, nil), }), } return bc }, expectRunErr: assert.NoError, validateDeets: func(t *testing.T, d details.Details) { files := selectFilesFromDeets(d) require.Len(t, files, 0) }, }, { name: "Merge backup, 1 new deets", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", extData[0], T1, false, nil), }), } return bc }, expectRunErr: assert.NoError, validateDeets: func(t *testing.T, d details.Details) { files := selectFilesFromDeets(d) require.Len(t, files, 1) for _, f := range files { switch f.ItemRef { case "file1": require.True(t, T1.Equal(f.Modified())) require.NotZero(t, f.Extension.Data[extensions.KNumBytes]) default: require.Fail(t, "unexpected file", f.ItemRef) } } }, }, // This test fails currently, need to rerun with Ashlie's PR. { name: "Assist backup after merge backup, 1 new deets, 1 existing deet", collFunc: func() []data.BackupCollection { bc := []data.BackupCollection{ makeBackupCollection( tmp, locPath, []dataMock.Item{ makeMockItem("file1", extData[0], T1, false, nil), makeMockItem("file2", extData[1], T2, false, nil), makeMockItem("file3", extData[2], T3, false, assert.AnError), }), } return bc }, expectRunErr: assert.Error, validateDeets: func(t *testing.T, d details.Details) { files := selectFilesFromDeets(d) require.Len(t, files, 2) for _, f := range files { switch f.ItemRef { case "file1": require.True(t, T1.Equal(f.Modified())) require.NotZero(t, f.Extension.Data[extensions.KNumBytes]) case "file2": require.True(t, T2.Equal(f.Modified())) require.NotZero(t, f.Extension.Data[extensions.KNumBytes]) default: require.Fail(t, "unexpected file", f.ItemRef) } } }, }, // TODO(pandeyabs): Remaining tests. // 1. Deets updated in assist backup. Following backup should have updated deets. // 2. Concurrent overlapping reasons. } for _, test := range table { suite.Run(test.name, func() { t := suite.T() ctx, flush := tester.NewContext(t) defer flush() cs := test.collFunc() pathPrefix, err := path.BuildMetadata( tenantID, userID, path.OneDriveService, path.FilesCategory, false) require.NoError(t, err, clues.ToCore(err)) mc, err := graph.MakeMetadataCollection( pathPrefix, makeMetadataCollectionEntries("url/1", driveID, folderID, tmp), func(*support.ControllerOperationStatus) {}, count.New()) require.NoError(t, err, clues.ToCore(err)) cs = append(cs, mc) bp := opMock.NewMockBackupProducer(cs, data.CollectionStats{}, false) opts.FailureHandling = failurePolicy bo, err := NewBackupOperation( ctx, opts, suite.kw, suite.sw, &bp, acct, osel.Selector, selectors.Selector{DiscreteOwner: userID}, evmock.NewBus(), count.New()) require.NoError(t, err, clues.ToCore(err)) err = bo.Run(ctx) test.expectRunErr(t, err, clues.ToCore(err)) assert.NotEmpty(t, bo.Results.BackupID) deets, _ := deeTD.GetDeetsInBackup( t, ctx, bo.Results.BackupID, tenantID, userID, path.OneDriveService, deeTD.DriveIDFromRepoRef, suite.ms, sss) assert.NotNil(t, deets) test.validateDeets(t, deets) // Clear extension data between test runs for i := 0; i < 3; i++ { d := make(map[string]any) extData[i] = &details.ExtensionData{ Data: d, } } }) } }