diff --git a/ee/server/calendar/google_calendar.go b/ee/server/calendar/google_calendar.go index 26d1ba1e66..42f8b7b0d8 100644 --- a/ee/server/calendar/google_calendar.go +++ b/ee/server/calendar/google_calendar.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "net/http" + "os" + "regexp" "time" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -19,18 +21,30 @@ import ( "google.golang.org/api/option" ) +// The calendar package has the following features for testing: +// 1. High level UserCalendar interface and Low level GoogleCalendarAPI interface can have a custom implementations. +// 2. Setting "client_email" to "calendar-mock@example.com" in the API key will use a mock in-memory implementation GoogleCalendarMockAPI of GoogleCalendarAPI. +// 3. Setting FLEET_GOOGLE_CALENDAR_PLUS_ADDRESSING environment variable to "1" will strip the "plus addressing" from the user email, effectively allowing a single user +// to create multiple events in the same calendar. This is useful for load testing. For example: john+test@example.com becomes john@example.com + const ( eventTitle = "💻🚫Downtime" startHour = 9 endHour = 17 eventLength = 30 * time.Minute calendarID = "primary" + mockEmail = "calendar-mock@example.com" + loadEmail = "calendar-load@example.com" ) -var calendarScopes = []string{ - "https://www.googleapis.com/auth/calendar.events", - "https://www.googleapis.com/auth/calendar.settings.readonly", -} +var ( + calendarScopes = []string{ + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/calendar.settings.readonly", + } + plusAddressing = os.Getenv("FLEET_GOOGLE_CALENDAR_PLUS_ADDRESSING") == "1" + plusAddressingRegex = regexp.MustCompile(`\+.*@`) +) type GoogleCalendarConfig struct { Context context.Context @@ -43,19 +57,22 @@ type GoogleCalendarConfig struct { // GoogleCalendar is an implementation of the UserCalendar interface that uses the // Google Calendar API to manage events. type GoogleCalendar struct { - config *GoogleCalendarConfig - currentUserEmail string - timezoneOffset *int + config *GoogleCalendarConfig + currentUserEmail string + adjustedUserEmail string + location *time.Location } func NewGoogleCalendar(config *GoogleCalendarConfig) *GoogleCalendar { - if config.API == nil { - if config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == "calendar-mock@example.com" { - // Assumes that only 1 Fleet server accesses the calendar, since all mock events are held in memory - config.API = &GoogleCalendarMockAPI{} - } else { - config.API = &GoogleCalendarLowLevelAPI{} - } + switch { + case config.API != nil: + // Use the provided API. + case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == loadEmail: + config.API = &GoogleCalendarLoadAPI{Logger: config.Logger} + case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == mockEmail: + config.API = &GoogleCalendarMockAPI{config.Logger} + default: + config.API = &GoogleCalendarLowLevelAPI{} } return &GoogleCalendar{ config: config, @@ -101,6 +118,13 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) Configure( return nil } +func adjustEmail(email string) string { + if plusAddressing { + return plusAddressingRegex.ReplaceAllString(email, "@") + } + return email +} + func (lowLevelAPI *GoogleCalendarLowLevelAPI) GetSetting(name string) (*calendar.Setting, error) { return lowLevelAPI.service.Settings.Get(name).Do() } @@ -130,14 +154,18 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) DeleteEvent(id string) error { } func (c *GoogleCalendar) Configure(userEmail string) error { + adjustedUserEmail := adjustEmail(userEmail) err := c.config.API.Configure( c.config.Context, c.config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail], - c.config.IntegrationConfig.ApiKey[fleet.GoogleCalendarPrivateKey], userEmail, + c.config.IntegrationConfig.ApiKey[fleet.GoogleCalendarPrivateKey], adjustedUserEmail, ) if err != nil { return ctxerr.Wrap(c.config.Context, err, "creating Google calendar service") } c.currentUserEmail = userEmail + c.adjustedUserEmail = adjustedUserEmail + // Clear the timezone offset so that it will be recalculated + c.location = nil return nil } @@ -162,7 +190,7 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn return nil, false, ctxerr.Wrap(c.config.Context, err, "retrieving Google calendar event") } if !deleted && gEvent.Status != "cancelled" { - if details.ETag == gEvent.Etag { + if details.ETag != "" && details.ETag == gEvent.Etag { // Event was not modified return event, false, nil } @@ -246,20 +274,20 @@ func calculateNewEventDate(oldStartDate time.Time) time.Time { } func (c *GoogleCalendar) parseDateTime(eventDateTime *calendar.EventDateTime) (*time.Time, error) { - var endTime time.Time + var t time.Time var err error if eventDateTime.TimeZone != "" { loc := getLocation(eventDateTime.TimeZone, c.config) - endTime, err = time.ParseInLocation(time.RFC3339, eventDateTime.DateTime, loc) + t, err = time.ParseInLocation(time.RFC3339, eventDateTime.DateTime, loc) } else { - endTime, err = time.Parse(time.RFC3339, eventDateTime.DateTime) + t, 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 + return &t, nil } func isNotFound(err error) bool { @@ -271,6 +299,15 @@ func isNotFound(err error) bool { return ok && ae.Code == http.StatusNotFound } +func isAlreadyDeleted(err error) bool { + if err == nil { + return false + } + var ae *googleapi.Error + ok := errors.As(err, &ae) + return ok && ae.Code == http.StatusGone +} + func (c *GoogleCalendar) unmarshalDetails(event *fleet.CalendarEvent) (*eventDetails, error) { var details eventDetails err := json.Unmarshal(event.Data, &details) @@ -293,18 +330,18 @@ func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, genBodyFn func(confli func (c *GoogleCalendar) createEvent( dayOfEvent time.Time, genBodyFn func(conflict bool) string, timeNow func() time.Time, ) (*fleet.CalendarEvent, error) { - if c.timezoneOffset == nil { - err := getTimezone(c) + var err error + if c.location == nil { + c.location, err = getTimezone(c) if err != nil { return nil, err } } - location := time.FixedZone("", *c.timezoneOffset) - 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) + dayStart := time.Date(dayOfEvent.Year(), dayOfEvent.Month(), dayOfEvent.Day(), startHour, 0, 0, 0, c.location) + dayEnd := time.Date(dayOfEvent.Year(), dayOfEvent.Month(), dayOfEvent.Day(), endHour, 0, 0, 0, c.location) - now := timeNow().In(location) + now := timeNow().In(c.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"}) @@ -342,7 +379,7 @@ func (c *GoogleCalendar) createEvent( // Ignore events that the user has declined var declined bool for _, attendee := range gEvent.Attendees { - if attendee.Email == c.currentUserEmail { + if attendee.Email == c.adjustedUserEmail { // The user has declined the event, so this time is open for scheduling if attendee.ResponseStatus == "declined" { declined = true @@ -396,7 +433,9 @@ func (c *GoogleCalendar) createEvent( if err != nil { return nil, err } - level.Debug(c.config.Logger).Log("msg", "created Google calendar event", "user", c.currentUserEmail, "startTime", eventStart) + level.Debug(c.config.Logger).Log( + "msg", "created Google calendar event", "user", c.adjustedUserEmail, "startTime", eventStart, "timezone", c.location.String(), + ) return fleetEvent, nil } @@ -419,17 +458,14 @@ func adjustEventTimes(endTime time.Time, dayEnd time.Time) (eventStart time.Time return eventStart, eventEnd, isLastSlot, conflict } -func getTimezone(gCal *GoogleCalendar) error { +func getTimezone(gCal *GoogleCalendar) (*time.Location, error) { config := gCal.config setting, err := config.API.GetSetting("timezone") if err != nil { - return ctxerr.Wrap(config.Context, err, "retrieving Google calendar timezone") + return nil, ctxerr.Wrap(config.Context, err, "retrieving Google calendar timezone") } - loc := getLocation(setting.Value, config) - _, timezoneOffset := time.Now().In(loc).Zone() - gCal.timezoneOffset = &timezoneOffset - return nil + return getLocation(setting.Value, config), nil } func getLocation(name string, config *GoogleCalendarConfig) *time.Location { @@ -467,7 +503,10 @@ func (c *GoogleCalendar) DeleteEvent(event *fleet.CalendarEvent) error { return err } err = c.config.API.DeleteEvent(details.ID) - if err != nil { + switch { + case isAlreadyDeleted(err): + return nil + case err != nil: return ctxerr.Wrap(c.config.Context, err, "deleting Google calendar event") } return nil diff --git a/ee/server/calendar/google_calendar_integration_test.go b/ee/server/calendar/google_calendar_integration_test.go new file mode 100644 index 0000000000..7f42b23b22 --- /dev/null +++ b/ee/server/calendar/google_calendar_integration_test.go @@ -0,0 +1,132 @@ +package calendar + +import ( + "context" + "github.com/fleetdm/fleet/v4/ee/server/calendar/load_test" + "github.com/fleetdm/fleet/v4/server/fleet" + kitlog "github.com/go-kit/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "net/http/httptest" + "os" + "testing" + "time" +) + +type googleCalendarIntegrationTestSuite struct { + suite.Suite + server *httptest.Server + dbFile *os.File +} + +func (s *googleCalendarIntegrationTestSuite) SetupSuite() { + dbFile, err := os.CreateTemp("", "calendar.db") + s.Require().NoError(err) + handler, err := calendartest.Configure(dbFile.Name()) + s.Require().NoError(err) + server := httptest.NewUnstartedServer(handler) + server.Listener.Addr() + server.Start() + s.server = server +} + +func (s *googleCalendarIntegrationTestSuite) TearDownSuite() { + if s.dbFile != nil { + s.dbFile.Close() + _ = os.Remove(s.dbFile.Name()) + } + if s.server != nil { + s.server.Close() + } + calendartest.Close() +} + +// TestGoogleCalendarIntegration tests should be able to be run in parallel, but this is not natively supported by suites: https://github.com/stretchr/testify/issues/187 +// There are workarounds that can be explored. +func TestGoogleCalendarIntegration(t *testing.T) { + testingSuite := new(googleCalendarIntegrationTestSuite) + suite.Run(t, testingSuite) +} + +func (s *googleCalendarIntegrationTestSuite) TestCreateGetDeleteEvent() { + t := s.T() + userEmail := "user1@example.com" + config := &GoogleCalendarConfig{ + Context: context.Background(), + IntegrationConfig: &fleet.GoogleCalendarIntegration{ + Domain: "example.com", + ApiKey: map[string]string{ + "client_email": loadEmail, + "private_key": s.server.URL, + }, + }, + Logger: kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(os.Stdout)), + } + gCal := NewGoogleCalendar(config) + err := gCal.Configure(userEmail) + require.NoError(t, err) + genBodyFn := func(bool) string { + return "Test event" + } + eventDate := time.Now().Add(48 * time.Hour) + event, err := gCal.CreateEvent(eventDate, genBodyFn) + require.NoError(t, err) + assert.Equal(t, startHour, event.StartTime.Hour()) + assert.Equal(t, 0, event.StartTime.Minute()) + + eventRsp, updated, err := gCal.GetAndUpdateEvent(event, genBodyFn) + require.NoError(t, err) + assert.False(t, updated) + assert.Equal(t, event, eventRsp) + + err = gCal.DeleteEvent(event) + assert.NoError(t, err) + // delete again + err = gCal.DeleteEvent(event) + assert.NoError(t, err) + + // Try to get deleted event + eventRsp, updated, err = gCal.GetAndUpdateEvent(event, genBodyFn) + require.NoError(t, err) + assert.True(t, updated) + assert.NotEqual(t, event.StartTime.UTC().Truncate(24*time.Hour), eventRsp.StartTime.UTC().Truncate(24*time.Hour)) +} + +func (s *googleCalendarIntegrationTestSuite) TestFillUpCalendar() { + t := s.T() + userEmail := "user2@example.com" + config := &GoogleCalendarConfig{ + Context: context.Background(), + IntegrationConfig: &fleet.GoogleCalendarIntegration{ + Domain: "example.com", + ApiKey: map[string]string{ + "client_email": loadEmail, + "private_key": s.server.URL, + }, + }, + Logger: kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(os.Stdout)), + } + gCal := NewGoogleCalendar(config) + err := gCal.Configure(userEmail) + require.NoError(t, err) + genBodyFn := func(bool) string { + return "Test event" + } + eventDate := time.Now().Add(48 * time.Hour) + event, err := gCal.CreateEvent(eventDate, genBodyFn) + require.NoError(t, err) + assert.Equal(t, startHour, event.StartTime.Hour()) + assert.Equal(t, 0, event.StartTime.Minute()) + + currentEventTime := event.StartTime + for i := 0; i < 20; i++ { + if !(currentEventTime.Hour() == endHour-1 && currentEventTime.Minute() == 30) { + currentEventTime = currentEventTime.Add(30 * time.Minute) + } + event, err = gCal.CreateEvent(eventDate, genBodyFn) + require.NoError(t, err) + assert.Equal(t, currentEventTime.UTC(), event.StartTime.UTC()) + } + +} diff --git a/ee/server/calendar/google_calendar_load.go b/ee/server/calendar/google_calendar_load.go new file mode 100644 index 0000000000..8446af20c5 --- /dev/null +++ b/ee/server/calendar/google_calendar_load.go @@ -0,0 +1,234 @@ +package calendar + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/fleetdm/fleet/v4/pkg/fleethttp" + kitlog "github.com/go-kit/log" + "google.golang.org/api/calendar/v3" + "google.golang.org/api/googleapi" + "io" + "net/http" + "net/url" + "os" +) + +// GoogleCalendarLoadAPI is used for load testing. +type GoogleCalendarLoadAPI struct { + Logger kitlog.Logger + baseUrl string + userToImpersonate string + ctx context.Context + client *http.Client +} + +// Configure creates a new Google Calendar service using the provided credentials. +func (lowLevelAPI *GoogleCalendarLoadAPI) Configure(ctx context.Context, _ string, privateKey string, userToImpersonate string) error { + if lowLevelAPI.Logger == nil { + lowLevelAPI.Logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stderr), "mock", "GoogleCalendarLoadAPI", "user", userToImpersonate) + } + lowLevelAPI.baseUrl = privateKey + lowLevelAPI.userToImpersonate = userToImpersonate + lowLevelAPI.ctx = ctx + if lowLevelAPI.client == nil { + lowLevelAPI.client = fleethttp.NewClient() + } + return nil +} + +func (lowLevelAPI *GoogleCalendarLoadAPI) GetSetting(name string) (*calendar.Setting, error) { + reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/settings") + if err != nil { + return nil, err + } + query := reqUrl.Query() + query.Set("name", name) + query.Set("email", lowLevelAPI.userToImpersonate) + reqUrl.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "GET", reqUrl.String(), nil) + if err != nil { + return nil, err + } + rsp, err := lowLevelAPI.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = rsp.Body.Close() + }() + if rsp.StatusCode != http.StatusOK { + var data []byte + if rsp.Body != nil { + data, _ = io.ReadAll(rsp.Body) + } + return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data)) + } + var setting calendar.Setting + body, err := io.ReadAll(rsp.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(body, &setting) + if err != nil { + return nil, err + } + return &setting, nil +} + +func (lowLevelAPI *GoogleCalendarLoadAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) { + body, err := json.Marshal(event) + if err != nil { + return nil, err + } + reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events/add") + if err != nil { + return nil, err + } + query := reqUrl.Query() + query.Set("email", lowLevelAPI.userToImpersonate) + reqUrl.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "POST", reqUrl.String(), bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + rsp, err := lowLevelAPI.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = rsp.Body.Close() + }() + if rsp.StatusCode != http.StatusCreated { + var data []byte + if rsp.Body != nil { + data, _ = io.ReadAll(rsp.Body) + } + return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data)) + } + var rspEvent calendar.Event + body, err = io.ReadAll(rsp.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(body, &rspEvent) + if err != nil { + return nil, err + } + return &rspEvent, nil +} + +func (lowLevelAPI *GoogleCalendarLoadAPI) GetEvent(id, _ string) (*calendar.Event, error) { + reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events") + if err != nil { + return nil, err + } + query := reqUrl.Query() + query.Set("id", id) + reqUrl.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "GET", reqUrl.String(), nil) + if err != nil { + return nil, err + } + rsp, err := lowLevelAPI.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = rsp.Body.Close() + }() + if rsp.StatusCode == http.StatusNotFound { + return nil, &googleapi.Error{Code: http.StatusNotFound} + } + if rsp.StatusCode != http.StatusOK { + var data []byte + if rsp.Body != nil { + data, _ = io.ReadAll(rsp.Body) + } + return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data)) + } + var rspEvent calendar.Event + body, err := io.ReadAll(rsp.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(body, &rspEvent) + if err != nil { + return nil, err + } + return &rspEvent, nil +} + +func (lowLevelAPI *GoogleCalendarLoadAPI) ListEvents(timeMin string, timeMax string) (*calendar.Events, error) { + reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events/list") + if err != nil { + return nil, err + } + query := reqUrl.Query() + query.Set("timemin", timeMin) + query.Set("timemax", timeMax) + query.Set("email", lowLevelAPI.userToImpersonate) + reqUrl.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "GET", reqUrl.String(), nil) + if err != nil { + return nil, err + } + rsp, err := lowLevelAPI.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = rsp.Body.Close() + }() + if rsp.StatusCode != http.StatusOK { + var data []byte + if rsp.Body != nil { + data, _ = io.ReadAll(rsp.Body) + } + return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data)) + } + var events calendar.Events + body, err := io.ReadAll(rsp.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(body, &events) + if err != nil { + return nil, err + } + return &events, nil +} + +func (lowLevelAPI *GoogleCalendarLoadAPI) DeleteEvent(id string) error { + reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events/delete") + if err != nil { + return err + } + query := reqUrl.Query() + query.Set("id", id) + reqUrl.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "DELETE", reqUrl.String(), nil) + if err != nil { + return err + } + rsp, err := lowLevelAPI.client.Do(req) + if err != nil { + return err + } + defer func() { + _ = rsp.Body.Close() + }() + if rsp.StatusCode == http.StatusGone { + return &googleapi.Error{Code: http.StatusGone} + } + if rsp.StatusCode != http.StatusOK { + var data []byte + if rsp.Body != nil { + data, _ = io.ReadAll(rsp.Body) + } + return fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data)) + } + return nil +} diff --git a/ee/server/calendar/google_calendar_mock.go b/ee/server/calendar/google_calendar_mock.go index a6d7d6040a..255f8d87c7 100644 --- a/ee/server/calendar/google_calendar_mock.go +++ b/ee/server/calendar/google_calendar_mock.go @@ -17,7 +17,7 @@ type GoogleCalendarMockAPI struct { logger kitlog.Logger } -var events = make(map[string]*calendar.Event) +var mockEvents = make(map[string]*calendar.Event) var mu sync.Mutex var id uint64 @@ -49,7 +49,7 @@ func (lowLevelAPI *GoogleCalendarMockAPI) CreateEvent(event *calendar.Event) (*c id += 1 event.Id = strconv.FormatUint(id, 10) lowLevelAPI.logger.Log("msg", "CreateEvent", "id", event.Id, "start", event.Start.DateTime) - events[event.Id] = event + mockEvents[event.Id] = event return event, nil } @@ -57,7 +57,7 @@ func (lowLevelAPI *GoogleCalendarMockAPI) GetEvent(id, _ string) (*calendar.Even time.Sleep(latency) mu.Lock() defer mu.Unlock() - event, ok := events[id] + event, ok := mockEvents[id] if !ok { return nil, &googleapi.Error{Code: http.StatusNotFound} } @@ -76,6 +76,6 @@ func (lowLevelAPI *GoogleCalendarMockAPI) DeleteEvent(id string) error { mu.Lock() defer mu.Unlock() lowLevelAPI.logger.Log("msg", "DeleteEvent", "id", id) - delete(events, id) + delete(mockEvents, id) return nil } diff --git a/ee/server/calendar/google_calendar_test.go b/ee/server/calendar/google_calendar_test.go index ad5e1c89ca..02d024792e 100644 --- a/ee/server/calendar/google_calendar_test.go +++ b/ee/server/calendar/google_calendar_test.go @@ -84,6 +84,29 @@ func TestGoogleCalendar_Configure(t *testing.T) { assert.ErrorIs(t, err, assert.AnError) } +func TestGoogleCalendar_ConfigurePlusAddressing(t *testing.T) { + // Do not run this test in t.Parallel(), since it involves modifying a global variable + plusAddressing = true + t.Cleanup( + func() { + plusAddressing = false + }, + ) + email := "user+my_test+email@example.com" + 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, "user@example.com", userToImpersonateEmail) + return nil + } + + var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI)) + err := cal.Configure(email) + assert.NoError(t, err) +} + func makeConfig(mockAPI *MockGoogleCalendarLowLevelAPI) *GoogleCalendarConfig { if mockAPI != nil && mockAPI.ConfigureFunc == nil { mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error { @@ -125,6 +148,13 @@ func TestGoogleCalendar_DeleteEvent(t *testing.T) { } err = cal.DeleteEvent(&fleet.CalendarEvent{Data: []byte(`{"ID":"event-id"}`)}) assert.ErrorIs(t, err, assert.AnError) + + // Event already deleted + mockAPI.DeleteEventFunc = func(id string) error { + return &googleapi.Error{Code: http.StatusGone} + } + err = cal.DeleteEvent(&fleet.CalendarEvent{Data: []byte(`{"ID":"event-id"}`)}) + assert.NoError(t, err) } func TestGoogleCalendar_unmarshalDetails(t *testing.T) { diff --git a/ee/server/calendar/load_test/calendar_http_handler.go b/ee/server/calendar/load_test/calendar_http_handler.go new file mode 100644 index 0000000000..e69c945523 --- /dev/null +++ b/ee/server/calendar/load_test/calendar_http_handler.go @@ -0,0 +1,343 @@ +// Package calendartest is not imported in production code, so it will not be compiled for Fleet server. +package calendartest + +import ( + "context" + "crypto/md5" //nolint:gosec // (only used in testing) + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + _ "github.com/mattn/go-sqlite3" + "google.golang.org/api/calendar/v3" + "hash/fnv" + "io" + "log" + "net/http" + "os" + "time" +) + +// This calendar does not support all-day events. + +var db *sql.DB +var timezones = []string{ + "America/Chicago", + "America/New_York", + "America/Los_Angeles", + "America/Anchorage", + "Pacific/Honolulu", + "America/Argentina/Buenos_Aires", + "Asia/Kolkata", + "Europe/London", + "Europe/Paris", + "Australia/Sydney", +} + +func Configure(dbPath string) (http.Handler, error) { + var err error + db, err = sql.Open("sqlite3", dbPath) + if err != nil { + log.Fatal(err) + } + + logger := log.New(os.Stdout, "", log.LstdFlags) + logger.Println("Server is starting...") + + // Initialize the database schema if needed + err = initializeSchema() + if err != nil { + return nil, err + } + + router := http.NewServeMux() + router.HandleFunc("/settings", getSetting) + router.HandleFunc("/events", getEvent) + router.HandleFunc("/events/list", getEvents) + router.HandleFunc("/events/add", addEvent) + router.HandleFunc("/events/delete", deleteEvent) + return logging(logger)(router), nil +} + +func logging(logger *log.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + defer func() { + logger.Println(r.Method, r.URL.String(), r.RemoteAddr) + }() + next.ServeHTTP(w, r) + }, + ) + } +} + +func Close() { + _ = db.Close() +} + +func getSetting(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "missing name", http.StatusBadRequest) + return + } + if name != "timezone" { + http.Error(w, "unsupported setting", http.StatusNotFound) + return + } + email := r.URL.Query().Get("email") + if email == "" { + http.Error(w, "missing email", http.StatusBadRequest) + return + } + timezone := getTimezone(email) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + setting := calendar.Setting{Value: timezone} + err := json.NewEncoder(w).Encode(setting) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +// The timezone is determined by the user's email address +func getTimezone(email string) string { + index := hash(email) % uint32(len(timezones)) + timezone := timezones[index] + return timezone +} + +func hash(s string) uint32 { + h := fnv.New32a() + _, _ = h.Write([]byte(s)) + return h.Sum32() +} + +// getEvent handles GET /events?id=123 +func getEvent(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "missing id", http.StatusBadRequest) + return + } + sqlStmt := "SELECT email, start, end, summary, description, status FROM events WHERE id = ?" + var start, end int64 + var email, summary, description, status string + err := db.QueryRow(sqlStmt, id).Scan(&email, &start, &end, &summary, &description, &status) + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + timezone := getTimezone(email) + loc, err := time.LoadLocation(timezone) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + calEvent := calendar.Event{} + calEvent.Id = id + calEvent.Start = &calendar.EventDateTime{DateTime: time.Unix(start, 0).In(loc).Format(time.RFC3339)} + calEvent.End = &calendar.EventDateTime{DateTime: time.Unix(end, 0).In(loc).Format(time.RFC3339)} + calEvent.Summary = summary + calEvent.Description = description + calEvent.Status = status + calEvent.Etag = computeETag(start, end, summary, description, status) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err = json.NewEncoder(w).Encode(calEvent) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func getEvents(w http.ResponseWriter, r *http.Request) { + email := r.URL.Query().Get("email") + if email == "" { + http.Error(w, "missing email", http.StatusBadRequest) + return + } + timeMin := r.URL.Query().Get("timemin") + if email == "" { + http.Error(w, "missing timemin", http.StatusBadRequest) + return + } + timeMax := r.URL.Query().Get("timemax") + if email == "" { + http.Error(w, "missing timemax", http.StatusBadRequest) + return + } + minTime, err := parseDateTime(r.Context(), &calendar.EventDateTime{DateTime: timeMin}) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + maxTime, err := parseDateTime(r.Context(), &calendar.EventDateTime{DateTime: timeMax}) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + sqlStmt := "SELECT id, start, end, summary, description, status FROM events WHERE email = ? AND end > ? AND start < ?" + rows, err := db.Query(sqlStmt, email, minTime.Unix(), maxTime.Unix()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + timezone := getTimezone(email) + loc, err := time.LoadLocation(timezone) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + events := calendar.Events{} + events.Items = make([]*calendar.Event, 0) + for rows.Next() { + var id, start, end int64 + var summary, description, status string + err = rows.Scan(&id, &start, &end, &summary, &description, &status) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + calEvent := calendar.Event{} + calEvent.Id = fmt.Sprintf("%d", id) + calEvent.Start = &calendar.EventDateTime{DateTime: time.Unix(start, 0).In(loc).Format(time.RFC3339)} + calEvent.End = &calendar.EventDateTime{DateTime: time.Unix(end, 0).In(loc).Format(time.RFC3339)} + calEvent.Summary = summary + calEvent.Description = description + calEvent.Status = status + calEvent.Etag = computeETag(start, end, summary, description, status) + events.Items = append(events.Items, &calEvent) + } + if err = rows.Err(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err = json.NewEncoder(w).Encode(events) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +// addEvent handles POST /events/add?email=user@example.com +func addEvent(w http.ResponseWriter, r *http.Request) { + var event calendar.Event + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + err = json.Unmarshal(body, &event) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + email := r.URL.Query().Get("email") + if email == "" { + http.Error(w, "missing email", http.StatusBadRequest) + return + } + start, err := parseDateTime(r.Context(), event.Start) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + end, err := parseDateTime(r.Context(), event.End) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + status := "confirmed" + sqlStmt := `INSERT INTO events (email, start, end, summary, description, status) VALUES (?, ?, ?, ?, ?, ?)` + result, err := db.Exec(sqlStmt, email, start.Unix(), end.Unix(), event.Summary, event.Description, status) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + id, err := result.LastInsertId() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + event.Id = fmt.Sprintf("%d", id) + event.Etag = computeETag(start.Unix(), end.Unix(), event.Summary, event.Description, status) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + err = json.NewEncoder(w).Encode(event) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func computeETag(args ...any) string { + h := md5.New() //nolint:gosec // (only used for tests) + _, _ = fmt.Fprint(h, args...) + checksum := h.Sum(nil) + return hex.EncodeToString(checksum) +} + +// deleteEvent handles DELETE /events/delete?id=123 +func deleteEvent(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "missing id", http.StatusBadRequest) + return + } + sqlStmt := "DELETE FROM events WHERE id = ?" + _, err := db.Exec(sqlStmt, id) + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "not found", http.StatusGone) + return + } +} + +func initializeSchema() error { + createTableSQL := `CREATE TABLE IF NOT EXISTS events ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "email" TEXT NOT NULL, + "start" INTEGER NOT NULL, + "end" INTEGER NOT NULL, + "summary" TEXT NOT NULL, + "description" TEXT NOT NULL, + "status" TEXT NOT NULL + );` + _, err := db.Exec(createTableSQL) + if err != nil { + return fmt.Errorf("failed to create table: %w", err) + } + return nil +} + +func parseDateTime(ctx context.Context, eventDateTime *calendar.EventDateTime) (*time.Time, error) { + var t time.Time + var err error + if eventDateTime.TimeZone != "" { + var loc *time.Location + loc, err = time.LoadLocation(eventDateTime.TimeZone) + if err == nil { + t, err = time.ParseInLocation(time.RFC3339, eventDateTime.DateTime, loc) + } + } else { + t, err = time.Parse(time.RFC3339, eventDateTime.DateTime) + } + if err != nil { + return nil, ctxerr.Wrap( + ctx, err, fmt.Sprintf("parsing calendar event time: %s", eventDateTime.DateTime), + ) + } + return &t, nil +} diff --git a/tools/calendar/README.md b/tools/calendar/README.md new file mode 100644 index 0000000000..5d222e45de --- /dev/null +++ b/tools/calendar/README.md @@ -0,0 +1,26 @@ +# Calendar server for load testing + +Test calendar server that provides a REST API for managing events. +Since we may not have access to a real calendar server (such as Google Calendar API), this server will be used to test the calendar feature during load testing. + +Start the server like: +```shell +go run calendar.go --port 8083 --db ./calendar.db +``` + +The server uses a SQLite database to store events. This database can be modified during testing. + +On the fleet server, configure Google Calendar API key where `client_email` is the specified value and the `private_key` is the base URL of the calendar server: +```json +{ + "client_email": "calendar-load@example.com", + "private_key": "http://localhost:8083" +} +``` + +## Useful tricks + +To update all the events in SQLite database to start at the current time, do SQL query: +```sql +UPDATE events SET start = unixepoch('now'), end = unixepoch('now', '+30 minutes'); +``` diff --git a/tools/calendar/calendar.go b/tools/calendar/calendar.go new file mode 100644 index 0000000000..64b70c7a94 --- /dev/null +++ b/tools/calendar/calendar.go @@ -0,0 +1,39 @@ +package main + +import ( + "flag" + "fmt" + calendartest "github.com/fleetdm/fleet/v4/ee/server/calendar/load_test" + _ "github.com/mattn/go-sqlite3" + "log" + "net/http" + "os" + "time" +) + +func main() { + port := flag.Uint("port", 8083, "Port to listen on") + dbFileName := flag.String("db", "./calendar.db", "SQLite db file name") + flag.Parse() + + handler, err := calendartest.Configure(*dbFileName) + if err != nil { + log.Fatal(err) + } + defer calendartest.Close() + + listenAddr := fmt.Sprintf(":%d", *port) + errLogger := log.New(os.Stderr, "", log.LstdFlags) + + server := &http.Server{ + Addr: listenAddr, + Handler: handler, + ErrorLog: errLogger, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 15 * time.Second, + } + + // Start the HTTP server + log.Fatal(server.ListenAndServe()) +}