From b0305a53195dbf5d42404714d12c206fdd5202b1 Mon Sep 17 00:00:00 2001 From: Abin Simon Date: Thu, 22 Jun 2023 19:07:05 +0530 Subject: [PATCH] Update attachments for events which drift from series master (#3644) This updates any changes to attachments for individual event instances for the ones that differ from the series master. --- #### Does this PR need a docs update or release note? - [x] :white_check_mark: Yes, it's included - [ ] :clock1: Yes, but in a later PR - [ ] :no_entry: No #### Type of change - [x] :sunflower: Feature - [ ] :bug: Bugfix - [ ] :world_map: Documentation - [ ] :robot: Supportability/Tests - [ ] :computer: CI/Deployment - [ ] :broom: Tech Debt/Cleanup #### Issue(s) * fixes https://github.com/alcionai/corso/issues/2835 #### Test Plan - [ ] :muscle: Manual - [ ] :zap: Unit test - [x] :green_heart: E2E --- CHANGELOG.md | 3 - src/cmd/factory/impl/exchange.go | 2 +- src/internal/m365/exchange/events_restore.go | 159 ++++++++++++++++-- src/internal/m365/exchange/mock/event.go | 131 ++++++++++----- src/internal/m365/exchange/restore_test.go | 71 ++++++++ .../operations/backup_integration_test.go | 2 +- src/pkg/services/m365/api/events.go | 45 +++-- website/docs/support/known-issues.md | 2 - 8 files changed, 338 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b73ade67..834f0b374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Do not display all the items that we restored at the end if there are more than 15. You can override this with `--verbose`. -### Known Issues -- Changes to attachments in instances of recurring events compared to the series master aren't restored - ## [v0.8.0] (beta) - 2023-05-15 ### Added diff --git a/src/cmd/factory/impl/exchange.go b/src/cmd/factory/impl/exchange.go index 2c7f0f4e3..bf8450da2 100644 --- a/src/cmd/factory/impl/exchange.go +++ b/src/cmd/factory/impl/exchange.go @@ -116,7 +116,7 @@ func handleExchangeCalendarEventFactory(cmd *cobra.Command, args []string) error User, subject, body, body, exchMock.NoOriginalStartDate, now, now, exchMock.NoRecurrence, exchMock.NoAttendees, - false, exchMock.NoCancelledOccurrences, + exchMock.NoAttachments, exchMock.NoCancelledOccurrences, exchMock.NoExceptionOccurrences) }, control.Defaults(), diff --git a/src/internal/m365/exchange/events_restore.go b/src/internal/m365/exchange/events_restore.go index fcb1273a0..cb288771e 100644 --- a/src/internal/m365/exchange/events_restore.go +++ b/src/internal/m365/exchange/events_restore.go @@ -1,6 +1,7 @@ package exchange import ( + "bytes" "context" "fmt" "strings" @@ -15,6 +16,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" "github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/services/m365/api" ) @@ -117,7 +119,15 @@ func (h eventRestoreHandler) restore( } // Fix up event instances in case we have a recurring event - err = updateRecurringEvents(ctx, h.ac, userID, destinationID, ptr.Val(item.GetId()), event) + err = updateRecurringEvents( + ctx, + h.ac, + userID, + destinationID, + ptr.Val(item.GetId()), + event, + errs, + ) if err != nil { return nil, clues.Stack(err) } @@ -133,6 +143,7 @@ func updateRecurringEvents( ac api.Events, userID, containerID, itemID string, event models.Eventable, + errs *fault.Bus, ) error { if event.GetRecurrence() == nil { return nil @@ -149,7 +160,7 @@ func updateRecurringEvents( return clues.Wrap(err, "update cancelled occurrences") } - err = updateExceptionOccurrences(ctx, ac, userID, itemID, exceptionOccurrences) + err = updateExceptionOccurrences(ctx, ac, userID, containerID, itemID, exceptionOccurrences, errs) if err != nil { return clues.Wrap(err, "update exception occurrences") } @@ -164,8 +175,10 @@ func updateExceptionOccurrences( ctx context.Context, ac api.Events, userID string, + containerID string, itemID string, exceptionOccurrences any, + errs *fault.Bus, ) error { if exceptionOccurrences == nil { return nil @@ -193,9 +206,11 @@ func updateExceptionOccurrences( startStr := dttm.FormatTo(start, dttm.DateOnly) endStr := dttm.FormatTo(start.Add(24*time.Hour), dttm.DateOnly) + ictx := clues.Add(ctx, "event_instance_id", ptr.Val(evt.GetId()), "event_instance_date", start) + // Get all instances on the day of the instance which should // just the one we need to modify - evts, err := ac.GetItemInstances(ctx, userID, itemID, startStr, endStr) + instances, err := ac.GetItemInstances(ictx, userID, itemID, startStr, endStr) if err != nil { return clues.Wrap(err, "getting instances") } @@ -203,27 +218,141 @@ func updateExceptionOccurrences( // Since the min recurrence interval is 1 day and we are // querying for only a single day worth of instances, we // should not have more than one instance here. - if len(evts) != 1 { + if len(instances) != 1 { return clues.New("invalid number of instances for modified"). - With("instances_count", len(evts), "search_start", startStr, "search_end", endStr) + With("instances_count", len(instances), "search_start", startStr, "search_end", endStr) } evt = toEventSimplified(evt) - // TODO(meain): Update attachments (might have to diff the - // attachments using ids and delete or add). We will have - // to get the id of the existing attachments, diff them - // with what we need a then create/delete items kinda like - // permissions - _, err = ac.PatchItem(ctx, userID, ptr.Val(evts[0].GetId()), evt) + _, err = ac.PatchItem(ictx, userID, ptr.Val(instances[0].GetId()), evt) if err != nil { return clues.Wrap(err, "updating event instance") } + + // We are creating event again from map as `toEventSimplified` + // removed the attachments and creating a clone from start of + // the event is non-trivial + evt, err = api.EventFromMap(instance) + if err != nil { + return clues.Wrap(err, "parsing event instance") + } + + err = updateAttachments(ictx, ac, userID, containerID, ptr.Val(instances[0].GetId()), evt, errs) + if err != nil { + return clues.Wrap(err, "updating event instance attachments") + } } return nil } +// updateAttachments updates the attachments of an event to match what +// is present in the backed up event. Ideally we could make use of the +// id of the series master event's attachments to see if we had +// added/removed any attachments, but as soon an event is modified, +// the id changes which makes the ids unusable. In this function, we +// use the name and content bytes to detect the changes. This function +// can be used to update the attachments of any event irrespective of +// whether they are event instances of a series master although for +// newer event, since we probably won't already have any events it +// would be better use Post[Small|Large]Attachment. +func updateAttachments( + ctx context.Context, + client api.Events, + userID, containerID, eventID string, + event models.Eventable, + errs *fault.Bus, +) error { + el := errs.Local() + + attachments, err := client.GetAttachments(ctx, false, userID, eventID) + if err != nil { + return clues.Wrap(err, "getting attachments") + } + + // Delete attachments that are not present in the backup but are + // present in the event(ones that were automatically inherited + // from series master). + for _, att := range attachments { + if el.Failure() != nil { + return el.Failure() + } + + name := ptr.Val(att.GetName()) + id := ptr.Val(att.GetId()) + + content, err := api.GetAttachmentContent(att) + if err != nil { + return clues.Wrap(err, "getting attachment").With("attachment_id", id) + } + + found := false + + for _, nAtt := range event.GetAttachments() { + nName := ptr.Val(nAtt.GetName()) + + nContent, err := api.GetAttachmentContent(nAtt) + if err != nil { + return clues.Wrap(err, "getting attachment").With("attachment_id", ptr.Val(nAtt.GetId())) + } + + if name == nName && bytes.Equal(content, nContent) { + found = true + break + } + } + + if !found { + err = client.DeleteAttachment(ctx, userID, containerID, eventID, id) + if err != nil { + logger.CtxErr(ctx, err).With("attachment_name", name).Info("attachment delete failed") + el.AddRecoverable(ctx, clues.Wrap(err, "deleting event attachment"). + WithClues(ctx).With("attachment_name", name)) + } + } + } + + // Upload missing(attachments that are present in the individual + // instance but not in the series master event) attachments + for _, att := range event.GetAttachments() { + name := ptr.Val(att.GetName()) + id := ptr.Val(att.GetId()) + + content, err := api.GetAttachmentContent(att) + if err != nil { + return clues.Wrap(err, "getting attachment").With("attachment_id", id) + } + + found := false + + for _, nAtt := range attachments { + nName := ptr.Val(nAtt.GetName()) + + bContent, err := api.GetAttachmentContent(nAtt) + if err != nil { + return clues.Wrap(err, "getting attachment").With("attachment_id", ptr.Val(nAtt.GetId())) + } + + // Max size allowed for an outlook attachment is 150MB + if name == nName && bytes.Equal(content, bContent) { + found = true + break + } + } + + if !found { + err = uploadAttachment(ctx, client, userID, containerID, eventID, att) + if err != nil { + return clues.Wrap(err, "uploading attachment"). + With("attachment_id", id) + } + } + } + + return el.Failure() +} + // updateCancelledOccurrences get the cancelled occurrences which is a // list of strings of the format ".", parses the date out of // that and uses the to get the event instance at that date to delete. @@ -266,7 +395,7 @@ func updateCancelledOccurrences( // Get all instances on the day of the instance which should // just the one we need to modify - evts, err := ac.GetItemInstances(ctx, userID, itemID, startStr, endStr) + instances, err := ac.GetItemInstances(ctx, userID, itemID, startStr, endStr) if err != nil { return clues.Wrap(err, "getting instances") } @@ -274,12 +403,12 @@ func updateCancelledOccurrences( // Since the min recurrence interval is 1 day and we are // querying for only a single day worth of instances, we // should not have more than one instance here. - if len(evts) != 1 { + if len(instances) != 1 { return clues.New("invalid number of instances for cancelled"). - With("instances_count", len(evts), "search_start", startStr, "search_end", endStr) + With("instances_count", len(instances), "search_start", startStr, "search_end", endStr) } - err = ac.DeleteItem(ctx, userID, ptr.Val(evts[0].GetId())) + err = ac.DeleteItem(ctx, userID, ptr.Val(instances[0].GetId())) if err != nil { return clues.Wrap(err, "deleting event instance") } diff --git a/src/internal/m365/exchange/mock/event.go b/src/internal/m365/exchange/mock/event.go index d5711b7ce..bc7be481c 100644 --- a/src/internal/m365/exchange/mock/event.go +++ b/src/internal/m365/exchange/mock/event.go @@ -23,7 +23,7 @@ import ( // 10. attendees //nolint:lll -const ( +var ( eventTmpl = `{ "categories":[], "changeKey":"0hATW1CAfUS+njw3hdxSGAAAJIxNug==", @@ -98,8 +98,10 @@ const ( defaultEventBody = "This meeting is to review the latest Tailspin Toys project proposal.
\\r\\nBut why not eat some sushi while we’re at it? :)" defaultEventBodyPreview = "This meeting is to review the latest Tailspin Toys project proposal.\\r\\nBut why not eat some sushi while we’re at it? :)" defaultEventOrganizer = "foobar@8qzvrj.onmicrosoft.com" - eventAttachment = "\"attachments\":[{\"id\":\"AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwBGAAAAAADCNgjhM9QmQYWNcI7hCpPrBwDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAADSEBNbUIB9RL6ePDeF3FIYAACLjfLQAAABEgAQAHoI0xBbBBVEh6bFMU78ZUo=\",\"@odata.type\":\"#microsoft.graph.fileAttachment\"," + - "\"@odata.mediaContentType\":\"application/octet-stream\",\"contentType\":\"application/octet-stream\",\"isInline\":false,\"lastModifiedDateTime\":\"2022-10-26T15:19:42Z\",\"name\":\"database.db\",\"size\":11418," + + + NoAttachments = "" + eventAttachmentFormat = "{\"id\":\"AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwBGAAAAAADCNgjhM9QmQYWNcI7hCpPrBwDSEBNbUIB9RL6ePDeF3FIYAAAAAAENAADSEBNbUIB9RL6ePDeF3FIYAACLjfLQAAABEgAQAHoI0xBbBBVEh6bFMU78ZUo=\",\"@odata.type\":\"#microsoft.graph.fileAttachment\"," + + "\"@odata.mediaContentType\":\"application/octet-stream\",\"contentType\":\"application/octet-stream\",\"isInline\":false,\"lastModifiedDateTime\":\"2022-10-26T15:19:42Z\",\"name\":\"%s\",\"size\":11418," + "\"contentBytes\":\"U1FMaXRlIGZvcm1hdCAzAAQAAQEAQCAgAAAATQAAAAsAAAAEAAAACAAAAAsAAAAEAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABNAC3mBw0DZwACAg8AAxUCDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCAwMHFxUVAYNpdGFibGVkYXRhZGF0YQJDUkVBVEUgVEFCTEUgZGF0YSAoCiAgICAgICAgIGlkIGludGVnZXIgcHJpbWFyeSBrZXkgYXV0b2luY3JlbWVudCwKICAgICAgICAgbWVhbiB0ZXh0IG5vdCBudWxsLAogICAgICAgICBtYXggdGV4dCBub3QgbnVsbCwKICAgICAgICAgbWluIHRleHQgbm90IG51bGwsCiAgICAgICAgIGRhdGEgdGV" + @@ -146,7 +148,8 @@ const ( "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"}]," + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"}" + defaultEventAttachments = "\"attachments\":[" + fmt.Sprintf(eventAttachmentFormat, "database.db") + "]," originalStartDateFormat = `"originalStart": "%s",` NoOriginalStartDate = `` @@ -226,37 +229,43 @@ func EventBytes(subject string) []byte { } func EventWithSubjectBytes(subject string) []byte { - tomorrow := time.Now().UTC().AddDate(0, 0, 1) - at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) - atTime := dttm.Format(at) - endTime := dttm.Format(at.Add(30 * time.Minute)) + var ( + tomorrow = time.Now().UTC().AddDate(0, 0, 1) + at = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) + atTime = dttm.Format(at) + endTime = dttm.Format(at.Add(30 * time.Minute)) + ) return EventWith( defaultEventOrganizer, subject, defaultEventBody, defaultEventBodyPreview, NoOriginalStartDate, atTime, endTime, NoRecurrence, NoAttendees, - false, NoCancelledOccurrences, NoExceptionOccurrences, + NoAttachments, NoCancelledOccurrences, NoExceptionOccurrences, ) } func EventWithAttachment(subject string) []byte { - tomorrow := time.Now().UTC().AddDate(0, 0, 1) - at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) - atTime := dttm.Format(at) + var ( + tomorrow = time.Now().UTC().AddDate(0, 0, 1) + at = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) + atTime = dttm.Format(at) + ) return EventWith( defaultEventOrganizer, subject, defaultEventBody, defaultEventBodyPreview, NoOriginalStartDate, atTime, atTime, NoRecurrence, NoAttendees, - true, NoCancelledOccurrences, NoExceptionOccurrences, + defaultEventAttachments, NoCancelledOccurrences, NoExceptionOccurrences, ) } func EventWithRecurrenceBytes(subject, recurrenceTimeZone string) []byte { - tomorrow := time.Now().UTC().AddDate(0, 0, 1) - at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) - atTime := dttm.Format(at) - timeSlice := strings.Split(atTime, "T") + var ( + tomorrow = time.Now().UTC().AddDate(0, 0, 1) + at = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) + atTime = dttm.Format(at) + timeSlice = strings.Split(atTime, "T") + ) recurrence := string(fmt.Sprintf( recurrenceTmpl, @@ -270,16 +279,18 @@ func EventWithRecurrenceBytes(subject, recurrenceTimeZone string) []byte { defaultEventOrganizer, subject, defaultEventBody, defaultEventBodyPreview, NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl, - true, NoCancelledOccurrences, NoExceptionOccurrences, + NoAttachments, NoCancelledOccurrences, NoExceptionOccurrences, ) } func EventWithRecurrenceAndCancellationBytes(subject string) []byte { - tomorrow := time.Now().UTC().AddDate(0, 0, 1) - at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) - atTime := dttm.Format(at) - timeSlice := strings.Split(atTime, "T") - nextYear := tomorrow.AddDate(1, 0, 0) + var ( + tomorrow = time.Now().UTC().AddDate(0, 0, 1) + at = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) + atTime = dttm.Format(at) + timeSlice = strings.Split(atTime, "T") + nextYear = tomorrow.AddDate(1, 0, 0) + ) recurrence := string(fmt.Sprintf( recurrenceTmpl, @@ -296,17 +307,19 @@ func EventWithRecurrenceAndCancellationBytes(subject string) []byte { defaultEventOrganizer, subject, defaultEventBody, defaultEventBodyPreview, NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl, - true, cancelledOccurrences, NoExceptionOccurrences, + defaultEventAttachments, cancelledOccurrences, NoExceptionOccurrences, ) } func EventWithRecurrenceAndExceptionBytes(subject string) []byte { - tomorrow := time.Now().UTC().AddDate(0, 0, 1) - at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) - atTime := dttm.Format(at) - timeSlice := strings.Split(atTime, "T") - newTime := dttm.Format(tomorrow.AddDate(0, 0, 1)) - originalStartDate := dttm.FormatTo(at, dttm.TabularOutput) + var ( + tomorrow = time.Now().UTC().AddDate(0, 0, 1) + at = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) + atTime = dttm.Format(at) + timeSlice = strings.Split(atTime, "T") + newTime = dttm.Format(tomorrow.AddDate(0, 0, 1)) + originalStartDate = dttm.FormatTo(at, dttm.TabularOutput) + ) recurrence := string(fmt.Sprintf( recurrenceTmpl, @@ -321,7 +334,43 @@ func EventWithRecurrenceAndExceptionBytes(subject string) []byte { defaultEventBody, defaultEventBodyPreview, fmt.Sprintf(originalStartDateFormat, originalStartDate), newTime, newTime, NoRecurrence, attendeesTmpl, - false, NoCancelledOccurrences, NoExceptionOccurrences, + NoAttachments, NoCancelledOccurrences, NoExceptionOccurrences, + ) + exceptionOccurrences := fmt.Sprintf(exceptionOccurrencesFormat, exceptionEvent) + + return EventWith( + defaultEventOrganizer, subject, + defaultEventBody, defaultEventBodyPreview, + NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl, + defaultEventAttachments, NoCancelledOccurrences, exceptionOccurrences, + ) +} + +func EventWithRecurrenceAndExceptionAndAttachmentBytes(subject string) []byte { + var ( + tomorrow = time.Now().UTC().AddDate(0, 0, 1) + at = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) + atTime = dttm.Format(at) + timeSlice = strings.Split(atTime, "T") + newTime = dttm.Format(tomorrow.AddDate(0, 0, 1)) + originalStartDate = dttm.FormatTo(at, dttm.TabularOutput) + ) + + recurrence := string(fmt.Sprintf( + recurrenceTmpl, + strconv.Itoa(int(at.Month())), + strconv.Itoa(at.Day()), + timeSlice[0], + `"UTC"`, + )) + + exceptionEvent := EventWith( + defaultEventOrganizer, subject+"(modified)", + defaultEventBody, defaultEventBodyPreview, + fmt.Sprintf(originalStartDateFormat, originalStartDate), + newTime, newTime, NoRecurrence, attendeesTmpl, + "\"attachments\":["+fmt.Sprintf(eventAttachmentFormat, "exception-database.db")+"],", + NoCancelledOccurrences, NoExceptionOccurrences, ) exceptionOccurrences := fmt.Sprintf( exceptionOccurrencesFormat, @@ -332,20 +381,22 @@ func EventWithRecurrenceAndExceptionBytes(subject string) []byte { defaultEventOrganizer, subject, defaultEventBody, defaultEventBodyPreview, NoOriginalStartDate, atTime, atTime, recurrence, attendeesTmpl, - true, NoCancelledOccurrences, exceptionOccurrences, + defaultEventAttachments, NoCancelledOccurrences, exceptionOccurrences, ) } func EventWithAttendeesBytes(subject string) []byte { - tomorrow := time.Now().UTC().AddDate(0, 0, 1) - at := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) - atTime := dttm.Format(at) + var ( + tomorrow = time.Now().UTC().AddDate(0, 0, 1) + at = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), tomorrow.Hour(), 0, 0, 0, time.UTC) + atTime = dttm.Format(at) + ) return EventWith( defaultEventOrganizer, subject, defaultEventBody, defaultEventBodyPreview, NoOriginalStartDate, atTime, atTime, NoRecurrence, attendeesTmpl, - true, NoCancelledOccurrences, NoExceptionOccurrences, + defaultEventAttachments, NoCancelledOccurrences, NoExceptionOccurrences, ) } @@ -357,13 +408,9 @@ func EventWithAttendeesBytes(subject string) []byte { func EventWith( organizer, subject, body, bodyPreview, originalStartDate, startDateTime, endDateTime, recurrence, attendees string, - hasAttachments bool, cancelledOccurrences, exceptionOccurrences string, + attachments string, cancelledOccurrences, exceptionOccurrences string, ) []byte { - var attachments string - if hasAttachments { - attachments = eventAttachment - } - + hasAttachments := len(attachments) > 0 startDateTime = strings.TrimSuffix(startDateTime, "Z") endDateTime = strings.TrimSuffix(endDateTime, "Z") diff --git a/src/internal/m365/exchange/restore_test.go b/src/internal/m365/exchange/restore_test.go index e94c7670b..370e6e879 100644 --- a/src/internal/m365/exchange/restore_test.go +++ b/src/internal/m365/exchange/restore_test.go @@ -124,6 +124,10 @@ func (suite *RestoreIntgSuite) TestRestoreEvent() { name: "Test exceptionOccurrences", bytes: exchMock.EventWithRecurrenceAndExceptionBytes(subject), }, + { + name: "Test exceptionOccurrences with different attachments", + bytes: exchMock.EventWithRecurrenceAndExceptionAndAttachmentBytes(subject), + }, } for _, test := range tests { @@ -369,3 +373,70 @@ func (suite *RestoreIntgSuite) TestRestoreExchangeObject() { }) } } + +func (suite *RestoreIntgSuite) TestRestoreAndBackupEvent_recurringInstancesWithAttachments() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + var ( + userID = tester.M365UserID(t) + subject = testdata.DefaultRestoreConfig("event").Location + handler = newEventRestoreHandler(suite.ac) + ) + + calendar, err := handler.ac.CreateContainer(ctx, userID, subject, "") + require.NoError(t, err, clues.ToCore(err)) + + calendarID := ptr.Val(calendar.GetId()) + + bytes := exchMock.EventWithRecurrenceAndExceptionAndAttachmentBytes("Reoccurring event restore and backup test") + info, err := handler.restore( + ctx, + bytes, + userID, calendarID, + fault.New(true)) + require.NoError(t, err, clues.ToCore(err)) + assert.NotNil(t, info, "event item info") + + ec, err := handler.ac.Stable. + Client(). + Users(). + ByUserId(userID). + Calendars(). + ByCalendarId(calendarID). + Events(). + Get(ctx, nil) + require.NoError(t, err, clues.ToCore(err)) + + evts := ec.GetValue() + assert.Len(t, evts, 1, "count of events") + + sp, info, err := suite.ac.Events().GetItem(ctx, userID, ptr.Val(evts[0].GetId()), false, fault.New(true)) + require.NoError(t, err, clues.ToCore(err)) + assert.NotNil(t, info, "event item info") + + body, err := suite.ac.Events().Serialize(ctx, sp, userID, ptr.Val(evts[0].GetId())) + require.NoError(t, err, clues.ToCore(err)) + + event, err := api.BytesToEventable(body) + require.NoError(t, err, clues.ToCore(err)) + + assert.NotNil(t, event.GetRecurrence(), "recurrence") + eo := event.GetAdditionalData()["exceptionOccurrences"] + assert.NotNil(t, eo, "exceptionOccurrences") + + assert.NotEqual( + t, + ptr.Val(event.GetSubject()), + ptr.Val(eo.([]any)[0].(map[string]any)["subject"].(*string)), + "name equal") + + atts := eo.([]any)[0].(map[string]any)["attachments"] + assert.NotEqual( + t, + ptr.Val(event.GetAttachments()[0].GetName()), + ptr.Val(atts.([]any)[0].(map[string]any)["name"].(*string)), + "attachment name equal") +} diff --git a/src/internal/operations/backup_integration_test.go b/src/internal/operations/backup_integration_test.go index 0c657a56c..aa1a42ce8 100644 --- a/src/internal/operations/backup_integration_test.go +++ b/src/internal/operations/backup_integration_test.go @@ -815,7 +815,7 @@ func testExchangeContinuousBackups(suite *BackupOpIntegrationSuite, toggles cont suite.user, subject, body, body, exchMock.NoOriginalStartDate, now, now, exchMock.NoRecurrence, exchMock.NoAttendees, - false, exchMock.NoCancelledOccurrences, + exchMock.NoAttachments, exchMock.NoCancelledOccurrences, exchMock.NoExceptionOccurrences) } diff --git a/src/pkg/services/m365/api/events.go b/src/pkg/services/m365/api/events.go index fff4a35cc..5882df306 100644 --- a/src/pkg/services/m365/api/events.go +++ b/src/pkg/services/m365/api/events.go @@ -241,9 +241,12 @@ func (c Events) GetItem( return nil, nil, clues.Wrap(err, "fixup exception occurrences") } - attachments, err := c.getAttachments(ctx, event, immutableIDs, userID, itemID) - if err != nil { - return nil, nil, err + var attachments []models.Attachmentable + if ptr.Val(event.GetHasAttachments()) || HasAttachments(event.GetBody()) { + attachments, err = c.GetAttachments(ctx, immutableIDs, userID, itemID) + if err != nil { + return nil, nil, err + } } event.SetAttachments(attachments) @@ -286,10 +289,14 @@ func fixupExceptionOccurrences( // OPTIMIZATION: We don't have to store any of the // attachments that carry over from the original - attachments, err := client.getAttachments(ctx, evt, immutableIDs, userID, ptr.Val(evt.GetId())) - if err != nil { - return clues.Wrap(err, "getting exception attachments"). - With("exception_event_id", ptr.Val(evt.GetId())) + + var attachments []models.Attachmentable + if ptr.Val(event.GetHasAttachments()) || HasAttachments(event.GetBody()) { + attachments, err = client.GetAttachments(ctx, immutableIDs, userID, ptr.Val(evt.GetId())) + if err != nil { + return clues.Wrap(err, "getting event instance attachments"). + With("event_instance_id", ptr.Val(evt.GetId())) + } } // This odd roundabout way of doing this is required as @@ -370,17 +377,12 @@ func parseableToMap(att serialization.Parsable) (map[string]any, error) { return item, nil } -func (c Events) getAttachments( +func (c Events) GetAttachments( ctx context.Context, - event models.Eventable, immutableIDs bool, userID string, itemID string, ) ([]models.Attachmentable, error) { - if !ptr.Val(event.GetHasAttachments()) && !HasAttachments(event.GetBody()) { - return nil, nil - } - config := &users.ItemEventsItemAttachmentsRequestBuilderGetRequestConfiguration{ QueryParameters: &users.ItemEventsItemAttachmentsRequestBuilderGetQueryParameters{ Expand: []string{"microsoft.graph.itemattachment/item"}, @@ -403,6 +405,23 @@ func (c Events) getAttachments( return attached.GetValue(), nil } +func (c Events) DeleteAttachment( + ctx context.Context, + userID, calendarID, eventID, attachmentID string, +) error { + return c.Stable. + Client(). + Users(). + ByUserId(userID). + Calendars(). + ByCalendarId(calendarID). + Events(). + ByEventId(eventID). + Attachments(). + ByAttachmentId(attachmentID). + Delete(ctx, nil) +} + func (c Events) GetItemInstances( ctx context.Context, userID, itemID string, diff --git a/website/docs/support/known-issues.md b/website/docs/support/known-issues.md index 3b368f256..6f98071fb 100644 --- a/website/docs/support/known-issues.md +++ b/website/docs/support/known-issues.md @@ -26,5 +26,3 @@ included in backup and restore. * SharePoint document library data can't be restored after the library has been deleted. * Sharing information of items in OneDrive/SharePoint using sharing links aren't backed up and restored. - -* Changes to attachments in instances of recurring events compared to the series master aren't restored