diff --git a/docs/adapters.md b/docs/adapters.md index a36d05e..86b86b0 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -18,13 +18,14 @@ source: The ZEP adapter is only supported as a source. ## Outlook Adapter Setup + The Outlook calendar is synchronized via Microsoft Graph API. You will need to [register an application on Azure](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app). The application needs the following permissions: -* `Calendar.ReadWrite` +* `Calendars.ReadWrite` -The `User.read` permission should be assigned by default. To assign the `Calendar.ReadWrite` permission, click on "API Permissions" and add the permission to the "Microsoft Graph API". +The `User.Read` permission should be assigned by default. To assign the `Calendars.ReadWrite` permission, click on "API Permissions" and add the delegated permission to the "Microsoft Graph API". You also need to setup a platform specific configuration. This can be done in the "Authentication" menu. Add a "mobile and desktop application" platform configuration and add `http://localhost/redirect` as a valid redirect uri. @@ -40,13 +41,35 @@ source: adapter: type: "outlook_http" calendar: "[base64-format string here]" - config: + oAuth: tenantId: "[UUID-format string here]" clientId: "[UUID-format string here]" ``` To get your calendar ID, use the [Microsoft Graph Explorer](https://developer.microsoft.com/en-us/graph/graph-explorer) and query `GET https://graph.microsoft.com/v1.0/me/calendar`. +### Shared Mailboxes and delegated calendars + +By default, the adapter accesses `/me/calendars/{calendarID}`. To target a Microsoft 365 Shared Mailbox or another delegated user's calendar, set `config.user` to that mailbox's user principal name (UPN) or Entra object ID: + +```yaml +source: + adapter: + type: "outlook_http" + calendar: "[shared-calendar-id]" + config: + user: "shared-mailbox@example.com" + oAuth: + tenantId: "[UUID-format string here]" + clientId: "[UUID-format string here]" +``` + +CalendarSync still authenticates an interactive user, not the Shared Mailbox itself. That user must have the appropriate Exchange Online mailbox or calendar delegation for the target mailbox. Microsoft Graph's delegated `Calendars.Read.Shared` permission is sufficient for read-only access. CalendarSync requests `Calendars.ReadWrite.Shared` in addition to `Calendars.ReadWrite` when `config.user` is set because the Outlook adapter can also be used as a sink. The Graph permission does not grant access unless the mailbox delegation is also configured. See Microsoft's documentation for [accessing shared or delegated Outlook calendars](https://learn.microsoft.com/en-us/graph/outlook-get-shared-events-calendars) and [creating events in shared or delegated calendars](https://learn.microsoft.com/en-us/graph/outlook-create-event-in-shared-delegated-calendar). The same `config.user` mechanism can target a delegated normal user mailbox. + +Microsoft Graph does not allow delegates to create events with open extensions in Shared Mailbox calendars. CalendarSync therefore stores its synchronization metadata in a single-value legacy extended property whenever `config.user` is set. Existing `/me` configurations continue to use open extensions without changing their stored metadata. See Microsoft's [open-extension limitations](https://learn.microsoft.com/en-us/graph/extensibility-overview#comparison-of-extension-types). + +If `config.user` is omitted or empty, the existing `/me` behavior and permissions remain unchanged. When enabling it for an existing configuration, remove the affected CalendarSync authentication entry using the normal auth-storage workflow and authenticate again so the new shared-calendar permission can be granted. Do not edit encrypted authentication storage manually. + ## Google Adapter Setup diff --git a/example.sync.yaml b/example.sync.yaml index 099cedd..1182687 100644 --- a/example.sync.yaml +++ b/example.sync.yaml @@ -22,6 +22,9 @@ source: adapter: type: "outlook_http" calendar: "[base64-format string here]" + # Optional: target a shared/delegated mailbox instead of /me. + # config: + # user: "shared-mailbox@example.com" oAuth: clientId: "[UUID-format string here]" tenantId: "[UUID-format string here]" diff --git a/internal/adapter/adapter_test.go b/internal/adapter/adapter_test.go new file mode 100644 index 0000000..efe992e --- /dev/null +++ b/internal/adapter/adapter_test.go @@ -0,0 +1,35 @@ +package adapter + +import ( + "context" + "testing" + + "github.com/charmbracelet/log" + "github.com/stretchr/testify/require" + + "github.com/inovex/CalendarSync/internal/config" +) + +func TestNewSourceAdapterConfiguresBeforeOAuth(t *testing.T) { + adapterConfig := config.NewAdapterConfig(config.Adapter{ + Type: string(OutlookHttpCalendarType), + Calendar: "calendar-id", + Config: config.CustomMap{"user": 42}, + }) + + _, err := NewSourceAdapterFromConfig(context.Background(), 0, false, adapterConfig, nil, log.Default()) + + require.EqualError(t, err, "Outlook adapter config 'user' must be a string") +} + +func TestNewSinkAdapterConfiguresBeforeOAuth(t *testing.T) { + adapterConfig := config.NewAdapterConfig(config.Adapter{ + Type: string(OutlookHttpCalendarType), + Calendar: "calendar-id", + Config: config.CustomMap{"user": 42}, + }) + + _, err := NewSinkAdapterFromConfig(context.Background(), 0, false, adapterConfig, nil, log.Default()) + + require.EqualError(t, err, "Outlook adapter config 'user' must be a string") +} diff --git a/internal/adapter/outlook_http/adapter.go b/internal/adapter/outlook_http/adapter.go index c3ddc5b..7ebcf83 100644 --- a/internal/adapter/outlook_http/adapter.go +++ b/internal/adapter/outlook_http/adapter.go @@ -30,6 +30,7 @@ type OutlookCalendarClient interface { type CalendarAPI struct { outlookClient OutlookCalendarClient calendarID string + user string oAuthConfig *oauth2.Config authenticated bool @@ -44,6 +45,7 @@ type CalendarAPI struct { // Assert that the expected interfaces are implemented var _ port.Configurable = &CalendarAPI{} +var _ port.ConfigSetter = &CalendarAPI{} var _ port.LogSetter = &CalendarAPI{} var _ port.CalendarIDSetter = &CalendarAPI{} var _ port.OAuth2Adapter = &CalendarAPI{} @@ -56,6 +58,21 @@ func (c *CalendarAPI) SetCalendarID(calendarID string) error { return nil } +func (c *CalendarAPI) SetConfig(config map[string]interface{}) error { + user, configured := config["user"] + if !configured { + c.user = "" + return nil + } + + configuredUser, ok := user.(string) + if !ok { + return fmt.Errorf("%s adapter config 'user' must be a string", c.Name()) + } + c.user = configuredUser + return nil +} + func (c *CalendarAPI) SetupOauth2(ctx context.Context, credentials auth.Credentials, storage auth.Storage, bindPort uint) error { // Outlook Adapter does not need the clientKey switch { @@ -71,10 +88,16 @@ func (c *CalendarAPI) SetupOauth2(ctx context.Context, credentials auth.Credenti AuthStyle: oauth2.AuthStyleInParams, } + scopes := []string{"Calendars.ReadWrite"} + if c.user != "" { + scopes = append(scopes, "Calendars.ReadWrite.Shared") + } + scopes = append(scopes, "offline_access") + oAuthConfig := oauth2.Config{ ClientID: credentials.Client.Id, Endpoint: endpoint, - Scopes: []string{"Calendars.ReadWrite", "offline_access"}, // You need to request offline_access in order to retrieve a refresh token + Scopes: scopes, // You need to request offline_access in order to retrieve a refresh token } oAuthListener, err := auth.NewOAuthHandler(oAuthConfig, bindPort) @@ -84,7 +107,8 @@ func (c *CalendarAPI) SetupOauth2(ctx context.Context, credentials auth.Credenti c.oAuthHandler = oAuthListener c.storage = storage - c.oAuthConfig = &oAuthConfig + c.oAuthConfig = c.oAuthHandler.Configuration() + c.oAuthUrl = c.oAuthConfig.AuthCodeURL("state", oauth2.AccessTypeOffline) storedAuth, err := c.storage.ReadCalendarAuth(c.calendarID) if err != nil { @@ -166,10 +190,8 @@ func (c *CalendarAPI) SetupOauth2(ctx context.Context, credentials auth.Credenti return nil } -func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, config map[string]interface{}) error { +func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, _ map[string]interface{}) error { if !c.authenticated { - c.oAuthUrl = c.oAuthHandler.Configuration().AuthCodeURL("state", oauth2.AccessTypeOffline) - if openBrowser { c.logger.Infof("opening browser window for authentication of %s\n", c.Name()) err := browser.OpenURL(c.oAuthUrl) @@ -202,7 +224,7 @@ func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, config m client := c.oAuthConfig.Client(ctx, c.oAuthToken) - c.outlookClient = &OutlookClient{Client: client, CalendarID: c.calendarID} + c.outlookClient = &OutlookClient{Client: client, CalendarID: c.calendarID, User: c.user} return nil } diff --git a/internal/adapter/outlook_http/adapter_test.go b/internal/adapter/outlook_http/adapter_test.go new file mode 100644 index 0000000..ee7d2bf --- /dev/null +++ b/internal/adapter/outlook_http/adapter_test.go @@ -0,0 +1,117 @@ +package outlook_http + +import ( + "context" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inovex/CalendarSync/internal/auth" + "github.com/inovex/CalendarSync/internal/config" +) + +type memoryStorage struct{} + +func (memoryStorage) WriteCalendarAuth(auth.CalendarAuth) (bool, error) { + return true, nil +} + +func (memoryStorage) ReadCalendarAuth(string) (*auth.CalendarAuth, error) { + return nil, nil +} + +func (memoryStorage) RemoveCalendarAuth(string) error { + return nil +} + +func (memoryStorage) Setup(config.AuthStorage, string) error { + return nil +} + +func TestCalendarAPISetConfig(t *testing.T) { + tests := []struct { + name string + config map[string]interface{} + expectedUser string + expectedError string + }{ + { + name: "absent user", + }, + { + name: "empty user", + config: map[string]interface{}{"user": ""}, + }, + { + name: "shared mailbox", + config: map[string]interface{}{"user": "shared-mailbox@example.com"}, + expectedUser: "shared-mailbox@example.com", + }, + { + name: "non-string user", + config: map[string]interface{}{"user": 42}, + expectedError: "Outlook adapter config 'user' must be a string", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + calendarAPI := &CalendarAPI{} + + err := calendarAPI.SetConfig(test.config) + + if test.expectedError != "" { + require.EqualError(t, err, test.expectedError) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedUser, calendarAPI.user) + }) + } +} + +func TestCalendarAPIAuthorizationURLScopes(t *testing.T) { + tests := []struct { + name string + config map[string]interface{} + expectedScopes []string + }{ + { + name: "current user", + expectedScopes: []string{"Calendars.ReadWrite", "offline_access"}, + }, + { + name: "shared mailbox", + config: map[string]interface{}{"user": "shared-mailbox@example.com"}, + expectedScopes: []string{"Calendars.ReadWrite", "Calendars.ReadWrite.Shared", "offline_access"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + calendarAPI := &CalendarAPI{calendarID: "calendar-id"} + require.NoError(t, calendarAPI.SetConfig(test.config)) + + err := calendarAPI.SetupOauth2( + context.Background(), + auth.Credentials{ + Client: auth.Client{Id: "client-id"}, + Tenant: auth.Tenant{Id: "tenant-id"}, + }, + memoryStorage{}, + 0, + ) + require.NoError(t, err) + + authorizationURL, err := url.Parse(calendarAPI.oAuthUrl) + require.NoError(t, err) + urlScopes := strings.Fields(authorizationURL.Query().Get("scope")) + + assert.ElementsMatch(t, test.expectedScopes, urlScopes) + assert.ElementsMatch(t, test.expectedScopes, calendarAPI.oAuthConfig.Scopes) + }) + } +} diff --git a/internal/adapter/outlook_http/client.go b/internal/adapter/outlook_http/client.go index 2064e74..8b0650c 100644 --- a/internal/adapter/outlook_http/client.go +++ b/internal/adapter/outlook_http/client.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "time" "github.com/charmbracelet/log" @@ -19,12 +20,32 @@ import ( const ( ExtensionOdataType = "microsoft.graph.openTypeExtension" ExtensionName = "inovex.calendarsync.meta" + // This identifier must remain stable so every CalendarSync build recognizes shared-mailbox metadata. + // Its UUID is UUIDv5(URL, "https://github.com/inovex/CalendarSync/outlook-metadata"). + singleValueMetadataID = "String {23f8dbef-16e9-5e2c-8cc7-e7f020136a50} Name " + ExtensionName + openExtensionExpand = "extensions($filter=Id%20eq%20'inovex.calendarsync.meta')" + singleValueMetadataExpand = "singleValueExtendedProperties($filter=id%20eq%20'String%20%7B23f8dbef-16e9-5e2c-8cc7-e7f020136a50%7D%20Name%20inovex.calendarsync.meta')" ) // OutlookClient implements the OutlookCalendarClient interface type OutlookClient struct { Client *http.Client CalendarID string + User string +} + +func (o OutlookClient) calendarPath() string { + if o.User == "" { + return "/me/calendars/" + o.CalendarID + } + return "/users/" + url.PathEscape(o.User) + "/calendars/" + o.CalendarID +} + +func (o OutlookClient) metadataExpand() string { + if o.User == "" { + return openExtensionExpand + } + return singleValueMetadataExpand } func (o *OutlookClient) ListEvents(ctx context.Context, start time.Time, end time.Time) ([]models.Event, error) { @@ -33,9 +54,9 @@ func (o *OutlookClient) ListEvents(ctx context.Context, start time.Time, end tim // Query can't simply be encoded with the url package for example, microsoft also uses its own encoding here. // Otherwise this always ends in a 500 return code, see also https://stackoverflow.com/a/62770941 - query := "?startDateTime=" + startDate + "&endDateTime=" + endDate + "&$expand=extensions($filter=Id%20eq%20'inovex.calendarsync.meta')" + query := "?startDateTime=" + startDate + "&endDateTime=" + endDate + "&$expand=" + o.metadataExpand() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseUrl+"/me/calendars/"+o.CalendarID+"/CalendarView"+query, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseUrl+o.calendarPath()+"/CalendarView"+query, nil) if err != nil { return nil, err } @@ -49,15 +70,13 @@ func (o *OutlookClient) ListEvents(ctx context.Context, start time.Time, end tim return nil, err } - if resp.StatusCode != http.StatusOK { - return nil, err - } - - body, _ := io.ReadAll(resp.Body) - err = resp.Body.Close() + body, err := readResponseBody(resp) if err != nil { return nil, err } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status code at event listing was not 200, got status code %d, response: %s", resp.StatusCode, string(body)) + } var eventList EventList err = json.Unmarshal(body, &eventList) @@ -72,11 +91,13 @@ func (o *OutlookClient) ListEvents(ctx context.Context, start time.Time, end tim return nil, err } - body, _ := io.ReadAll(resp.Body) - err = resp.Body.Close() + body, err := readResponseBody(resp) if err != nil { return nil, err } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status code at paginated event listing was not 200, got status code %d, response: %s", resp.StatusCode, string(body)) + } var nextList EventList err = json.Unmarshal(body, &nextList) @@ -110,7 +131,7 @@ func (o *OutlookClient) CreateEvent(ctx context.Context, event models.Event) err return err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseUrl+"/me/calendars/"+o.CalendarID+"/events", bytes.NewBuffer(by)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseUrl+o.calendarPath()+"/events", bytes.NewBuffer(by)) if err != nil { return err } @@ -151,7 +172,7 @@ func (o *OutlookClient) UpdateEvent(ctx context.Context, event models.Event) err return err } - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, baseUrl+"/me/calendars/"+o.CalendarID+"/events/"+event.ID, bytes.NewBuffer(by)) + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, baseUrl+o.calendarPath()+"/events/"+event.ID, bytes.NewBuffer(by)) if err != nil { return err } @@ -180,21 +201,44 @@ func (o *OutlookClient) UpdateEvent(ctx context.Context, event models.Event) err func (o *OutlookClient) DeleteEvent(ctx context.Context, event models.Event) error { // https://learn.microsoft.com/en-us/graph/api/event-delete?view=graph-rest-1.0&tabs=http - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, baseUrl+"/me/calendars/"+o.CalendarID+"/events/"+event.ID, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, baseUrl+o.calendarPath()+"/events/"+event.ID, nil) if err != nil { return err } - _, err = o.Client.Do(req) + resp, err := o.Client.Do(req) if err != nil { return err } + body, err := readResponseBody(resp) + if err != nil { + return err + } + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("status code at event deletion was not 204, got status code %d, response: %s", resp.StatusCode, string(body)) + } return nil } +func readResponseBody(response *http.Response) ([]byte, error) { + body, err := io.ReadAll(response.Body) + if err != nil { + _ = response.Body.Close() + return nil, err + } + if err := response.Body.Close(); err != nil { + return nil, err + } + return body, nil +} + func (o OutlookClient) GetCalendarHash() string { var id []byte - sum := sha1.Sum([]byte(o.CalendarID)) + identity := o.CalendarID + if o.User != "" { + identity = o.User + "\x00" + o.CalendarID + } + sum := sha1.Sum([]byte(identity)) id = append(id, sum[:]...) return base64.URLEncoding.EncodeToString(id) } @@ -219,17 +263,24 @@ func (o OutlookClient) eventToOutlookEvent(e models.Event) (oe Event) { outlookEvent.Body.ContentType = "text" } - calendarSyncExtension := &Extensions{ - OdataType: ExtensionOdataType, - ExtensionName: ExtensionName, - - Metadata: models.Metadata{ - SyncID: e.Metadata.SyncID, - SourceID: e.Metadata.SourceID, - OriginalEventUri: e.Metadata.OriginalEventUri, - }, + calendarSyncMetadata := models.Metadata{ + SyncID: e.Metadata.SyncID, + SourceID: e.Metadata.SourceID, + OriginalEventUri: e.Metadata.OriginalEventUri, + } + if o.User == "" { + outlookEvent.Extensions = append(outlookEvent.Extensions, Extensions{ + OdataType: ExtensionOdataType, + ExtensionName: ExtensionName, + Metadata: calendarSyncMetadata, + }) + } else { + metadataValue, _ := json.Marshal(calendarSyncMetadata) + outlookEvent.SingleValueExtendedProperties = append(outlookEvent.SingleValueExtendedProperties, SingleValueExtendedProperty{ + ID: singleValueMetadataID, + Value: string(metadataValue), + }) } - outlookEvent.Extensions = append(outlookEvent.Extensions, *calendarSyncExtension) for _, att := range e.Attendees { outlookEvent.Attendees = append(outlookEvent.Attendees, Attendee{ @@ -289,6 +340,10 @@ func (o OutlookClient) outlookEventToEvent(oe Event, adapterSourceID string) (e if oe.ResponseStatus.Response == "declined" { hasEventAccepted = false } + metadata, err := ensureMetadata(oe, adapterSourceID) + if err != nil { + return bufEvent, err + } bufEvent = models.Event{ ICalUID: oe.UID, @@ -298,7 +353,7 @@ func (o OutlookClient) outlookEventToEvent(oe Event, adapterSourceID string) (e Location: oe.Location.Name, StartTime: startTime, EndTime: endTime, - Metadata: ensureMetadata(oe, adapterSourceID), + Metadata: metadata, Attendees: attendees, Reminders: reminders, MeetingLink: oe.OnlineMeetingUrl, @@ -315,15 +370,28 @@ func (o OutlookClient) outlookEventToEvent(oe Event, adapterSourceID string) (e // Adding metadata is a bit more complicated as in the google adapter // see also: https://learn.microsoft.com/en-us/graph/api/opentypeextension-post-opentypeextension?view=graph-rest-1.0&tabs=http // Retrieve metadata if possible otherwise regenerate it -func ensureMetadata(event Event, adapterSourceID string) *models.Metadata { +func ensureMetadata(event Event, adapterSourceID string) (*models.Metadata, error) { for _, extension := range event.Extensions { if extension.ExtensionName == ExtensionName && (len(extension.SyncID) != 0 && len(extension.SourceID) != 0) { return &models.Metadata{ SyncID: extension.SyncID, OriginalEventUri: extension.OriginalEventUri, SourceID: extension.SourceID, - } + }, nil + } + } + for _, property := range event.SingleValueExtendedProperties { + if property.ID != singleValueMetadataID { + continue + } + var metadata models.Metadata + if err := json.Unmarshal([]byte(property.Value), &metadata); err != nil { + return nil, fmt.Errorf("cannot decode Outlook event metadata: %w", err) + } + if metadata.SyncID == "" || metadata.SourceID == "" { + return nil, fmt.Errorf("cannot decode Outlook event metadata: SyncID and SourceID must not be empty") } + return &metadata, nil } - return models.NewEventMetadata(event.ID, event.HtmlLink, adapterSourceID) + return models.NewEventMetadata(event.ID, event.HtmlLink, adapterSourceID), nil } diff --git a/internal/adapter/outlook_http/client_test.go b/internal/adapter/outlook_http/client_test.go new file mode 100644 index 0000000..ac56c51 --- /dev/null +++ b/internal/adapter/outlook_http/client_test.go @@ -0,0 +1,341 @@ +package outlook_http + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inovex/CalendarSync/internal/models" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +func TestOutlookClientCalendarPath(t *testing.T) { + tests := []struct { + name string + user string + expectedPath string + }{ + { + name: "current user", + expectedPath: "/me/calendars/calendar-id", + }, + { + name: "shared mailbox", + user: "shared-mailbox@example.com", + expectedPath: "/users/shared-mailbox@example.com/calendars/calendar-id", + }, + { + name: "escaped user path segment", + user: "shared/mailbox@example.com", + expectedPath: "/users/shared%2Fmailbox@example.com/calendars/calendar-id", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := OutlookClient{CalendarID: "calendar-id", User: test.user} + request, err := http.NewRequest(http.MethodGet, "https://example.com"+client.calendarPath(), nil) + require.NoError(t, err) + + assert.Equal(t, test.expectedPath, request.URL.EscapedPath()) + }) + } +} + +func TestOutlookClientRequestsUseCalendarPath(t *testing.T) { + start := time.Date(2026, time.July, 20, 8, 0, 0, 0, time.UTC) + end := start.Add(time.Hour) + event := models.Event{ + ID: "event-id", + StartTime: start, + EndTime: end, + Metadata: &models.Metadata{ + SyncID: "sync-id", + OriginalEventUri: "event-uri", + SourceID: "source-id", + }, + } + + users := []struct { + name string + user string + basePath string + expectedExpand string + }{ + { + name: "current user", + basePath: "/v1.0/me/calendars/calendar-id", + expectedExpand: "extensions($filter=Id eq 'inovex.calendarsync.meta')", + }, + { + name: "shared mailbox", + user: "shared-mailbox@example.com", + basePath: "/v1.0/users/shared-mailbox@example.com/calendars/calendar-id", + expectedExpand: "singleValueExtendedProperties($filter=id eq 'String {23f8dbef-16e9-5e2c-8cc7-e7f020136a50} Name inovex.calendarsync.meta')", + }, + } + operations := []struct { + name string + method string + suffix string + responseStatus int + responseBody string + invoke func(*OutlookClient) error + }{ + { + name: "list", + method: http.MethodGet, + suffix: "/CalendarView", + responseStatus: http.StatusOK, + responseBody: `{"value":[]}`, + invoke: func(client *OutlookClient) error { + _, err := client.ListEvents(context.Background(), start, end) + return err + }, + }, + { + name: "create", + method: http.MethodPost, + suffix: "/events", + responseStatus: http.StatusCreated, + invoke: func(client *OutlookClient) error { + return client.CreateEvent(context.Background(), event) + }, + }, + { + name: "update", + method: http.MethodPatch, + suffix: "/events/event-id", + responseStatus: http.StatusOK, + responseBody: `{}`, + invoke: func(client *OutlookClient) error { + return client.UpdateEvent(context.Background(), event) + }, + }, + { + name: "delete", + method: http.MethodDelete, + suffix: "/events/event-id", + responseStatus: http.StatusNoContent, + invoke: func(client *OutlookClient) error { + return client.DeleteEvent(context.Background(), event) + }, + }, + } + + for _, user := range users { + for _, operation := range operations { + t.Run(user.name+" "+operation.name, func(t *testing.T) { + requestSeen := false + httpClient := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + requestSeen = true + assert.Equal(t, operation.method, request.Method) + assert.Equal(t, user.basePath+operation.suffix, request.URL.EscapedPath()) + + if operation.name == "list" { + assert.Equal(t, start.Format(timeFormat), request.URL.Query().Get("startDateTime")) + assert.Equal(t, end.Format(timeFormat), request.URL.Query().Get("endDateTime")) + assert.Equal(t, user.expectedExpand, request.URL.Query().Get("$expand")) + assert.Equal(t, `outlook.timezone="UTC"`, request.Header.Get("Prefer")) + } + if operation.name == "create" || operation.name == "update" { + assert.Equal(t, "application/json", request.Header.Get("Content-Type")) + + body, err := io.ReadAll(request.Body) + require.NoError(t, err) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(body, &payload)) + + if user.user == "" { + require.NotContains(t, payload, "singleValueExtendedProperties") + require.Contains(t, payload, "extensions") + extensions := payload["extensions"].([]interface{}) + require.Len(t, extensions, 1) + extension := extensions[0].(map[string]interface{}) + assert.Equal(t, "inovex.calendarsync.meta", extension["extensionName"]) + assert.Equal(t, "sync-id", extension["SyncID"]) + assert.Equal(t, "event-uri", extension["OriginalEventUri"]) + assert.Equal(t, "source-id", extension["SourceID"]) + } else { + require.NotContains(t, payload, "extensions") + require.Contains(t, payload, "singleValueExtendedProperties") + properties := payload["singleValueExtendedProperties"].([]interface{}) + require.Len(t, properties, 1) + property := properties[0].(map[string]interface{}) + assert.Equal(t, "String {23f8dbef-16e9-5e2c-8cc7-e7f020136a50} Name inovex.calendarsync.meta", property["id"]) + + var metadata map[string]string + require.NoError(t, json.Unmarshal([]byte(property["value"].(string)), &metadata)) + assert.Equal(t, map[string]string{ + "SyncID": "sync-id", + "OriginalEventUri": "event-uri", + "SourceID": "source-id", + }, metadata) + } + } + + return &http.Response{ + StatusCode: operation.responseStatus, + Body: io.NopCloser(strings.NewReader(operation.responseBody)), + Header: make(http.Header), + }, nil + })} + client := &OutlookClient{Client: httpClient, CalendarID: "calendar-id", User: user.user} + + require.NoError(t, operation.invoke(client)) + assert.True(t, requestSeen) + }) + } + } +} + +func TestOutlookClientCalendarHash(t *testing.T) { + currentUser := OutlookClient{CalendarID: "calendar-id"} + sharedMailbox := OutlookClient{CalendarID: "calendar-id", User: "shared-mailbox@example.com"} + otherMailbox := OutlookClient{CalendarID: "calendar-id", User: "other-mailbox@example.com"} + + assert.Equal(t, "lL43FOf3Lx8yhz-dV1N6PM3pRSc=", currentUser.GetCalendarHash()) + assert.NotEqual(t, currentUser.GetCalendarHash(), sharedMailbox.GetCalendarHash()) + assert.NotEqual(t, sharedMailbox.GetCalendarHash(), otherMailbox.GetCalendarHash()) + assert.Equal(t, sharedMailbox.GetCalendarHash(), sharedMailbox.GetCalendarHash()) +} + +func TestOutlookClientReadsSingleValueMetadata(t *testing.T) { + outlookEvent := Event{ + ID: "event-id", + HtmlLink: "https://example.com/event", + Start: Time{DateTime: "2026-07-20T08:00:00.0000000", TimeZone: "UTC"}, + End: Time{DateTime: "2026-07-20T09:00:00.0000000", TimeZone: "UTC"}, + SingleValueExtendedProperties: []SingleValueExtendedProperty{ + { + ID: "String {23f8dbef-16e9-5e2c-8cc7-e7f020136a50} Name inovex.calendarsync.meta", + Value: `{"SyncID":"sync-id","OriginalEventUri":"event-uri","SourceID":"source-id"}`, + }, + }, + } + + event, err := (OutlookClient{}).outlookEventToEvent(outlookEvent, "adapter-source-id") + + require.NoError(t, err) + assert.Equal(t, &models.Metadata{ + SyncID: "sync-id", + OriginalEventUri: "event-uri", + SourceID: "source-id", + }, event.Metadata) +} + +func TestOutlookClientRejectsInvalidSingleValueMetadata(t *testing.T) { + tests := []struct { + name string + value string + }{ + {name: "malformed JSON", value: `{not-json}`}, + {name: "missing source identifier", value: `{"SyncID":"sync-id"}`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + outlookEvent := Event{ + ID: "event-id", + Start: Time{DateTime: "2026-07-20T08:00:00.0000000", TimeZone: "UTC"}, + End: Time{DateTime: "2026-07-20T09:00:00.0000000", TimeZone: "UTC"}, + SingleValueExtendedProperties: []SingleValueExtendedProperty{ + { + ID: "String {23f8dbef-16e9-5e2c-8cc7-e7f020136a50} Name inovex.calendarsync.meta", + Value: test.value, + }, + }, + } + + _, err := (OutlookClient{}).outlookEventToEvent(outlookEvent, "adapter-source-id") + + require.ErrorContains(t, err, "cannot decode Outlook event metadata") + }) + } +} + +func TestOutlookClientReturnsGraphErrors(t *testing.T) { + start := time.Date(2026, time.July, 20, 8, 0, 0, 0, time.UTC) + event := models.Event{ID: "event-id"} + tests := []struct { + name string + invoke func(*OutlookClient) error + }{ + { + name: "list", + invoke: func(client *OutlookClient) error { + _, err := client.ListEvents(context.Background(), start, start.Add(time.Hour)) + return err + }, + }, + { + name: "delete", + invoke: func(client *OutlookClient) error { + return client.DeleteEvent(context.Background(), event) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := &OutlookClient{ + CalendarID: "calendar-id", + Client: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"ErrorAccessDenied","message":"Access is denied."}}`)), + Header: make(http.Header), + }, nil + })}, + } + + err := test.invoke(client) + + require.ErrorContains(t, err, "status code 403") + require.ErrorContains(t, err, "ErrorAccessDenied") + }) + } +} + +func TestOutlookClientReturnsGraphErrorFromNextPage(t *testing.T) { + requestCount := 0 + client := &OutlookClient{ + CalendarID: "calendar-id", + Client: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + requestCount++ + if requestCount == 1 { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"@odata.nextLink":"https://graph.microsoft.com/next","value":[]}`)), + Header: make(http.Header), + }, nil + } + return &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"ErrorAccessDenied","message":"Access is denied."}}`)), + Header: make(http.Header), + }, nil + })}, + } + + _, err := client.ListEvents( + context.Background(), + time.Date(2026, time.July, 20, 8, 0, 0, 0, time.UTC), + time.Date(2026, time.July, 20, 9, 0, 0, 0, time.UTC), + ) + + require.ErrorContains(t, err, "status code 403") + require.ErrorContains(t, err, "ErrorAccessDenied") +} diff --git a/internal/adapter/outlook_http/models.go b/internal/adapter/outlook_http/models.go index 1564caf..8385d92 100644 --- a/internal/adapter/outlook_http/models.go +++ b/internal/adapter/outlook_http/models.go @@ -12,22 +12,23 @@ type EventList struct { } type Event struct { - ID string `json:"id"` - UID string `json:"iCalUId"` - ChangeKey string `json:"changeKey"` - HtmlLink string `json:"webLink"` - Subject string `json:"subject"` - Start Time `json:"start"` - End Time `json:"end"` - Body Body `json:"body,omitempty"` - Attendees []Attendee `json:"attendees,omitempty"` - Location Location `json:"location"` - IsReminderOn bool `json:"isReminderOn"` - ReminderMinutesBeforeStart int `json:"reminderMinutesBeforeStart"` - Extensions []Extensions `json:"extensions"` - IsAllDay bool `json:"isAllDay"` - OnlineMeetingUrl string `json:"onlineMeetingUrl"` - ResponseStatus ResponseStatus `json:"responseStatus,omitempty"` + ID string `json:"id"` + UID string `json:"iCalUId"` + ChangeKey string `json:"changeKey"` + HtmlLink string `json:"webLink"` + Subject string `json:"subject"` + Start Time `json:"start"` + End Time `json:"end"` + Body Body `json:"body,omitempty"` + Attendees []Attendee `json:"attendees,omitempty"` + Location Location `json:"location"` + IsReminderOn bool `json:"isReminderOn"` + ReminderMinutesBeforeStart int `json:"reminderMinutesBeforeStart"` + Extensions []Extensions `json:"extensions,omitempty"` + SingleValueExtendedProperties []SingleValueExtendedProperty `json:"singleValueExtendedProperties,omitempty"` + IsAllDay bool `json:"isAllDay"` + OnlineMeetingUrl string `json:"onlineMeetingUrl"` + ResponseStatus ResponseStatus `json:"responseStatus,omitempty"` } type Extensions struct { @@ -37,6 +38,11 @@ type Extensions struct { models.Metadata } +type SingleValueExtendedProperty struct { + ID string `json:"id"` + Value string `json:"value"` +} + type ResponseStatus struct { Response string `json:"response,omitempty"` // there's an additional field called `time` which returns date and time when the response was returned diff --git a/internal/adapter/port/interface.go b/internal/adapter/port/interface.go index 03a9fba..d7cf322 100644 --- a/internal/adapter/port/interface.go +++ b/internal/adapter/port/interface.go @@ -18,6 +18,11 @@ type CalendarIDSetter interface { SetCalendarID(calendarID string) error } +// ConfigSetter validates and stores adapter configuration before external initialization. +type ConfigSetter interface { + SetConfig(config map[string]interface{}) error +} + // Configurable is an interface which defines how arbitrary configuration data can be passed // to a struct which implements this interface. Clients should be configurable. type Configurable interface { diff --git a/internal/adapter/sink_adapter.go b/internal/adapter/sink_adapter.go index 257cc39..1501ef0 100644 --- a/internal/adapter/sink_adapter.go +++ b/internal/adapter/sink_adapter.go @@ -51,6 +51,12 @@ func NewSinkAdapterFromConfig(ctx context.Context, bindPort uint, openBrowser bo } } + if c, ok := client.(port.ConfigSetter); ok { + if err := c.SetConfig(config.Adapter().Config); err != nil { + return nil, err + } + } + if c, ok := client.(port.OAuth2Adapter); ok { if err := c.SetupOauth2(ctx, auth.Credentials{ diff --git a/internal/adapter/source_adapter.go b/internal/adapter/source_adapter.go index 214e633..ae6276d 100644 --- a/internal/adapter/source_adapter.go +++ b/internal/adapter/source_adapter.go @@ -55,6 +55,12 @@ func NewSourceAdapterFromConfig(ctx context.Context, bindPort uint, openBrowser } } + if c, ok := client.(port.ConfigSetter); ok { + if err := c.SetConfig(config.Adapter().Config); err != nil { + return nil, err + } + } + if c, ok := client.(port.OAuth2Adapter); ok { if err := c.SetupOauth2(ctx, auth.Credentials{