Adding calendar test server and other fixes. (#17751)

- Added a calendar server that can be used for load testing at
/tools/calendar
- Fixed minor calendar bugs

# Checklist for submitter
- [ ] Changes file added for user-visible changes in `changes/` or
`orbit/changes/`.
See [Changes
files](https://fleetdm.com/docs/contributing/committing-changes#changes-files)
for more information.
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Victor Lyuboslavsky 2024-03-22 09:19:55 -05:00 committed by Victor Lyuboslavsky
parent 2940b32a06
commit 16f122f02a
No known key found for this signature in database
8 changed files with 882 additions and 39 deletions

View file

@ -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

View file

@ -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())
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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) {

View file

@ -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
}

26
tools/calendar/README.md Normal file
View file

@ -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');
```

View file

@ -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())
}