diff --git a/ee/server/calendar/google_calendar.go b/ee/server/calendar/google_calendar.go index ce1e9cfb9a..53e189e24b 100644 --- a/ee/server/calendar/google_calendar.go +++ b/ee/server/calendar/google_calendar.go @@ -111,7 +111,14 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) GetEvent(id, eTag string) (*calend func (lowLevelAPI *GoogleCalendarLowLevelAPI) ListEvents(timeMin, timeMax string) (*calendar.Events, error) { // Default maximum number of events returned is 250, which should be sufficient for most calendars. - return lowLevelAPI.service.Events.List(calendarID).EventTypes("default").OrderBy("startTime").SingleEvents(true).TimeMin(timeMin).TimeMax(timeMax).Do() + return lowLevelAPI.service.Events.List(calendarID). + EventTypes("default"). + OrderBy("startTime"). + SingleEvents(true). + TimeMin(timeMin). + TimeMax(timeMax). + ShowDeleted(false). + Do() } func (lowLevelAPI *GoogleCalendarLowLevelAPI) DeleteEvent(id string) error { @@ -130,9 +137,7 @@ func (c *GoogleCalendar) Configure(userEmail string) error { } func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func() string) (*fleet.CalendarEvent, bool, error) { - if event.EndTime.Before(time.Now()) { - return nil, false, ctxerr.Errorf(c.config.Context, "cannot get and update an event that has already ended: %s", event.EndTime) - } + // We assume that the Fleet event has not already ended. We will simply return it if it has not been modified. details, err := c.unmarshalDetails(event) if err != nil { return nil, false, err @@ -143,6 +148,7 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn // http.StatusNotModified is returned sometimes, but not always, so we need to check ETag explicitly later case googleapi.IsNotModified(err): return event, false, nil + // http.StatusNotFound should be very rare -- Google keeps events for a while after they are deleted case isNotFound(err): deleted = true case err != nil: @@ -153,21 +159,50 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn // Event was not modified return event, false, nil } - endTime, err := time.Parse(time.RFC3339, gEvent.End.DateTime) - if err != nil { - return nil, false, ctxerr.Wrap( - c.config.Context, err, fmt.Sprintf("parsing Google calendar event end time: %s", gEvent.End.DateTime), - ) + if gEvent.End == nil || (gEvent.End.DateTime == "" && gEvent.End.Date == "") { + // We should not see this error. If we do, we can work around by treating event as deleted. + return nil, false, ctxerr.Errorf(c.config.Context, "missing end date/time for Google calendar event: %s", gEvent.Id) } - // If event already ended, it is effectively deleted - if endTime.After(time.Now()) { - startTime, err := time.Parse(time.RFC3339, gEvent.Start.DateTime) + + if gEvent.End.DateTime == "" { + // User has modified the event to be an all-day event. All-day events are problematic because they depend on the user's timezone. + // We won't handle all-day events at this time, and treat the event as deleted. + deleted = true + } + + var endTime *time.Time + if !deleted { + endTime, err = c.parseDateTime(gEvent.End) if err != nil { - return nil, false, ctxerr.Wrap( - c.config.Context, err, fmt.Sprintf("parsing Google calendar event start time: %s", gEvent.Start.DateTime), - ) + return nil, false, err } - fleetEvent, err := c.googleEventToFleetEvent(startTime, endTime, gEvent) + if !endTime.After(time.Now()) { + // If event already ended, it is effectively deleted + // Delete this event to prevent confusion. This operation should be rare. + err = c.DeleteEvent(event) + if err != nil { + level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which is in the past", "err", err) + } + deleted = true + } + } + if !deleted { + if gEvent.Start == nil || (gEvent.Start.DateTime == "" && gEvent.Start.Date == "") { + // We should not see this error. If we do, we can work around by treating event as deleted. + return nil, false, ctxerr.Errorf(c.config.Context, "missing start date/time for Google calendar event: %s", gEvent.Id) + } + if gEvent.Start.DateTime == "" { + // User has modified the event to be an all-day event. All-day events are problematic because they depend on the user's timezone. + // We won't handle all-day events at this time, and treat the event as deleted. + deleted = true + } + } + if !deleted { + startTime, err := c.parseDateTime(gEvent.Start) + if err != nil { + return nil, false, err + } + fleetEvent, err := c.googleEventToFleetEvent(*startTime, *endTime, gEvent) if err != nil { return nil, false, err } @@ -175,12 +210,7 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn } } - newStartDate := event.StartTime.Add(24 * time.Hour) - if newStartDate.Weekday() == time.Saturday { - newStartDate = newStartDate.Add(48 * time.Hour) - } else if newStartDate.Weekday() == time.Sunday { - newStartDate = newStartDate.Add(24 * time.Hour) - } + newStartDate := calculateNewEventDate(event.StartTime) fleetEvent, err := c.CreateEvent(newStartDate, genBodyFn()) if err != nil { @@ -189,6 +219,34 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn return fleetEvent, true, nil } +func calculateNewEventDate(oldStartDate time.Time) time.Time { + // Note: we do not handle time changes (daylight savings time, etc.) -- assuming 1 day is always 24 hours. + newStartDate := oldStartDate.Add(24 * time.Hour) + if newStartDate.Weekday() == time.Saturday { + newStartDate = newStartDate.Add(48 * time.Hour) + } else if newStartDate.Weekday() == time.Sunday { + newStartDate = newStartDate.Add(24 * time.Hour) + } + return newStartDate +} + +func (c *GoogleCalendar) parseDateTime(eventDateTime *calendar.EventDateTime) (*time.Time, error) { + var endTime time.Time + var err error + if eventDateTime.TimeZone != "" { + loc := getLocation(eventDateTime.TimeZone, c.config) + endTime, err = time.ParseInLocation(time.RFC3339, eventDateTime.DateTime, loc) + } else { + endTime, err = time.Parse(time.RFC3339, eventDateTime.DateTime) + } + if err != nil { + return nil, ctxerr.Wrap( + c.config.Context, err, fmt.Sprintf("parsing Google calendar event time: %s", eventDateTime.DateTime), + ) + } + return &endTime, nil +} + func isNotFound(err error) bool { if err == nil { return false @@ -212,6 +270,12 @@ func (c *GoogleCalendar) unmarshalDetails(event *fleet.CalendarEvent) (*eventDet } func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, body string) (*fleet.CalendarEvent, error) { + return c.createEvent(dayOfEvent, body, time.Now) +} + +// createEvent creates a new event on the calendar on the given date. timeNow is a function that returns the current time. +// timeNow can be overwritten for testing +func (c *GoogleCalendar) createEvent(dayOfEvent time.Time, body string, timeNow func() time.Time) (*fleet.CalendarEvent, error) { if c.timezoneOffset == nil { err := getTimezone(c) if err != nil { @@ -223,27 +287,26 @@ func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, body string) (*fleet. dayStart := time.Date(dayOfEvent.Year(), dayOfEvent.Month(), dayOfEvent.Day(), startHour, 0, 0, 0, location) dayEnd := time.Date(dayOfEvent.Year(), dayOfEvent.Month(), dayOfEvent.Day(), endHour, 0, 0, 0, location) - now := time.Now().In(location) + now := timeNow().In(location) if dayEnd.Before(now) { // The workday has already ended. return nil, ctxerr.Wrap(c.config.Context, fleet.DayEndedError{Msg: "cannot schedule an event for a day that has already ended"}) } // Adjust day start if workday already started - if dayStart.Before(now) { + if !dayStart.After(now) { dayStart = now.Truncate(eventLength) if dayStart.Before(now) { dayStart = dayStart.Add(eventLength) } - if dayStart.Equal(dayEnd) { + if !dayStart.Before(dayEnd) { return nil, ctxerr.Wrap(c.config.Context, fleet.DayEndedError{Msg: "no time available for event"}) } } eventStart := dayStart eventEnd := dayStart.Add(eventLength) - searchStart := dayStart.Add(-24 * time.Hour) - events, err := c.config.API.ListEvents(searchStart.Format(time.RFC3339), dayEnd.Format(time.RFC3339)) + events, err := c.config.API.ListEvents(dayStart.Format(time.RFC3339), dayEnd.Format(time.RFC3339)) if err != nil { return nil, ctxerr.Wrap(c.config.Context, err, "listing Google calendar events") } @@ -253,48 +316,44 @@ func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, body string) (*fleet. continue } + // Ignore all day events + if gEvent.Start == nil || gEvent.Start.DateTime == "" || gEvent.End == nil || gEvent.End.DateTime == "" { + continue + } + // Ignore events that the user has declined - var attending bool - if len(gEvent.Attendees) == 0 { - // No attendees, so we assume the user is attending - attending = true - } else { - for _, attendee := range gEvent.Attendees { - if attendee.Email == c.currentUserEmail { - if attendee.ResponseStatus != "declined" { - attending = true - } + var declined bool + for _, attendee := range gEvent.Attendees { + if attendee.Email == c.currentUserEmail { + // The user has declined the event, so this time is open for scheduling + if attendee.ResponseStatus == "declined" { + declined = true break } } } - if !attending { + if declined { continue } // Ignore events that will end before our event - endTime, err := time.Parse(time.RFC3339, gEvent.End.DateTime) + endTime, err := c.parseDateTime(gEvent.End) if err != nil { - return nil, ctxerr.Wrap( - c.config.Context, err, fmt.Sprintf("parsing Google calendar event end time: %s", gEvent.End.DateTime), - ) + return nil, err } - if endTime.Before(eventStart) || endTime.Equal(eventStart) { + if !endTime.After(eventStart) { continue } - startTime, err := time.Parse(time.RFC3339, gEvent.Start.DateTime) + startTime, err := c.parseDateTime(gEvent.Start) if err != nil { - return nil, ctxerr.Wrap( - c.config.Context, err, fmt.Sprintf("parsing Google calendar event start time: %s", gEvent.Start.DateTime), - ) + return nil, err } if startTime.Before(eventEnd) { // Event occurs during our event, so we need to adjust. - fmt.Printf("VICTOR Adjusting event times due to %s: %s - %s\n", gEvent.Summary, eventStart, eventEnd) var isLastSlot bool - eventStart, eventEnd, isLastSlot = adjustEventTimes(endTime, dayEnd) + eventStart, eventEnd, isLastSlot = adjustEventTimes(*endTime, dayEnd) if isLastSlot { break } @@ -349,17 +408,22 @@ func getTimezone(gCal *GoogleCalendar) error { return ctxerr.Wrap(config.Context, err, "retrieving Google calendar timezone") } - loc, err := time.LoadLocation(setting.Value) - if err != nil { - // Could not load location, use EST - level.Warn(config.Logger).Log("msg", "parsing Google calendar timezone", "timezone", setting.Value, "err", err) - loc, _ = time.LoadLocation("America/New_York") - } + loc := getLocation(setting.Value, config) _, timezoneOffset := time.Now().In(loc).Zone() gCal.timezoneOffset = &timezoneOffset return nil } +func getLocation(name string, config *GoogleCalendarConfig) *time.Location { + loc, err := time.LoadLocation(name) + if err != nil { + // Could not load location, use EST + level.Warn(config.Logger).Log("msg", "parsing Google calendar timezone", "timezone", name, "err", err) + loc, _ = time.LoadLocation("America/New_York") + } + return loc +} + func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime time.Time, event *calendar.Event) ( *fleet.CalendarEvent, error, ) { diff --git a/ee/server/calendar/google_calendar_test.go b/ee/server/calendar/google_calendar_test.go new file mode 100644 index 0000000000..4c3e2db092 --- /dev/null +++ b/ee/server/calendar/google_calendar_test.go @@ -0,0 +1,589 @@ +package calendar + +import ( + "context" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/go-kit/kit/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/calendar/v3" + "google.golang.org/api/googleapi" + "net/http" + "os" + "testing" + "time" +) + +const ( + baseServiceEmail = "service@example.com" + basePrivateKey = "private-key" + baseUserEmail = "user@example.com" +) + +var ( + baseCtx = context.Background() + logger = log.NewLogfmtLogger(log.NewSyncWriter(os.Stdout)) +) + +type MockGoogleCalendarLowLevelAPI struct { + ConfigureFunc func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error + GetSettingFunc func(name string) (*calendar.Setting, error) + ListEventsFunc func(timeMin, timeMax string) (*calendar.Events, error) + CreateEventFunc func(event *calendar.Event) (*calendar.Event, error) + GetEventFunc func(id, eTag string) (*calendar.Event, error) + DeleteEventFunc func(id string) error +} + +func (m *MockGoogleCalendarLowLevelAPI) Configure( + ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string, +) error { + return m.ConfigureFunc(ctx, serviceAccountEmail, privateKey, userToImpersonateEmail) +} + +func (m *MockGoogleCalendarLowLevelAPI) GetSetting(name string) (*calendar.Setting, error) { + return m.GetSettingFunc(name) +} + +func (m *MockGoogleCalendarLowLevelAPI) ListEvents(timeMin, timeMax string) (*calendar.Events, error) { + return m.ListEventsFunc(timeMin, timeMax) +} + +func (m *MockGoogleCalendarLowLevelAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) { + return m.CreateEventFunc(event) +} + +func (m *MockGoogleCalendarLowLevelAPI) GetEvent(id, eTag string) (*calendar.Event, error) { + return m.GetEventFunc(id, eTag) +} + +func (m *MockGoogleCalendarLowLevelAPI) DeleteEvent(id string) error { + return m.DeleteEventFunc(id) +} + +func TestGoogleCalendar_Configure(t *testing.T) { + t.Parallel() + mockAPI := &MockGoogleCalendarLowLevelAPI{} + mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error { + assert.Equal(t, baseCtx, ctx) + assert.Equal(t, baseServiceEmail, serviceAccountEmail) + assert.Equal(t, basePrivateKey, privateKey) + assert.Equal(t, baseUserEmail, userToImpersonateEmail) + return nil + } + + // Happy path test + var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI)) + err := cal.Configure(baseUserEmail) + assert.NoError(t, err) + + // Configure error test + mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error { + return assert.AnError + } + err = cal.Configure(baseUserEmail) + assert.ErrorIs(t, err, assert.AnError) +} + +func makeConfig(mockAPI *MockGoogleCalendarLowLevelAPI) *GoogleCalendarConfig { + if mockAPI != nil && mockAPI.ConfigureFunc == nil { + mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error { + return nil + } + } + config := &GoogleCalendarConfig{ + Context: context.Background(), + IntegrationConfig: &fleet.GoogleCalendarIntegration{ + Email: baseServiceEmail, + PrivateKey: basePrivateKey, + }, + Logger: logger, + API: mockAPI, + } + return config +} + +func TestGoogleCalendar_DeleteEvent(t *testing.T) { + t.Parallel() + mockAPI := &MockGoogleCalendarLowLevelAPI{} + mockAPI.DeleteEventFunc = func(id string) error { + assert.Equal(t, "event-id", id) + return nil + } + + // Happy path test + var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI)) + err := cal.Configure(baseUserEmail) + assert.NoError(t, err) + err = cal.DeleteEvent(&fleet.CalendarEvent{Data: []byte(`{"ID":"event-id"}`)}) + assert.NoError(t, err) + + // API error test + mockAPI.DeleteEventFunc = func(id string) error { + return assert.AnError + } + err = cal.DeleteEvent(&fleet.CalendarEvent{Data: []byte(`{"ID":"event-id"}`)}) + assert.ErrorIs(t, err, assert.AnError) +} + +func TestGoogleCalendar_unmarshalDetails(t *testing.T) { + t.Parallel() + var gCal = NewGoogleCalendar(makeConfig(&MockGoogleCalendarLowLevelAPI{})) + err := gCal.Configure(baseUserEmail) + assert.NoError(t, err) + details, err := gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"id":"event-id","etag":"event-eTag"}`)}) + assert.NoError(t, err) + assert.Equal(t, "event-id", details.ID) + assert.Equal(t, "event-eTag", details.ETag) + + // Missing ETag is OK + details, err = gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"id":"event-id"}`)}) + assert.NoError(t, err) + assert.Equal(t, "event-id", details.ID) + assert.Equal(t, "", details.ETag) + + // Bad JSON + _, err = gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"bozo`)}) + assert.Error(t, err) + + // Missing id + _, err = gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"myId":"event-id","etag":"event-eTag"}`)}) + assert.Error(t, err) +} + +func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { + t.Parallel() + mockAPI := &MockGoogleCalendarLowLevelAPI{} + const baseETag = "event-eTag" + const baseEventID = "event-id" + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + assert.Equal(t, baseEventID, id) + assert.Equal(t, baseETag, eTag) + return &calendar.Event{ + Etag: baseETag, // ETag matches -- no modifications to event + }, nil + } + genBodyFn := func() string { + t.Error("genBodyFn should not be called") + return "event-body" + } + var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI)) + err := cal.Configure(baseUserEmail) + assert.NoError(t, err) + + eventStartTime := time.Now().UTC() + event := &fleet.CalendarEvent{ + StartTime: eventStartTime, + EndTime: time.Now().Add(time.Hour), + Data: []byte(`{"ID":"` + baseEventID + `","ETag":"` + baseETag + `"}`), + } + + // ETag matches + retrievedEvent, updated, err := cal.GetAndUpdateEvent(event, genBodyFn) + assert.NoError(t, err) + assert.False(t, updated) + assert.Equal(t, event, retrievedEvent) + + // http.StatusNotModified response (ETag matches) + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return nil, &googleapi.Error{Code: http.StatusNotModified} + } + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.NoError(t, err) + assert.False(t, updated) + assert.Equal(t, event, retrievedEvent) + + // Cannot unmarshal details + eventBadDetails := &fleet.CalendarEvent{ + StartTime: time.Now(), + EndTime: time.Now().Add(time.Hour), + Data: []byte(`{"bozo`), + } + _, _, err = cal.GetAndUpdateEvent(eventBadDetails, genBodyFn) + assert.Error(t, err) + + // API error test + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return nil, assert.AnError + } + _, _, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.ErrorIs(t, err, assert.AnError) + + // Event has been modified + startTime := time.Now().Add(time.Minute).Truncate(time.Second) + endTime := time.Now().Add(time.Hour).Truncate(time.Second) + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)}, + }, nil + } + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.NoError(t, err) + assert.True(t, updated) + assert.NotEqual(t, event, retrievedEvent) + require.NotNil(t, retrievedEvent) + assert.Equal(t, startTime.UTC(), retrievedEvent.StartTime.UTC()) + assert.Equal(t, endTime.UTC(), retrievedEvent.EndTime.UTC()) + assert.Equal(t, baseUserEmail, retrievedEvent.Email) + gCal, _ := cal.(*GoogleCalendar) + details, err := gCal.unmarshalDetails(retrievedEvent) + require.NoError(t, err) + assert.Equal(t, "new-eTag", details.ETag) + assert.Equal(t, baseEventID, details.ID) + + // missing end time + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: ""}, + }, nil + } + _, _, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.Error(t, err) + + // missing start time + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)}, + }, nil + } + _, _, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.Error(t, err) + + // Bad time format + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: "bozo"}, + }, nil + } + _, _, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.Error(t, err) + + // Event has been modified, with custom timezone. + tzId := "Africa/Kinshasa" + location, _ := time.LoadLocation(tzId) + startTime = time.Now().Add(time.Minute).Truncate(time.Second).In(location) + endTime = time.Now().Add(time.Hour).Truncate(time.Second).In(location) + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{DateTime: startTime.UTC().Format(time.RFC3339), TimeZone: tzId}, + End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339), TimeZone: tzId}, + }, nil + } + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.NoError(t, err) + assert.True(t, updated) + assert.NotEqual(t, event, retrievedEvent) + require.NotNil(t, retrievedEvent) + assert.Equal(t, startTime.UTC(), retrievedEvent.StartTime.UTC()) + assert.Equal(t, endTime.UTC(), retrievedEvent.EndTime.UTC()) + assert.Equal(t, baseUserEmail, retrievedEvent.Email) + + // 404 response (deleted) + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return nil, &googleapi.Error{Code: http.StatusNotFound} + } + mockAPI.GetSettingFunc = func(name string) (*calendar.Setting, error) { + return &calendar.Setting{Value: "UTC"}, nil + } + mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { + return &calendar.Events{}, nil + } + genBodyFn = func() string { + return "event-body" + } + eventCreated := false + mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) { + assert.Equal(t, eventTitle, event.Summary) + assert.Equal(t, genBodyFn(), event.Description) + event.Id = baseEventID + event.Etag = baseETag + eventCreated = true + return event, nil + } + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + require.NoError(t, err) + assert.True(t, updated) + assert.NotEqual(t, event, retrievedEvent) + require.NotNil(t, retrievedEvent) + assert.Equal(t, baseUserEmail, retrievedEvent.Email) + newEventDate := calculateNewEventDate(eventStartTime) + expectedStartTime := time.Date(newEventDate.Year(), newEventDate.Month(), newEventDate.Day(), startHour, 0, 0, 0, time.UTC) + assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC()) + assert.True(t, eventCreated) + + // cancelled (deleted) + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)}, + Status: "cancelled", + }, nil + } + eventCreated = false + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + require.NoError(t, err) + assert.True(t, updated) + require.NotNil(t, retrievedEvent) + assert.NotEqual(t, event, retrievedEvent) + assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC()) + assert.True(t, eventCreated) + + // all day event (deleted) + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{Date: startTime.Format("2006-01-02")}, + End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)}, + }, nil + } + eventCreated = false + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + require.NoError(t, err) + assert.True(t, updated) + require.NotNil(t, retrievedEvent) + assert.NotEqual(t, event, retrievedEvent) + assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC()) + assert.True(t, eventCreated) + + // moved in the past event (deleted) + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{DateTime: startTime.Add(-2 * time.Hour).Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: endTime.Add(-2 * time.Hour).Format(time.RFC3339)}, + }, nil + } + eventCreated = false + mockAPI.DeleteEventFunc = func(id string) error { + assert.Equal(t, baseEventID, id) + return nil + } + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + require.NoError(t, err) + assert.True(t, updated) + require.NotNil(t, retrievedEvent) + assert.NotEqual(t, event, retrievedEvent) + assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC()) + assert.True(t, eventCreated) +} + +func TestGoogleCalendar_CreateEvent(t *testing.T) { + t.Parallel() + mockAPI := &MockGoogleCalendarLowLevelAPI{} + const baseEventID = "event-id" + const baseETag = "event-eTag" + const eventBody = "event-body" + var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI)) + err := cal.Configure(baseUserEmail) + assert.NoError(t, err) + + tzId := "Africa/Kinshasa" + mockAPI.GetSettingFunc = func(name string) (*calendar.Setting, error) { + return &calendar.Setting{Value: tzId}, nil + } + mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { + return &calendar.Events{}, nil + } + mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) { + assert.Equal(t, eventTitle, event.Summary) + assert.Equal(t, eventBody, event.Description) + event.Id = baseEventID + event.Etag = baseETag + return event, nil + } + + // Happy path test -- empty calendar + date := time.Now().Add(48 * time.Hour) + location, _ := time.LoadLocation(tzId) + expectedStartTime := time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location) + _, expectedOffset := expectedStartTime.Zone() + event, err := cal.CreateEvent(date, eventBody) + require.NoError(t, err) + assert.Equal(t, baseUserEmail, event.Email) + assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) + _, offset := event.StartTime.Zone() + assert.Equal(t, expectedOffset, offset) + _, offset = event.EndTime.Zone() + assert.Equal(t, expectedOffset, offset) + gCal, _ := cal.(*GoogleCalendar) + details, err := gCal.unmarshalDetails(event) + require.NoError(t, err) + assert.Equal(t, baseETag, details.ETag) + assert.Equal(t, baseEventID, details.ID) + + // Workday already ended + date = time.Now().Add(-48 * time.Hour) + _, err = cal.CreateEvent(date, eventBody) + assert.ErrorAs(t, err, &fleet.DayEndedError{}) + + // There is no time left in the day to schedule an event + date = time.Now().Add(48 * time.Hour) + timeNow := func() time.Time { + now := time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 45, 0, 0, location) + return now + } + _, err = gCal.createEvent(date, eventBody, timeNow) + assert.ErrorAs(t, err, &fleet.DayEndedError{}) + + // Workday already started + date = time.Now().Add(48 * time.Hour) + expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location) + timeNow = func() time.Time { + return expectedStartTime + } + event, err = gCal.createEvent(date, eventBody, timeNow) + require.NoError(t, err) + assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) + + // Busy calendar + date = time.Now().Add(48 * time.Hour) + dayStart := time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location) + dayEnd := time.Date(date.Year(), date.Month(), date.Day(), endHour, 0, 0, 0, location) + gEvents := &calendar.Events{} + // Cancelled event + gEvent := &calendar.Event{ + Id: "cancelled-event-id", + Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)}, + Status: "cancelled", + } + gEvents.Items = append(gEvents.Items, gEvent) + // All day events + gEvent = &calendar.Event{ + Id: "all-day-event-id", + Start: &calendar.EventDateTime{Date: dayStart.Format(time.DateOnly)}, + End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)}, + } + gEvents.Items = append(gEvents.Items, gEvent) + gEvent = &calendar.Event{ + Id: "all-day2-event-id", + Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{Date: dayEnd.Format(time.DateOnly)}, + } + gEvents.Items = append(gEvents.Items, gEvent) + // User-declined event + gEvent = &calendar.Event{ + Id: "user-declined-event-id", + Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)}, + Attendees: []*calendar.EventAttendee{{Email: baseUserEmail, ResponseStatus: "declined"}}, + } + gEvents.Items = append(gEvents.Items, gEvent) + // Event before day + gEvent = &calendar.Event{ + Id: "before-event-id", + Start: &calendar.EventDateTime{DateTime: dayStart.Add(-time.Hour).Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: dayStart.Add(-30 * time.Minute).Format(time.RFC3339)}, + } + gEvents.Items = append(gEvents.Items, gEvent) + + // Event from 6am to 11am + eventStart := time.Date(date.Year(), date.Month(), date.Day(), 6, 0, 0, 0, location) + eventEnd := time.Date(date.Year(), date.Month(), date.Day(), 11, 0, 0, 0, location) + gEvent = &calendar.Event{ + Id: "6-to-11-event-id", + Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)}, + Attendees: []*calendar.EventAttendee{{Email: baseUserEmail, ResponseStatus: "accepted"}}, + } + gEvents.Items = append(gEvents.Items, gEvent) + + // Event from 10am to 10:30am + eventStart = time.Date(date.Year(), date.Month(), date.Day(), 10, 0, 0, 0, location) + eventEnd = time.Date(date.Year(), date.Month(), date.Day(), 10, 30, 0, 0, location) + gEvent = &calendar.Event{ + Id: "10-to-10-30-event-id", + Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)}, + Attendees: []*calendar.EventAttendee{{Email: "other@example.com", ResponseStatus: "accepted"}}, + } + gEvents.Items = append(gEvents.Items, gEvent) + // Event from 11am to 11:45am + eventStart = time.Date(date.Year(), date.Month(), date.Day(), 11, 0, 0, 0, location) + eventEnd = time.Date(date.Year(), date.Month(), date.Day(), 11, 45, 0, 0, location) + gEvent = &calendar.Event{ + Id: "11-to-11-45-event-id", + Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)}, + Attendees: []*calendar.EventAttendee{{Email: "other@example.com", ResponseStatus: "accepted"}}, + } + gEvents.Items = append(gEvents.Items, gEvent) + + // Event after day + eventStart = time.Date(date.Year(), date.Month(), date.Day(), endHour, 0, 0, 0, location) + eventEnd = time.Date(date.Year(), date.Month(), date.Day(), endHour, 45, 0, 0, location) + gEvent = &calendar.Event{ + Id: "after-event-id", + Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)}, + Attendees: []*calendar.EventAttendee{{Email: "other@example.com", ResponseStatus: "accepted"}}, + } + gEvents.Items = append(gEvents.Items, gEvent) + mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { + return gEvents, nil + } + expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), 12, 0, 0, 0, location) + event, err = gCal.CreateEvent(date, eventBody) + require.NoError(t, err) + assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) + + // Full schedule -- pick the last slot + date = time.Now().Add(48 * time.Hour) + dayStart = time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location) + dayEnd = time.Date(date.Year(), date.Month(), date.Day(), endHour, 0, 0, 0, location) + gEvents = &calendar.Events{} + gEvent = &calendar.Event{ + Id: "9-to-5-event-id", + Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)}, + } + gEvents.Items = append(gEvents.Items, gEvent) + mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { + return gEvents, nil + } + expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location) + event, err = gCal.CreateEvent(date, eventBody) + require.NoError(t, err) + assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) + + // API error in ListEvents + mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { + return nil, assert.AnError + } + _, err = gCal.CreateEvent(date, eventBody) + assert.ErrorIs(t, err, assert.AnError) + + // API error in CreateEvent + mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { + return &calendar.Events{}, nil + } + mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) { + return nil, assert.AnError + } + _, err = gCal.CreateEvent(date, eventBody) + assert.ErrorIs(t, err, assert.AnError) +}