diff --git a/packages/twenty-server/src/modules/connected-account-sync-webhooks/drivers/microsoft/microsoft-calendar-notification.handler.ts b/packages/twenty-server/src/modules/connected-account-sync-webhooks/drivers/microsoft/microsoft-calendar-notification.handler.ts index a3577f2da7..95ead2ec60 100644 --- a/packages/twenty-server/src/modules/connected-account-sync-webhooks/drivers/microsoft/microsoft-calendar-notification.handler.ts +++ b/packages/twenty-server/src/modules/connected-account-sync-webhooks/drivers/microsoft/microsoft-calendar-notification.handler.ts @@ -3,7 +3,9 @@ import { timingSafeEqual } from 'crypto'; import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { type LifecycleEventType } from '@microsoft/microsoft-graph-types'; import { isNonEmptyString } from '@sniptt/guards'; +import { type AssertUnreachable } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { In, Repository } from 'typeorm'; @@ -92,21 +94,17 @@ export class MicrosoftCalendarNotificationHandler implements WebhookNotification continue; } - if ( - isNonEmptyString(notification.lifecycleEvent) && - notification.lifecycleEvent !== 'missed' - ) { - try { - await this.calendarWebhookSubscriptionService.renewSubscription({ - calendarChannelId: calendarChannel.id, - workspaceId: calendarChannel.workspaceId, - }); - } catch (error) { + if (isNonEmptyString(notification.lifecycleEvent)) { + await this.handleLifecycleEvent({ + lifecycleEvent: notification.lifecycleEvent, + removedSubscriptionId: notification.subscriptionId, + calendarChannel, + }).catch((error) => this.logger.error( - `Failed to renew calendar subscription for channel ${calendarChannel.id}`, + `Failed to handle ${notification.lifecycleEvent} lifecycle event for calendar channel ${calendarChannel.id}`, error, - ); - } + ), + ); continue; } @@ -120,4 +118,49 @@ export class MicrosoftCalendarNotificationHandler implements WebhookNotification ); } } + + private async handleLifecycleEvent({ + lifecycleEvent, + removedSubscriptionId, + calendarChannel, + }: { + lifecycleEvent: LifecycleEventType; + removedSubscriptionId: string; + calendarChannel: CalendarChannelEntity; + }): Promise { + switch (lifecycleEvent) { + case 'subscriptionRemoved': + await this.calendarWebhookSubscriptionService.recreateSubscription({ + calendarChannelId: calendarChannel.id, + workspaceId: calendarChannel.workspaceId, + removedSubscriptionId, + }); + await this.webhookSyncTriggerService.triggerCalendarSync( + calendarChannel.id, + calendarChannel.workspaceId, + ); + break; + case 'reauthorizationRequired': + await this.calendarWebhookSubscriptionService.renewSubscription({ + calendarChannelId: calendarChannel.id, + workspaceId: calendarChannel.workspaceId, + }); + break; + case 'missed': + await this.webhookSyncTriggerService.triggerCalendarSync( + calendarChannel.id, + calendarChannel.workspaceId, + ); + break; + default: { + const unhandledLifecycleEvent: AssertUnreachable< + typeof lifecycleEvent + > = lifecycleEvent; + + this.logger.warn( + `Ignored unrecognized lifecycle event ${unhandledLifecycleEvent} for calendar channel ${calendarChannel.id}`, + ); + } + } + } } diff --git a/packages/twenty-server/src/modules/connected-account-sync-webhooks/drivers/microsoft/microsoft-messaging-notification.handler.ts b/packages/twenty-server/src/modules/connected-account-sync-webhooks/drivers/microsoft/microsoft-messaging-notification.handler.ts index 83111b0d13..13f0c92a90 100644 --- a/packages/twenty-server/src/modules/connected-account-sync-webhooks/drivers/microsoft/microsoft-messaging-notification.handler.ts +++ b/packages/twenty-server/src/modules/connected-account-sync-webhooks/drivers/microsoft/microsoft-messaging-notification.handler.ts @@ -3,7 +3,9 @@ import { timingSafeEqual } from 'crypto'; import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { type LifecycleEventType } from '@microsoft/microsoft-graph-types'; import { isNonEmptyString } from '@sniptt/guards'; +import { type AssertUnreachable } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { In, Repository } from 'typeorm'; @@ -91,21 +93,17 @@ export class MicrosoftMessagingNotificationHandler implements WebhookNotificatio continue; } - if ( - isNonEmptyString(notification.lifecycleEvent) && - notification.lifecycleEvent !== 'missed' - ) { - try { - await this.messagingWebhookSubscriptionService.renewSubscription({ - messageChannelId: messageChannel.id, - workspaceId: messageChannel.workspaceId, - }); - } catch (error) { + if (isNonEmptyString(notification.lifecycleEvent)) { + await this.handleLifecycleEvent({ + lifecycleEvent: notification.lifecycleEvent, + removedSubscriptionId: notification.subscriptionId, + messageChannel, + }).catch((error) => this.logger.error( - `Failed to renew messaging subscription for channel ${messageChannel.id}`, + `Failed to handle ${notification.lifecycleEvent} lifecycle event for message channel ${messageChannel.id}`, error, - ); - } + ), + ); continue; } @@ -119,4 +117,49 @@ export class MicrosoftMessagingNotificationHandler implements WebhookNotificatio ); } } + + private async handleLifecycleEvent({ + lifecycleEvent, + removedSubscriptionId, + messageChannel, + }: { + lifecycleEvent: LifecycleEventType; + removedSubscriptionId: string; + messageChannel: MessageChannelEntity; + }): Promise { + switch (lifecycleEvent) { + case 'subscriptionRemoved': + await this.messagingWebhookSubscriptionService.recreateSubscription({ + messageChannelId: messageChannel.id, + workspaceId: messageChannel.workspaceId, + removedSubscriptionId, + }); + await this.webhookSyncTriggerService.triggerMessagingSync( + messageChannel.id, + messageChannel.workspaceId, + ); + break; + case 'reauthorizationRequired': + await this.messagingWebhookSubscriptionService.renewSubscription({ + messageChannelId: messageChannel.id, + workspaceId: messageChannel.workspaceId, + }); + break; + case 'missed': + await this.webhookSyncTriggerService.triggerMessagingSync( + messageChannel.id, + messageChannel.workspaceId, + ); + break; + default: { + const unhandledLifecycleEvent: AssertUnreachable< + typeof lifecycleEvent + > = lifecycleEvent; + + this.logger.warn( + `Ignored unrecognized lifecycle event ${unhandledLifecycleEvent} for message channel ${messageChannel.id}`, + ); + } + } + } } diff --git a/packages/twenty-server/src/modules/connected-account-sync-webhooks/types/microsoft-graph-notification.type.ts b/packages/twenty-server/src/modules/connected-account-sync-webhooks/types/microsoft-graph-notification.type.ts index 4d022d3863..c98b980e52 100644 --- a/packages/twenty-server/src/modules/connected-account-sync-webhooks/types/microsoft-graph-notification.type.ts +++ b/packages/twenty-server/src/modules/connected-account-sync-webhooks/types/microsoft-graph-notification.type.ts @@ -1,10 +1,10 @@ -export type MicrosoftGraphNotification = { +import { type ChangeNotification } from '@microsoft/microsoft-graph-types'; + +export type MicrosoftGraphNotification = Omit< + ChangeNotification, + 'subscriptionId' +> & { subscriptionId: string; - clientState?: string; - changeType?: string; - resource?: string; - lifecycleEvent?: string; - resourceData?: { id?: string } | null; }; export type MicrosoftGraphNotificationPayload = { diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception.ts index 92fbb7ef54..7b88c19882 100644 --- a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception.ts +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception.ts @@ -1,6 +1,6 @@ import { type MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; -import { assertUnreachable } from 'twenty-shared/utils'; +import { assertUnreachable, isDefined } from 'twenty-shared/utils'; import { CustomException } from 'src/utils/custom-exception'; @@ -8,15 +8,26 @@ export enum WebhookSubscriptionDriverExceptionCode { PROVIDER_NOT_CONFIGURED = 'PROVIDER_NOT_CONFIGURED', PROVIDER_RESPONSE_INVALID = 'PROVIDER_RESPONSE_INVALID', UNSUPPORTED_PROVIDER = 'UNSUPPORTED_PROVIDER', + NOT_FOUND = 'NOT_FOUND', + INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS', + TEMPORARY_ERROR = 'TEMPORARY_ERROR', + UNKNOWN = 'UNKNOWN', } const getWebhookSubscriptionDriverExceptionUserFriendlyMessage = ( code: WebhookSubscriptionDriverExceptionCode, ) => { switch (code) { + case WebhookSubscriptionDriverExceptionCode.NOT_FOUND: + return msg`The subscription is no longer available on the provider.`; + case WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS: + return msg`The provider denied access to this account. Please reconnect it.`; + case WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR: + return msg`The provider is temporarily unavailable. Please try again later.`; case WebhookSubscriptionDriverExceptionCode.PROVIDER_NOT_CONFIGURED: case WebhookSubscriptionDriverExceptionCode.PROVIDER_RESPONSE_INVALID: case WebhookSubscriptionDriverExceptionCode.UNSUPPORTED_PROVIDER: + case WebhookSubscriptionDriverExceptionCode.UNKNOWN: return msg`The webhook subscription could not be managed for this account.`; default: assertUnreachable(code); @@ -24,15 +35,24 @@ const getWebhookSubscriptionDriverExceptionUserFriendlyMessage = ( }; export class WebhookSubscriptionDriverException extends CustomException { + cause?: unknown; + constructor( message: string, code: WebhookSubscriptionDriverExceptionCode, - { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {}, + { + userFriendlyMessage, + cause, + }: { userFriendlyMessage?: MessageDescriptor; cause?: unknown } = {}, ) { super(message, code, { userFriendlyMessage: userFriendlyMessage ?? getWebhookSubscriptionDriverExceptionUserFriendlyMessage(code), }); + + if (isDefined(cause)) { + this.cause = cause; + } } } diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/google/google-webhook-subscription.driver.dev.spec.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/google/google-webhook-subscription.driver.dev.spec.ts new file mode 100644 index 0000000000..a369c48742 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/google/google-webhook-subscription.driver.dev.spec.ts @@ -0,0 +1,107 @@ +import { google } from 'googleapis'; +import { WebhookSubscriptionChannelType } from 'twenty-shared/types'; + +import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { type GoogleOAuth2ClientProvider } from 'src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client.provider'; +import { + WebhookSubscriptionDriverException, + WebhookSubscriptionDriverExceptionCode, +} from 'src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception'; +import { GoogleWebhookSubscriptionDriver } from 'src/modules/connected-account/webhook-subscription-manager/drivers/google/google-webhook-subscription.driver'; + +const accessToken = process.env.GOOGLE_DEV_ACCESS_TOKEN ?? ''; +const connectedAccountId = 'dev-connected-account'; + +const buildDriver = ({ + serverUrl = 'https://twenty-probe.invalid', + pubSubTopic = 'projects/twenty-auth-dev/topics/gmail', + token = accessToken, +}: { + serverUrl?: string; + pubSubTopic?: string; + token?: string; +} = {}) => { + const oAuth2Client = new google.auth.OAuth2(); + + oAuth2Client.setCredentials({ access_token: token }); + + const oAuth2ClientProvider = { + getClient: async () => oAuth2Client, + } as unknown as GoogleOAuth2ClientProvider; + + const twentyConfigService = { + get: (key: string) => + key === 'MESSAGING_GMAIL_PUBSUB_TOPIC' ? pubSubTopic : serverUrl, + } as unknown as TwentyConfigService; + + return new GoogleWebhookSubscriptionDriver( + oAuth2ClientProvider, + twentyConfigService, + ); +}; + +const expectDriverExceptionCode = async ( + operation: Promise, + expected: WebhookSubscriptionDriverExceptionCode, +) => { + await expect(operation).rejects.toBeInstanceOf( + WebhookSubscriptionDriverException, + ); + await expect(operation).rejects.toMatchObject({ code: expected }); +}; + +xdescribe('Google dev tests : webhook subscription driver', () => { + beforeAll(() => { + jest.useRealTimers(); + }); + + it('should map a stopped channel that no longer exists to NOT_FOUND', async () => { + await expectDriverExceptionCode( + buildDriver().deleteSubscription({ + connectedAccountId, + channelType: WebhookSubscriptionChannelType.CALENDAR, + externalSubscriptionId: '00000000-0000-4000-8000-000000000000', + externalResourceId: 'does-not-exist', + clientState: 'dev', + }), + WebhookSubscriptionDriverExceptionCode.NOT_FOUND, + ); + }); + + it('should map a non-https notification url to UNKNOWN', async () => { + await expectDriverExceptionCode( + buildDriver({ + serverUrl: 'http://insecure.example.com', + }).createSubscription( + connectedAccountId, + WebhookSubscriptionChannelType.CALENDAR, + 'dev', + ), + WebhookSubscriptionDriverExceptionCode.UNKNOWN, + ); + }); + + it('should map a pubsub topic owned by another project to UNKNOWN', async () => { + await expectDriverExceptionCode( + buildDriver({ + pubSubTopic: 'projects/twenty-probe-nonexistent/topics/nope', + }).createSubscription( + connectedAccountId, + WebhookSubscriptionChannelType.MESSAGING, + 'dev', + ), + WebhookSubscriptionDriverExceptionCode.UNKNOWN, + ); + }); + + it('should map an expired access token to INSUFFICIENT_PERMISSIONS', async () => { + await expectDriverExceptionCode( + buildDriver({ token: 'not-a-real-token' }).createSubscription( + connectedAccountId, + WebhookSubscriptionChannelType.CALENDAR, + 'dev', + ), + WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS, + ); + }); +}); diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/google/google-webhook-subscription.driver.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/google/google-webhook-subscription.driver.ts index a27953b684..bcfda43370 100644 --- a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/google/google-webhook-subscription.driver.ts +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/google/google-webhook-subscription.driver.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { isNonEmptyString } from '@sniptt/guards'; +import { type GaxiosError } from 'gaxios'; import { type calendar_v3, type gmail_v1, google } from 'googleapis'; import { isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; @@ -13,6 +14,7 @@ import { WebhookSubscriptionDriverException, WebhookSubscriptionDriverExceptionCode, } from 'src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception'; +import { parseGoogleWebhookSubscriptionError } from 'src/modules/connected-account/webhook-subscription-manager/drivers/google/utils/parse-google-webhook-subscription-error.util'; import { type WebhookSubscriptionContext, type WebhookSubscriptionDriver, @@ -83,12 +85,16 @@ export class GoogleWebhookSubscriptionDriver implements WebhookSubscriptionDrive const gmailClient = await this.getGmailClient(connectedAccountId); - const { data } = await gmailClient.users.watch({ - userId: 'me', - requestBody: { - topicName: pubSubTopicName, - }, - }); + const { data } = await gmailClient.users + .watch({ + userId: 'me', + requestBody: { + topicName: pubSubTopicName, + }, + }) + .catch((error: GaxiosError) => { + throw parseGoogleWebhookSubscriptionError(error, { cause: error }); + }); if (!isDefined(data.expiration)) { throw new WebhookSubscriptionDriverException( @@ -109,7 +115,11 @@ export class GoogleWebhookSubscriptionDriver implements WebhookSubscriptionDrive ): Promise { const gmailClient = await this.getGmailClient(connectedAccountId); - await gmailClient.users.stop({ userId: 'me' }); + await gmailClient.users + .stop({ userId: 'me' }) + .catch((error: GaxiosError) => { + throw parseGoogleWebhookSubscriptionError(error, { cause: error }); + }); } private async watchPrimaryCalendar( @@ -121,16 +131,20 @@ export class GoogleWebhookSubscriptionDriver implements WebhookSubscriptionDrive const notificationAddress = `${this.twentyConfigService.get('SERVER_URL')}/webhooks/google/calendar`; const watchChannelId = v4(); - const { data } = await calendarClient.events.watch({ - calendarId: 'primary', - requestBody: { - id: watchChannelId, - type: 'web_hook', - address: notificationAddress, - token: clientState, - params: { ttl: String(GOOGLE_CALENDAR_WATCH_TTL_MS / 1000) }, - }, - }); + const { data } = await calendarClient.events + .watch({ + calendarId: 'primary', + requestBody: { + id: watchChannelId, + type: 'web_hook', + address: notificationAddress, + token: clientState, + params: { ttl: String(GOOGLE_CALENDAR_WATCH_TTL_MS / 1000) }, + }, + }) + .catch((error: GaxiosError) => { + throw parseGoogleWebhookSubscriptionError(error, { cause: error }); + }); if (!isDefined(data.resourceId) || !isDefined(data.expiration)) { throw new WebhookSubscriptionDriverException( @@ -160,12 +174,16 @@ export class GoogleWebhookSubscriptionDriver implements WebhookSubscriptionDrive context.connectedAccountId, ); - await calendarClient.channels.stop({ - requestBody: { - id: context.externalSubscriptionId, - resourceId: context.externalResourceId, - }, - }); + await calendarClient.channels + .stop({ + requestBody: { + id: context.externalSubscriptionId, + resourceId: context.externalResourceId, + }, + }) + .catch((error: GaxiosError) => { + throw parseGoogleWebhookSubscriptionError(error, { cause: error }); + }); } private async getGmailClient( diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/google/utils/__tests__/parse-google-webhook-subscription-error.util.spec.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/google/utils/__tests__/parse-google-webhook-subscription-error.util.spec.ts new file mode 100644 index 0000000000..231cef4893 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/google/utils/__tests__/parse-google-webhook-subscription-error.util.spec.ts @@ -0,0 +1,172 @@ +import { type GaxiosError } from 'gaxios'; + +import { WebhookSubscriptionDriverExceptionCode } from 'src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception'; +import { parseGoogleWebhookSubscriptionError } from 'src/modules/connected-account/webhook-subscription-manager/drivers/google/utils/parse-google-webhook-subscription-error.util'; + +const buildGaxiosError = ({ + status, + reason, + message = 'error', +}: { + status: number; + reason?: string; + message?: string; +}) => + ({ + response: { status, data: { error: { errors: [{ reason, message }] } } }, + }) as GaxiosError; + +describe('parseGoogleWebhookSubscriptionError', () => { + it('should return NOT_FOUND when stopping a channel that no longer exists', () => { + const exception = parseGoogleWebhookSubscriptionError( + buildGaxiosError({ + status: 404, + reason: 'notFound', + message: "Channel '01cb40eb' not found for project '904792538077'", + }), + ); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.NOT_FOUND, + ); + }); + + it('should return INSUFFICIENT_PERMISSIONS on 401', () => { + const exception = parseGoogleWebhookSubscriptionError( + buildGaxiosError({ + status: 401, + reason: 'authError', + message: 'Invalid Credentials', + }), + ); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS, + ); + }); + + it('should return TEMPORARY_ERROR when concurrent requests are throttled', () => { + const exception = parseGoogleWebhookSubscriptionError( + buildGaxiosError({ + status: 429, + reason: 'rateLimitExceeded', + message: 'Too many concurrent requests for user.', + }), + ); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + ); + }); + + it('should return TEMPORARY_ERROR when the per-minute quota is exhausted', () => { + const exception = parseGoogleWebhookSubscriptionError( + buildGaxiosError({ + status: 403, + reason: 'rateLimitExceeded', + message: + "Quota exceeded for quota metric 'Queries' and limit 'Units per minute per user'", + }), + ); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + ); + }); + + it('should return INSUFFICIENT_PERMISSIONS when the scope was not granted', () => { + const exception = parseGoogleWebhookSubscriptionError( + buildGaxiosError({ + status: 403, + reason: 'insufficientPermissions', + message: 'Request had insufficient authentication scopes.', + }), + ); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS, + ); + }); + + it.each([429, 500, 502, 503, 504])( + 'should return TEMPORARY_ERROR on %i', + (status) => { + const exception = parseGoogleWebhookSubscriptionError( + buildGaxiosError({ status }), + ); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + ); + }, + ); + + it.each([ + ['push.webhookUrlNotHttps', 'WebHook callback must be HTTPS'], + ['invalidArgument', 'Invalid topicName does not match projects/x/topics/*'], + ])('should return UNKNOWN for the 400 reason %s', (reason, message) => { + const exception = parseGoogleWebhookSubscriptionError( + buildGaxiosError({ status: 400, reason, message }), + ); + + expect(exception.code).toBe(WebhookSubscriptionDriverExceptionCode.UNKNOWN); + }); + + it('should return TEMPORARY_ERROR when the request never got a response', () => { + const exception = parseGoogleWebhookSubscriptionError({ + message: 'connect ECONNRESET', + } as GaxiosError); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + ); + }); + + it('should return TEMPORARY_ERROR for a transient failed precondition', () => { + const exception = parseGoogleWebhookSubscriptionError( + buildGaxiosError({ + status: 400, + reason: 'failedPrecondition', + message: 'Precondition check failed.', + }), + ); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + ); + }); + + it('should return INSUFFICIENT_PERMISSIONS when mail service is not enabled', () => { + const exception = parseGoogleWebhookSubscriptionError( + buildGaxiosError({ + status: 400, + reason: 'failedPrecondition', + message: 'Mail service not enabled for this account.', + }), + ); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS, + ); + }); + + it('should keep the provider error as the exception cause', () => { + const providerError = buildGaxiosError({ status: 404, reason: 'notFound' }); + + const exception = parseGoogleWebhookSubscriptionError(providerError, { + cause: providerError, + }); + + expect(exception.cause).toBe(providerError); + }); + + it('should return INSUFFICIENT_PERMISSIONS when the refresh token was revoked', () => { + const exception = { + response: { status: 400, data: { error: 'invalid_grant' } }, + } as GaxiosError; + + expect(parseGoogleWebhookSubscriptionError(exception).code).toBe( + WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS, + ); + }); +}); diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/google/utils/parse-google-webhook-subscription-error.util.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/google/utils/parse-google-webhook-subscription-error.util.ts new file mode 100644 index 0000000000..6719280976 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/google/utils/parse-google-webhook-subscription-error.util.ts @@ -0,0 +1,110 @@ +import { type GaxiosError } from 'gaxios'; +import { isDefined } from 'twenty-shared/utils'; + +import { + WebhookSubscriptionDriverException, + WebhookSubscriptionDriverExceptionCode, +} from 'src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception'; + +export const parseGoogleWebhookSubscriptionError = ( + error: GaxiosError, + options?: { cause?: unknown }, +): WebhookSubscriptionDriverException => { + if (!isDefined(error.response)) { + return new WebhookSubscriptionDriverException( + `Google API transport error: ${error.message}`, + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + options, + ); + } + + const googleApiError = { + code: error.response?.status, + reason: + error.response?.data?.error?.errors?.[0].reason || + error.response?.data?.error || + 'Unknown reason', + message: + error.response?.data?.error?.errors?.[0].message || + error.response?.data?.error_description || + 'Unknown error', + }; + + switch (googleApiError.code) { + case 400: + if (googleApiError.reason === 'invalid_grant') { + return new WebhookSubscriptionDriverException( + googleApiError.message, + WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS, + options, + ); + } + + if (googleApiError.reason === 'failedPrecondition') { + return new WebhookSubscriptionDriverException( + googleApiError.message, + googleApiError.message.includes('Mail service not enabled') + ? WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS + : WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + options, + ); + } + + return new WebhookSubscriptionDriverException( + googleApiError.message, + WebhookSubscriptionDriverExceptionCode.UNKNOWN, + options, + ); + + case 401: + return new WebhookSubscriptionDriverException( + googleApiError.message, + WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS, + options, + ); + + case 403: + if ( + googleApiError.reason === 'rateLimitExceeded' || + googleApiError.reason === 'userRateLimitExceeded' || + googleApiError.reason === 'dailyLimitExceeded' + ) { + return new WebhookSubscriptionDriverException( + googleApiError.message, + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + options, + ); + } + + return new WebhookSubscriptionDriverException( + googleApiError.message, + WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS, + options, + ); + + case 404: + return new WebhookSubscriptionDriverException( + googleApiError.message, + WebhookSubscriptionDriverExceptionCode.NOT_FOUND, + options, + ); + + case 429: + case 500: + case 502: + case 503: + case 504: + return new WebhookSubscriptionDriverException( + googleApiError.message, + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + options, + ); + + default: + return new WebhookSubscriptionDriverException( + googleApiError.message, + WebhookSubscriptionDriverExceptionCode.UNKNOWN, + options, + ); + } +}; diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/microsoft-webhook-subscription.driver.dev.spec.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/microsoft-webhook-subscription.driver.dev.spec.ts new file mode 100644 index 0000000000..08dd5452d7 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/microsoft-webhook-subscription.driver.dev.spec.ts @@ -0,0 +1,89 @@ +import { Client } from '@microsoft/microsoft-graph-client'; +import { WebhookSubscriptionChannelType } from 'twenty-shared/types'; + +import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { type MicrosoftOAuth2ClientProvider } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client.provider'; +import { + WebhookSubscriptionDriverException, + WebhookSubscriptionDriverExceptionCode, +} from 'src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception'; +import { MicrosoftWebhookSubscriptionDriver } from 'src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/microsoft-webhook-subscription.driver'; + +const accessToken = process.env.MICROSOFT_DEV_ACCESS_TOKEN ?? ''; +const connectedAccountId = 'dev-connected-account'; +const goneSubscriptionId = '00000000-0000-4000-8000-000000000000'; + +const buildDriver = ({ + serverUrl = 'https://twenty-probe-does-not-exist.invalid', + token = accessToken, +}: { serverUrl?: string; token?: string } = {}) => { + const oAuth2ClientProvider = { + getClient: async () => + Client.init({ authProvider: (done) => done(null, token) }), + } as unknown as MicrosoftOAuth2ClientProvider; + + const twentyConfigService = { + get: () => serverUrl, + } as unknown as TwentyConfigService; + + return new MicrosoftWebhookSubscriptionDriver( + oAuth2ClientProvider, + twentyConfigService, + ); +}; + +const context = { + connectedAccountId, + channelType: WebhookSubscriptionChannelType.CALENDAR, + externalSubscriptionId: goneSubscriptionId, + externalResourceId: null, + clientState: 'dev', +}; + +const expectDriverExceptionCode = async ( + operation: Promise, + expected: WebhookSubscriptionDriverExceptionCode, +) => { + await expect(operation).rejects.toBeInstanceOf( + WebhookSubscriptionDriverException, + ); + await expect(operation).rejects.toMatchObject({ code: expected }); +}; + +xdescribe('Microsoft dev tests : webhook subscription driver', () => { + beforeAll(() => { + jest.useRealTimers(); + }); + + it('should map renewing a removed subscription to NOT_FOUND', async () => { + await expectDriverExceptionCode( + buildDriver().renewSubscription(context), + WebhookSubscriptionDriverExceptionCode.NOT_FOUND, + ); + }); + + it('should map deleting a removed subscription to NOT_FOUND', async () => { + await expectDriverExceptionCode( + buildDriver().deleteSubscription(context), + WebhookSubscriptionDriverExceptionCode.NOT_FOUND, + ); + }); + + it('should map an unreachable notification url to UNKNOWN', async () => { + await expectDriverExceptionCode( + buildDriver().createSubscription( + connectedAccountId, + WebhookSubscriptionChannelType.CALENDAR, + 'dev', + ), + WebhookSubscriptionDriverExceptionCode.UNKNOWN, + ); + }); + + it('should map an expired access token to TEMPORARY_ERROR', async () => { + await expectDriverExceptionCode( + buildDriver({ token: 'not-a-real-token' }).renewSubscription(context), + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + ); + }); +}); diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/microsoft-webhook-subscription.driver.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/microsoft-webhook-subscription.driver.ts index 2dbdc10e72..8d7af1a70c 100644 --- a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/microsoft-webhook-subscription.driver.ts +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/microsoft-webhook-subscription.driver.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { type GraphError } from '@microsoft/microsoft-graph-client'; import { type Subscription } from '@microsoft/microsoft-graph-types'; import { isDefined } from 'twenty-shared/utils'; @@ -17,6 +18,7 @@ import { type WebhookSubscriptionResult, } from 'src/modules/connected-account/webhook-subscription-manager/types/webhook-subscription-driver.type'; import { MicrosoftOAuth2ClientProvider } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client.provider'; +import { parseMicrosoftWebhookSubscriptionError } from 'src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/utils/parse-microsoft-webhook-subscription-error.util'; import { MICROSOFT_SUBSCRIPTION_TTL_BUFFER_MS } from './constants/microsoft-subscription-ttl-ms-buffer.constant'; type MicrosoftGraphResourceConfig = Pick< @@ -77,7 +79,10 @@ export class MicrosoftWebhookSubscriptionDriver implements WebhookSubscriptionDr const subscription: Subscription = await graphClient .api('/subscriptions') - .post(subscriptionPayload); + .post(subscriptionPayload) + .catch((error: GraphError) => { + throw parseMicrosoftWebhookSubscriptionError(error, { cause: error }); + }); return this.toResult(subscription); } @@ -100,7 +105,10 @@ export class MicrosoftWebhookSubscriptionDriver implements WebhookSubscriptionDr const renewedSubscription: Subscription = await graphClient .api(`/subscriptions/${context.externalSubscriptionId}`) - .patch(subscriptionPatch); + .patch(subscriptionPatch) + .catch((error: GraphError) => { + throw parseMicrosoftWebhookSubscriptionError(error, { cause: error }); + }); return this.toResult(renewedSubscription); } @@ -116,7 +124,10 @@ export class MicrosoftWebhookSubscriptionDriver implements WebhookSubscriptionDr await graphClient .api(`/subscriptions/${context.externalSubscriptionId}`) - .delete(); + .delete() + .catch((error: GraphError) => { + throw parseMicrosoftWebhookSubscriptionError(error, { cause: error }); + }); } private toResult(subscription: Subscription): WebhookSubscriptionResult { diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/utils/__tests__/parse-microsoft-webhook-subscription-error.util.spec.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/utils/__tests__/parse-microsoft-webhook-subscription-error.util.spec.ts new file mode 100644 index 0000000000..b30b11ae46 --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/utils/__tests__/parse-microsoft-webhook-subscription-error.util.spec.ts @@ -0,0 +1,106 @@ +import { WebhookSubscriptionDriverExceptionCode } from 'src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception'; +import { parseMicrosoftWebhookSubscriptionError } from 'src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/utils/parse-microsoft-webhook-subscription-error.util'; + +describe('parseMicrosoftWebhookSubscriptionError', () => { + it('should return INSUFFICIENT_PERMISSIONS when the mailbox is not enabled for the REST API', () => { + const exception = parseMicrosoftWebhookSubscriptionError({ + statusCode: 404, + code: 'MailboxNotEnabledForRESTAPI', + message: + 'The mailbox is either inactive, soft-deleted, or is hosted on-premise.', + }); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS, + ); + }); + + it('should return NOT_FOUND when the subscription no longer exists', () => { + const exception = parseMicrosoftWebhookSubscriptionError({ + statusCode: 404, + code: 'ResourceNotFound', + message: 'The object was not found.', + }); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.NOT_FOUND, + ); + }); + + it.each([ + ['InvalidRequest', 'Failed to resolve domain example.invalid'], + ['ValidationError', 'Subscription validation request failed.'], + ])('should return UNKNOWN for the 400 code %s', (code, message) => { + const exception = parseMicrosoftWebhookSubscriptionError({ + statusCode: 400, + code, + message, + }); + + expect(exception.code).toBe(WebhookSubscriptionDriverExceptionCode.UNKNOWN); + }); + + it('should return TEMPORARY_ERROR when a 400 carries no error body', () => { + const exception = parseMicrosoftWebhookSubscriptionError({ + statusCode: 400, + }); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + ); + }); + + it('should keep the provider error as the exception cause', () => { + const providerError = new Error('graph exploded'); + + const exception = parseMicrosoftWebhookSubscriptionError( + { statusCode: 404, code: 'ResourceNotFound' }, + { cause: providerError }, + ); + + expect(exception.cause).toBe(providerError); + }); + + it('should return INSUFFICIENT_PERMISSIONS on 403', () => { + const exception = parseMicrosoftWebhookSubscriptionError({ + statusCode: 403, + code: 'ErrorAccessDenied', + message: 'Access is denied.', + }); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS, + ); + }); + + it.each([401, 429, 500, 502, 503, 504, 509])( + 'should return TEMPORARY_ERROR on %i', + (statusCode) => { + const exception = parseMicrosoftWebhookSubscriptionError({ statusCode }); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + ); + }, + ); + + it('should return TEMPORARY_ERROR when the application is throttled', () => { + const exception = parseMicrosoftWebhookSubscriptionError({ + statusCode: 429, + code: 'ApplicationThrottled', + message: 'Application is over its MailboxConcurrency limit.', + }); + + expect(exception.code).toBe( + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + ); + }); + + it('should return UNKNOWN on an unhandled status code', () => { + const exception = parseMicrosoftWebhookSubscriptionError({ + statusCode: 418, + }); + + expect(exception.code).toBe(WebhookSubscriptionDriverExceptionCode.UNKNOWN); + }); +}); diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/utils/parse-microsoft-webhook-subscription-error.util.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/utils/parse-microsoft-webhook-subscription-error.util.ts new file mode 100644 index 0000000000..5dea53b6ec --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/drivers/microsoft/utils/parse-microsoft-webhook-subscription-error.util.ts @@ -0,0 +1,85 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { + WebhookSubscriptionDriverException, + WebhookSubscriptionDriverExceptionCode, +} from 'src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception'; + +const MICROSOFT_MAILBOX_NOT_ENABLED_FOR_REST_API_ERROR_CODE = + 'MailboxNotEnabledForRESTAPI'; + +export const parseMicrosoftWebhookSubscriptionError = ( + error: { + statusCode: number; + message?: string; + code?: string | null; + }, + options?: { cause?: unknown }, +): WebhookSubscriptionDriverException => { + switch (error.statusCode) { + case 400: + if (!isDefined(error.message)) { + return new WebhookSubscriptionDriverException( + 'Microsoft Graph API returned 400 with empty error body', + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + options, + ); + } + + return new WebhookSubscriptionDriverException( + `Invalid request to Microsoft Graph API: ${error.message}`, + WebhookSubscriptionDriverExceptionCode.UNKNOWN, + options, + ); + + case 401: + return new WebhookSubscriptionDriverException( + `Unauthorized access to Microsoft Graph API - code:${error.code} ${error.message}`, + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + options, + ); + + case 403: + return new WebhookSubscriptionDriverException( + `Forbidden access to Microsoft Graph API - code:${error.code} ${error.message}`, + WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS, + options, + ); + + case 404: + if ( + error.code === MICROSOFT_MAILBOX_NOT_ENABLED_FOR_REST_API_ERROR_CODE + ) { + return new WebhookSubscriptionDriverException( + `Disabled, deleted, inactive or no licence Microsoft account - code:${error.code}`, + WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS, + options, + ); + } + + return new WebhookSubscriptionDriverException( + `Not found - code:${error.code}`, + WebhookSubscriptionDriverExceptionCode.NOT_FOUND, + options, + ); + + case 429: + case 500: + case 502: + case 503: + case 504: + case 509: + return new WebhookSubscriptionDriverException( + `Microsoft Graph API ${error.code} ${error.statusCode} error: ${error.message}`, + WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR, + options, + ); + + default: + return new WebhookSubscriptionDriverException( + `Microsoft Graph API unknown error: ${error.message} with status code ${error.statusCode}`, + WebhookSubscriptionDriverExceptionCode.UNKNOWN, + options, + ); + } +}; diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/calendar-webhook-subscription.service.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/calendar-webhook-subscription.service.ts index 8b48e56771..b954654cdf 100644 --- a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/calendar-webhook-subscription.service.ts +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/calendar-webhook-subscription.service.ts @@ -14,7 +14,13 @@ import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service' import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; +import { + WebhookSubscriptionDriverException, + WebhookSubscriptionDriverExceptionCode, +} from 'src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception'; import { WebhookSubscriptionDriverFactory } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-driver-factory.service'; +import { WebhookSubscriptionExceptionHandlerService } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-exception-handler.service'; +import { WebhookSubscriptionStatusService } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-status.service'; import { type WebhookSubscriptionContext } from 'src/modules/connected-account/webhook-subscription-manager/types/webhook-subscription-driver.type'; @Injectable() @@ -27,6 +33,8 @@ export class CalendarWebhookSubscriptionService { private readonly webhookSubscriptionDriverFactory: WebhookSubscriptionDriverFactory, private readonly exceptionHandlerService: ExceptionHandlerService, private readonly metricsService: MetricsService, + private readonly webhookSubscriptionStatusService: WebhookSubscriptionStatusService, + private readonly webhookSubscriptionExceptionHandlerService: WebhookSubscriptionExceptionHandlerService, ) {} async createSubscription( @@ -78,13 +86,12 @@ export class CalendarWebhookSubscriptionService { clientState, ); - await this.calendarChannelRepository.update(calendarChannel.id, { - webhookSubscriptionExternalId: result.externalSubscriptionId, - webhookSubscriptionExternalResourceId: result.externalResourceId, - webhookSubscriptionClientState: clientState, - webhookSubscriptionStatus: WebhookSubscriptionStatus.ACTIVE, - webhookSubscriptionExpiresAt: result.expiresAt, - }); + await this.webhookSubscriptionStatusService.markAsActive( + WebhookSubscriptionChannelType.CALENDAR, + calendarChannel.id, + result, + clientState, + ); this.metricsService.incrementCounterBy({ key: MetricsKeys.ConnectedAccountWebhookSubscriptionCreated, @@ -92,11 +99,11 @@ export class CalendarWebhookSubscriptionService { attributes: this.buildMetricAttributes(connectedAccount.provider), }); } catch (error) { - await this.calendarChannelRepository.update(calendarChannel.id, { - webhookSubscriptionClientState: clientState, - webhookSubscriptionStatus: WebhookSubscriptionStatus.FAILED, - webhookSubscriptionExpiresAt: null, - }); + await this.webhookSubscriptionStatusService.resetPendingSubscription( + WebhookSubscriptionChannelType.CALENDAR, + calendarChannel.id, + clientState, + ); this.metricsService.incrementCounterBy({ key: MetricsKeys.ConnectedAccountWebhookSubscriptionCreationFailed, @@ -104,11 +111,15 @@ export class CalendarWebhookSubscriptionService { attributes: this.buildMetricAttributes(connectedAccount.provider), }); - this.exceptionHandlerService.captureExceptions([error], { - workspace: { id: workspaceId }, - }); + await this.webhookSubscriptionExceptionHandlerService.handleDriverException( + error, + 'CREATE', + WebhookSubscriptionChannelType.CALENDAR, + calendarChannel, + workspaceId, + ); - throw error; + return; } if (isDefined(previousSubscription)) { @@ -118,6 +129,30 @@ export class CalendarWebhookSubscriptionService { } } + async recreateSubscription({ + calendarChannelId, + workspaceId, + removedSubscriptionId, + }: { + calendarChannelId: string; + workspaceId: string; + removedSubscriptionId: string | null; + }): Promise { + const cleared = + await this.webhookSubscriptionStatusService.clearRemovedSubscription( + WebhookSubscriptionChannelType.CALENDAR, + calendarChannelId, + workspaceId, + removedSubscriptionId, + ); + + if (!cleared) { + return; + } + + await this.createSubscription(calendarChannelId, workspaceId); + } + async renewSubscription({ calendarChannelId, workspaceId, @@ -158,12 +193,11 @@ export class CalendarWebhookSubscriptionService { this.toContext(calendarChannel), ); - await this.calendarChannelRepository.update(calendarChannel.id, { - webhookSubscriptionExternalId: result.externalSubscriptionId, - webhookSubscriptionExternalResourceId: result.externalResourceId, - webhookSubscriptionStatus: WebhookSubscriptionStatus.ACTIVE, - webhookSubscriptionExpiresAt: result.expiresAt, - }); + await this.webhookSubscriptionStatusService.markAsActive( + WebhookSubscriptionChannelType.CALENDAR, + calendarChannel.id, + result, + ); this.metricsService.incrementCounterBy({ key: MetricsKeys.ConnectedAccountWebhookSubscriptionRenewed, @@ -171,21 +205,24 @@ export class CalendarWebhookSubscriptionService { attributes: this.buildMetricAttributes(connectedAccount.provider), }); } catch (error) { - await this.calendarChannelRepository.update(calendarChannel.id, { - webhookSubscriptionStatus: WebhookSubscriptionStatus.FAILED, - }); - this.metricsService.incrementCounterBy({ key: MetricsKeys.ConnectedAccountWebhookSubscriptionRenewalFailed, amount: 1, attributes: this.buildMetricAttributes(connectedAccount.provider), }); - this.exceptionHandlerService.captureExceptions([error], { - workspace: { id: calendarChannel.workspaceId }, - }); + const recoveryAction = + await this.webhookSubscriptionExceptionHandlerService.handleDriverException( + error, + 'RENEW', + WebhookSubscriptionChannelType.CALENDAR, + calendarChannel, + calendarChannel.workspaceId, + ); - throw error; + if (recoveryAction === 'RECREATE') { + await this.createSubscription(calendarChannelId, workspaceId); + } } } @@ -225,6 +262,13 @@ export class CalendarWebhookSubscriptionService { attributes: this.buildMetricAttributes(connectedAccount.provider), }); } catch (error) { + if ( + error instanceof WebhookSubscriptionDriverException && + error.code === WebhookSubscriptionDriverExceptionCode.NOT_FOUND + ) { + return; + } + this.metricsService.incrementCounterBy({ key: MetricsKeys.ConnectedAccountWebhookSubscriptionDeletionFailed, amount: 1, diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/messaging-webhook-subscription.service.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/messaging-webhook-subscription.service.ts index e11b6f07fb..33912d4bbc 100644 --- a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/messaging-webhook-subscription.service.ts +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/messaging-webhook-subscription.service.ts @@ -14,7 +14,13 @@ import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service' import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; +import { + WebhookSubscriptionDriverException, + WebhookSubscriptionDriverExceptionCode, +} from 'src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception'; import { WebhookSubscriptionDriverFactory } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-driver-factory.service'; +import { WebhookSubscriptionExceptionHandlerService } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-exception-handler.service'; +import { WebhookSubscriptionStatusService } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-status.service'; import { type WebhookSubscriptionContext } from 'src/modules/connected-account/webhook-subscription-manager/types/webhook-subscription-driver.type'; @Injectable() @@ -27,6 +33,8 @@ export class MessagingWebhookSubscriptionService { private readonly webhookSubscriptionDriverFactory: WebhookSubscriptionDriverFactory, private readonly exceptionHandlerService: ExceptionHandlerService, private readonly metricsService: MetricsService, + private readonly webhookSubscriptionStatusService: WebhookSubscriptionStatusService, + private readonly webhookSubscriptionExceptionHandlerService: WebhookSubscriptionExceptionHandlerService, ) {} async createSubscription( @@ -78,12 +86,12 @@ export class MessagingWebhookSubscriptionService { clientState, ); - await this.messageChannelRepository.update(messageChannel.id, { - webhookSubscriptionExternalId: result.externalSubscriptionId, - webhookSubscriptionClientState: clientState, - webhookSubscriptionStatus: WebhookSubscriptionStatus.ACTIVE, - webhookSubscriptionExpiresAt: result.expiresAt, - }); + await this.webhookSubscriptionStatusService.markAsActive( + WebhookSubscriptionChannelType.MESSAGING, + messageChannel.id, + result, + clientState, + ); this.metricsService.incrementCounterBy({ key: MetricsKeys.ConnectedAccountWebhookSubscriptionCreated, @@ -91,11 +99,11 @@ export class MessagingWebhookSubscriptionService { attributes: this.buildMetricAttributes(connectedAccount.provider), }); } catch (error) { - await this.messageChannelRepository.update(messageChannel.id, { - webhookSubscriptionClientState: clientState, - webhookSubscriptionStatus: WebhookSubscriptionStatus.FAILED, - webhookSubscriptionExpiresAt: null, - }); + await this.webhookSubscriptionStatusService.resetPendingSubscription( + WebhookSubscriptionChannelType.MESSAGING, + messageChannel.id, + clientState, + ); this.metricsService.incrementCounterBy({ key: MetricsKeys.ConnectedAccountWebhookSubscriptionCreationFailed, @@ -103,11 +111,15 @@ export class MessagingWebhookSubscriptionService { attributes: this.buildMetricAttributes(connectedAccount.provider), }); - this.exceptionHandlerService.captureExceptions([error], { - workspace: { id: workspaceId }, - }); + await this.webhookSubscriptionExceptionHandlerService.handleDriverException( + error, + 'CREATE', + WebhookSubscriptionChannelType.MESSAGING, + messageChannel, + workspaceId, + ); - throw error; + return; } if (isDefined(previousSubscription)) { @@ -117,6 +129,30 @@ export class MessagingWebhookSubscriptionService { } } + async recreateSubscription({ + messageChannelId, + workspaceId, + removedSubscriptionId, + }: { + messageChannelId: string; + workspaceId: string; + removedSubscriptionId: string | null; + }): Promise { + const cleared = + await this.webhookSubscriptionStatusService.clearRemovedSubscription( + WebhookSubscriptionChannelType.MESSAGING, + messageChannelId, + workspaceId, + removedSubscriptionId, + ); + + if (!cleared) { + return; + } + + await this.createSubscription(messageChannelId, workspaceId); + } + async renewSubscription({ messageChannelId, workspaceId, @@ -157,11 +193,11 @@ export class MessagingWebhookSubscriptionService { this.toContext(messageChannel), ); - await this.messageChannelRepository.update(messageChannel.id, { - webhookSubscriptionExternalId: result.externalSubscriptionId, - webhookSubscriptionStatus: WebhookSubscriptionStatus.ACTIVE, - webhookSubscriptionExpiresAt: result.expiresAt, - }); + await this.webhookSubscriptionStatusService.markAsActive( + WebhookSubscriptionChannelType.MESSAGING, + messageChannel.id, + result, + ); this.metricsService.incrementCounterBy({ key: MetricsKeys.ConnectedAccountWebhookSubscriptionRenewed, @@ -169,21 +205,24 @@ export class MessagingWebhookSubscriptionService { attributes: this.buildMetricAttributes(connectedAccount.provider), }); } catch (error) { - await this.messageChannelRepository.update(messageChannel.id, { - webhookSubscriptionStatus: WebhookSubscriptionStatus.FAILED, - }); - this.metricsService.incrementCounterBy({ key: MetricsKeys.ConnectedAccountWebhookSubscriptionRenewalFailed, amount: 1, attributes: this.buildMetricAttributes(connectedAccount.provider), }); - this.exceptionHandlerService.captureExceptions([error], { - workspace: { id: messageChannel.workspaceId }, - }); + const recoveryAction = + await this.webhookSubscriptionExceptionHandlerService.handleDriverException( + error, + 'RENEW', + WebhookSubscriptionChannelType.MESSAGING, + messageChannel, + messageChannel.workspaceId, + ); - throw error; + if (recoveryAction === 'RECREATE') { + await this.createSubscription(messageChannelId, workspaceId); + } } } @@ -223,6 +262,13 @@ export class MessagingWebhookSubscriptionService { attributes: this.buildMetricAttributes(connectedAccount.provider), }); } catch (error) { + if ( + error instanceof WebhookSubscriptionDriverException && + error.code === WebhookSubscriptionDriverExceptionCode.NOT_FOUND + ) { + return; + } + this.metricsService.incrementCounterBy({ key: MetricsKeys.ConnectedAccountWebhookSubscriptionDeletionFailed, amount: 1, diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-exception-handler.service.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-exception-handler.service.ts new file mode 100644 index 0000000000..4b8047407c --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-exception-handler.service.ts @@ -0,0 +1,144 @@ +import { Injectable } from '@nestjs/common'; + +import { type WebhookSubscriptionChannelType } from 'twenty-shared/types'; + +import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; +import { + WebhookSubscriptionDriverException, + WebhookSubscriptionDriverExceptionCode, +} from 'src/modules/connected-account/webhook-subscription-manager/drivers/exceptions/webhook-subscription-driver.exception'; +import { WebhookSubscriptionStatusService } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-status.service'; +import { + type WebhookSubscribableChannel, + type WebhookSubscriptionOperation, + type WebhookSubscriptionRecoveryAction, +} from 'src/modules/connected-account/webhook-subscription-manager/types/webhook-subscription-driver.type'; + +type WebhookSubscribableChannelReference = Pick< + WebhookSubscribableChannel, + 'id' | 'webhookSubscriptionExternalId' +>; + +@Injectable() +export class WebhookSubscriptionExceptionHandlerService { + constructor( + private readonly webhookSubscriptionStatusService: WebhookSubscriptionStatusService, + private readonly exceptionHandlerService: ExceptionHandlerService, + ) {} + + public async handleDriverException( + exception: unknown, + operation: WebhookSubscriptionOperation, + channelType: WebhookSubscriptionChannelType, + channel: WebhookSubscribableChannelReference, + workspaceId: string, + ): Promise { + if (exception instanceof WebhookSubscriptionDriverException) { + switch (exception.code) { + case WebhookSubscriptionDriverExceptionCode.NOT_FOUND: + return await this.handleNotFoundException( + operation, + channelType, + channel, + workspaceId, + ); + case WebhookSubscriptionDriverExceptionCode.INSUFFICIENT_PERMISSIONS: + return await this.handleInsufficientPermissionsException( + channelType, + channel, + ); + case WebhookSubscriptionDriverExceptionCode.TEMPORARY_ERROR: + return await this.handleTemporaryException( + exception, + channelType, + channel, + ); + case WebhookSubscriptionDriverExceptionCode.PROVIDER_NOT_CONFIGURED: + case WebhookSubscriptionDriverExceptionCode.PROVIDER_RESPONSE_INVALID: + case WebhookSubscriptionDriverExceptionCode.UNSUPPORTED_PROVIDER: + case WebhookSubscriptionDriverExceptionCode.UNKNOWN: + default: + return await this.handleUnknownException( + exception, + channelType, + channel, + workspaceId, + ); + } + } + + return await this.handleUnknownException( + exception, + channelType, + channel, + workspaceId, + ); + } + + private async handleNotFoundException( + operation: WebhookSubscriptionOperation, + channelType: WebhookSubscriptionChannelType, + channel: WebhookSubscribableChannelReference, + workspaceId: string, + ): Promise { + if (operation === 'CREATE') { + return await this.handleInsufficientPermissionsException( + channelType, + channel, + ); + } + + const cleared = + await this.webhookSubscriptionStatusService.clearRemovedSubscription( + channelType, + channel.id, + workspaceId, + channel.webhookSubscriptionExternalId, + ); + + return cleared ? 'RECREATE' : 'NONE'; + } + + private async handleInsufficientPermissionsException( + channelType: WebhookSubscriptionChannelType, + channel: WebhookSubscribableChannelReference, + ): Promise { + await this.webhookSubscriptionStatusService.markAsExpired( + channelType, + channel.id, + ); + + return 'NONE'; + } + + private async handleTemporaryException( + exception: WebhookSubscriptionDriverException, + channelType: WebhookSubscriptionChannelType, + channel: WebhookSubscribableChannelReference, + ): Promise { + await this.webhookSubscriptionStatusService.markAsFailed( + channelType, + channel.id, + ); + + throw exception; + } + + private async handleUnknownException( + exception: unknown, + channelType: WebhookSubscriptionChannelType, + channel: WebhookSubscribableChannelReference, + workspaceId: string, + ): Promise { + await this.webhookSubscriptionStatusService.markAsFailed( + channelType, + channel.id, + ); + + this.exceptionHandlerService.captureExceptions([exception], { + workspace: { id: workspaceId }, + }); + + throw exception; + } +} diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-status.service.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-status.service.ts new file mode 100644 index 0000000000..de059b350d --- /dev/null +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-status.service.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { + WebhookSubscriptionChannelType, + WebhookSubscriptionStatus, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; +import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; + +import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity'; +import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; +import { + type WebhookSubscribableChannel, + type WebhookSubscriptionResult, +} from 'src/modules/connected-account/webhook-subscription-manager/types/webhook-subscription-driver.type'; + +@Injectable() +export class WebhookSubscriptionStatusService { + constructor( + @InjectRepository(MessageChannelEntity) + private readonly messageChannelRepository: Repository, + @InjectRepository(CalendarChannelEntity) + private readonly calendarChannelRepository: Repository, + ) {} + + public async markAsActive( + channelType: WebhookSubscriptionChannelType, + channelId: string, + result: WebhookSubscriptionResult, + clientState?: string, + ) { + await this.update(channelType, channelId, { + webhookSubscriptionExternalId: result.externalSubscriptionId, + webhookSubscriptionStatus: WebhookSubscriptionStatus.ACTIVE, + webhookSubscriptionExpiresAt: result.expiresAt, + ...(isDefined(clientState) + ? { webhookSubscriptionClientState: clientState } + : {}), + ...(channelType === WebhookSubscriptionChannelType.CALENDAR + ? { webhookSubscriptionExternalResourceId: result.externalResourceId } + : {}), + }); + } + + public async markAsFailed( + channelType: WebhookSubscriptionChannelType, + channelId: string, + ) { + await this.update(channelType, channelId, { + webhookSubscriptionStatus: WebhookSubscriptionStatus.FAILED, + }); + } + + public async markAsExpired( + channelType: WebhookSubscriptionChannelType, + channelId: string, + ) { + await this.update(channelType, channelId, { + webhookSubscriptionStatus: WebhookSubscriptionStatus.EXPIRED, + }); + } + + public async resetPendingSubscription( + channelType: WebhookSubscriptionChannelType, + channelId: string, + clientState: string, + ) { + await this.update(channelType, channelId, { + webhookSubscriptionClientState: clientState, + webhookSubscriptionExpiresAt: null, + }); + } + + public async clearRemovedSubscription( + channelType: WebhookSubscriptionChannelType, + channelId: string, + workspaceId: string, + removedSubscriptionId: string | null, + ): Promise { + const { affected } = await this.getRepository(channelType).update( + { + id: channelId, + workspaceId, + ...(isDefined(removedSubscriptionId) + ? { webhookSubscriptionExternalId: removedSubscriptionId } + : {}), + }, + { + webhookSubscriptionExternalId: null, + webhookSubscriptionStatus: WebhookSubscriptionStatus.FAILED, + webhookSubscriptionExpiresAt: null, + ...(channelType === WebhookSubscriptionChannelType.CALENDAR + ? { webhookSubscriptionExternalResourceId: null } + : {}), + }, + ); + + return isDefined(affected) && affected > 0; + } + + private update( + channelType: WebhookSubscriptionChannelType, + channelId: string, + payload: QueryDeepPartialEntity, + ) { + return this.getRepository(channelType).update(channelId, payload); + } + + private getRepository( + channelType: WebhookSubscriptionChannelType, + ): Repository { + return ( + channelType === WebhookSubscriptionChannelType.CALENDAR + ? this.calendarChannelRepository + : this.messageChannelRepository + ) as Repository; + } +} diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/types/webhook-subscription-driver.type.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/types/webhook-subscription-driver.type.ts index 2f05b0628b..d518ec554d 100644 --- a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/types/webhook-subscription-driver.type.ts +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/types/webhook-subscription-driver.type.ts @@ -1,5 +1,16 @@ import { type WebhookSubscriptionChannelType } from 'twenty-shared/types'; +import { type CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity'; +import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; + +export type WebhookSubscribableChannel = + | MessageChannelEntity + | CalendarChannelEntity; + +export type WebhookSubscriptionOperation = 'CREATE' | 'RENEW'; + +export type WebhookSubscriptionRecoveryAction = 'NONE' | 'RECREATE'; + export type WebhookSubscriptionResult = { externalSubscriptionId: string | null; externalResourceId: string | null; diff --git a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/webhook-subscription.module.ts b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/webhook-subscription.module.ts index fbde141ba2..6b3a858541 100644 --- a/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/webhook-subscription.module.ts +++ b/packages/twenty-server/src/modules/connected-account/webhook-subscription-manager/webhook-subscription.module.ts @@ -16,6 +16,8 @@ import { WebhookSubscriptionRenewalCronJob } from 'src/modules/connected-account import { WebhookSubscriptionChannelDeletedListener } from 'src/modules/connected-account/webhook-subscription-manager/listeners/webhook-subscription-channel-deleted.listener'; import { CalendarWebhookSubscriptionService } from 'src/modules/connected-account/webhook-subscription-manager/services/calendar-webhook-subscription.service'; import { MessagingWebhookSubscriptionService } from 'src/modules/connected-account/webhook-subscription-manager/services/messaging-webhook-subscription.service'; +import { WebhookSubscriptionExceptionHandlerService } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-exception-handler.service'; +import { WebhookSubscriptionStatusService } from 'src/modules/connected-account/webhook-subscription-manager/services/webhook-subscription-status.service'; import { WebhookSubscriptionManagerModule } from 'src/modules/connected-account/webhook-subscription-manager/webhook-subscription-manager.module'; @Module({ @@ -32,6 +34,8 @@ import { WebhookSubscriptionManagerModule } from 'src/modules/connected-account/ ]), ], providers: [ + WebhookSubscriptionStatusService, + WebhookSubscriptionExceptionHandlerService, MessagingWebhookSubscriptionService, CalendarWebhookSubscriptionService, WebhookSubscriptionChannelDeletedListener, diff --git a/packages/twenty-server/test/integration/microsoft/mocks/microsoft-webhook-subscription-handlers.util.ts b/packages/twenty-server/test/integration/microsoft/mocks/microsoft-webhook-subscription-handlers.util.ts new file mode 100644 index 0000000000..b12278b3d1 --- /dev/null +++ b/packages/twenty-server/test/integration/microsoft/mocks/microsoft-webhook-subscription-handlers.util.ts @@ -0,0 +1,84 @@ +import { type Subscription } from '@microsoft/microsoft-graph-types'; +import { http, HttpResponse } from 'msw'; + +import { type MswHandler } from 'test/integration/utils/http-mock.util'; + +export type MicrosoftSubscriptionStore = { + created: Subscription[]; + renewed: string[]; + deleted: string[]; + reset: () => void; +}; + +export const createMicrosoftSubscriptionStore = + (): MicrosoftSubscriptionStore => { + const store: MicrosoftSubscriptionStore = { + created: [], + renewed: [], + deleted: [], + reset: () => { + store.created = []; + store.renewed = []; + store.deleted = []; + }, + }; + + return store; + }; + +const subscriptionId = (request: Request) => + new URL(request.url).pathname.split('/').pop() ?? ''; + +const resourceNotFound = () => + HttpResponse.json( + { + error: { + code: 'ResourceNotFound', + message: 'The object was not found.', + }, + }, + { status: 404 }, + ); + +export const microsoftWebhookSubscriptionHandlers = ( + store: MicrosoftSubscriptionStore, + { renewalFails = false }: { renewalFails?: boolean } = {}, +): MswHandler[] => [ + http.post('*/subscriptions', async ({ request }) => { + const payload = (await request.json()) as Subscription; + const subscription: Subscription = { + ...payload, + id: `subscription-${store.created.length + 1}`, + expirationDateTime: + payload.expirationDateTime ?? + new Date(Date.now() + 3600 * 1000).toISOString(), + }; + + store.created.push(subscription); + + return HttpResponse.json(subscription); + }), + http.patch('*/subscriptions/*', async ({ request }) => { + const id = subscriptionId(request); + + if (renewalFails) { + return resourceNotFound(); + } + + store.renewed.push(id); + + const payload = (await request.json()) as Subscription; + + return HttpResponse.json({ + id, + expirationDateTime: + payload.expirationDateTime ?? + new Date(Date.now() + 3600 * 1000).toISOString(), + }); + }), + http.delete('*/subscriptions/*', ({ request }) => { + store.deleted.push(subscriptionId(request)); + + return new HttpResponse(null, { status: 204 }); + }), +]; diff --git a/packages/twenty-server/test/integration/microsoft/mocks/setup-microsoft-mock.util.ts b/packages/twenty-server/test/integration/microsoft/mocks/setup-microsoft-mock.util.ts index 8ec39b140d..0a91c9ea04 100644 --- a/packages/twenty-server/test/integration/microsoft/mocks/setup-microsoft-mock.util.ts +++ b/packages/twenty-server/test/integration/microsoft/mocks/setup-microsoft-mock.util.ts @@ -4,6 +4,11 @@ import { setupHttpMock } from 'test/integration/utils/http-mock.util'; import { microsoftAuthHandlers } from 'test/integration/microsoft/mocks/microsoft-auth-handlers.util'; import { microsoftCalendarEventsHandlers } from 'test/integration/microsoft/mocks/microsoft-calendar-events-handlers.util'; import { microsoftMailboxHandlers } from 'test/integration/microsoft/mocks/microsoft-mailbox-handlers.util'; +import { + createMicrosoftSubscriptionStore, + microsoftWebhookSubscriptionHandlers, + type MicrosoftSubscriptionStore, +} from 'test/integration/microsoft/mocks/microsoft-webhook-subscription-handlers.util'; import { createMockEntityStore, type MockEntityStore, @@ -16,10 +21,12 @@ const DEFAULT_FOLDERS: MailFolder[] = [ export type MicrosoftMock = { folders: MockEntityStore; + subscriptions: MicrosoftSubscriptionStore; serveCalendarEvents: ( events: Event[], options?: { deltaToken?: string }, ) => void; + failSubscriptionRenewal: () => void; }; export const setupMicrosoftMock = ({ @@ -34,16 +41,26 @@ export const setupMicrosoftMock = ({ (folder) => folder.id ?? '', ); + const subscriptionStore = createMicrosoftSubscriptionStore(); + const httpMock = setupHttpMock( ...microsoftAuthHandlers(handle), ...microsoftMailboxHandlers(folderStore), + ...microsoftWebhookSubscriptionHandlers(subscriptionStore), ); return { folders: folderStore, + subscriptions: subscriptionStore, serveCalendarEvents: ( events, { deltaToken = 'mock-calendar-delta-token' } = {}, ) => httpMock.use(...microsoftCalendarEventsHandlers(events, deltaToken)), + failSubscriptionRenewal: () => + httpMock.use( + ...microsoftWebhookSubscriptionHandlers(subscriptionStore, { + renewalFails: true, + }), + ), }; }; diff --git a/packages/twenty-server/test/integration/microsoft/webhook/lifecycle-notification.integration-spec.ts b/packages/twenty-server/test/integration/microsoft/webhook/lifecycle-notification.integration-spec.ts new file mode 100644 index 0000000000..5a41c7a5d8 --- /dev/null +++ b/packages/twenty-server/test/integration/microsoft/webhook/lifecycle-notification.integration-spec.ts @@ -0,0 +1,163 @@ +import request from 'supertest'; +import { + ConnectedAccountProvider, + WebhookSubscriptionStatus, +} from 'twenty-shared/types'; + +import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity'; + +import { setupMicrosoftMock } from 'test/integration/microsoft/mocks/setup-microsoft-mock.util'; +import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util'; +import { getCoreRepository } from 'test/integration/utils/get-core-repository.util'; +import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util'; + +const HANDLE = 'microsoft-webhook-lifecycle@apple.dev'; +const CLIENT_STATE = 'lifecycle-client-state'; +const REMOVED_SUBSCRIPTION_ID = 'subscription-removed-by-microsoft'; + +const postLifecycleNotification = (lifecycleEvent: string, overrides = {}) => + request(`http://localhost:${APP_PORT}`) + .post('/webhooks/microsoft/calendar') + .send({ + value: [ + { + subscriptionId: REMOVED_SUBSCRIPTION_ID, + clientState: CLIENT_STATE, + lifecycleEvent, + tenantId: 'mock-tenant', + ...overrides, + }, + ], + }); + +describe('Microsoft webhook lifecycle notifications (integration)', () => { + const microsoft = setupMicrosoftMock({ handle: HANDLE }); + + let account: Awaited>; + + const calendarChannelRepository = () => + getCoreRepository(CalendarChannelEntity); + + const readChannel = async () => + await calendarChannelRepository().findOneOrFail({ + where: { id: account.calendarChannelId }, + }); + + const giveChannelAnActiveSubscription = async () => { + await calendarChannelRepository().update(account.calendarChannelId, { + webhookSubscriptionExternalId: REMOVED_SUBSCRIPTION_ID, + webhookSubscriptionClientState: CLIENT_STATE, + webhookSubscriptionStatus: WebhookSubscriptionStatus.ACTIVE, + webhookSubscriptionExpiresAt: new Date(Date.now() + 3600 * 1000), + }); + }; + + beforeAll(async () => { + account = await connectMessagingAccount({ + provider: ConnectedAccountProvider.MICROSOFT, + handle: HANDLE, + }); + }, 120000); + + beforeEach(async () => { + microsoft.subscriptions.reset(); + await giveChannelAnActiveSubscription(); + }); + + afterAll(async () => { + await account?.cleanup().catch(() => undefined); + }); + + it('creates a replacement subscription when Microsoft reports it was removed', async () => { + await postLifecycleNotification('subscriptionRemoved').expect(200); + + await waitForAllJobsToFinish(); + + expect(microsoft.subscriptions.created).toHaveLength(1); + + const channel = await readChannel(); + + expect(channel.webhookSubscriptionExternalId).not.toBe( + REMOVED_SUBSCRIPTION_ID, + ); + expect(channel.webhookSubscriptionStatus).toBe( + WebhookSubscriptionStatus.ACTIVE, + ); + }, 60000); + + it('never patches the removed subscription', async () => { + await postLifecycleNotification('subscriptionRemoved').expect(200); + + await waitForAllJobsToFinish(); + + expect(microsoft.subscriptions.renewed).not.toContain( + REMOVED_SUBSCRIPTION_ID, + ); + }, 60000); + + it('renews in place when Microsoft asks for reauthorization', async () => { + await postLifecycleNotification('reauthorizationRequired').expect(200); + + await waitForAllJobsToFinish(); + + expect(microsoft.subscriptions.renewed).toContain(REMOVED_SUBSCRIPTION_ID); + expect(microsoft.subscriptions.created).toHaveLength(0); + + const channel = await readChannel(); + + expect(channel.webhookSubscriptionExternalId).toBe(REMOVED_SUBSCRIPTION_ID); + }, 60000); + + it('leaves the subscription untouched on a missed notification', async () => { + await postLifecycleNotification('missed').expect(200); + + await waitForAllJobsToFinish(); + + expect(microsoft.subscriptions.created).toHaveLength(0); + expect(microsoft.subscriptions.renewed).toHaveLength(0); + + const channel = await readChannel(); + + expect(channel.webhookSubscriptionExternalId).toBe(REMOVED_SUBSCRIPTION_ID); + }, 60000); + + it('ignores a lifecycle event it does not recognize', async () => { + await postLifecycleNotification('someFutureLifecycleEvent').expect(200); + + await waitForAllJobsToFinish(); + + expect(microsoft.subscriptions.created).toHaveLength(0); + expect(microsoft.subscriptions.renewed).toHaveLength(0); + }, 60000); + + it('ignores a notification whose client state does not match', async () => { + await postLifecycleNotification('subscriptionRemoved', { + clientState: 'forged-client-state', + }).expect(200); + + await waitForAllJobsToFinish(); + + expect(microsoft.subscriptions.created).toHaveLength(0); + + const channel = await readChannel(); + + expect(channel.webhookSubscriptionExternalId).toBe(REMOVED_SUBSCRIPTION_ID); + }, 60000); + + it('recreates the subscription when renewal reports the resource is gone', async () => { + microsoft.failSubscriptionRenewal(); + + await postLifecycleNotification('reauthorizationRequired').expect(200); + + await waitForAllJobsToFinish(); + + const channel = await readChannel(); + + expect(channel.webhookSubscriptionStatus).toBe( + WebhookSubscriptionStatus.ACTIVE, + ); + expect(channel.webhookSubscriptionExternalId).not.toBe( + REMOVED_SUBSCRIPTION_ID, + ); + }, 60000); +}); diff --git a/packages/twenty-server/test/integration/microsoft/webhook/messaging-lifecycle-notification.integration-spec.ts b/packages/twenty-server/test/integration/microsoft/webhook/messaging-lifecycle-notification.integration-spec.ts new file mode 100644 index 0000000000..7b795e201e --- /dev/null +++ b/packages/twenty-server/test/integration/microsoft/webhook/messaging-lifecycle-notification.integration-spec.ts @@ -0,0 +1,138 @@ +import request from 'supertest'; +import { + ConnectedAccountProvider, + WebhookSubscriptionStatus, +} from 'twenty-shared/types'; + +import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; + +import { setupMicrosoftMock } from 'test/integration/microsoft/mocks/setup-microsoft-mock.util'; +import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util'; +import { getCoreRepository } from 'test/integration/utils/get-core-repository.util'; +import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util'; + +const HANDLE = 'microsoft-messaging-lifecycle@apple.dev'; +const CLIENT_STATE = 'messaging-lifecycle-client-state'; +const REMOVED_SUBSCRIPTION_ID = 'messaging-subscription-removed'; + +const postLifecycleNotification = (lifecycleEvent: string, overrides = {}) => + request(`http://localhost:${APP_PORT}`) + .post('/webhooks/microsoft/messaging') + .send({ + value: [ + { + subscriptionId: REMOVED_SUBSCRIPTION_ID, + clientState: CLIENT_STATE, + lifecycleEvent, + tenantId: 'mock-tenant', + ...overrides, + }, + ], + }); + +describe('Microsoft messaging webhook lifecycle notifications (integration)', () => { + const microsoft = setupMicrosoftMock({ handle: HANDLE }); + + let account: Awaited>; + + const messageChannelRepository = () => + getCoreRepository(MessageChannelEntity); + + const readChannel = async () => + await messageChannelRepository().findOneOrFail({ + where: { id: account.channelId }, + }); + + beforeAll(async () => { + account = await connectMessagingAccount({ + provider: ConnectedAccountProvider.MICROSOFT, + handle: HANDLE, + }); + }, 120000); + + beforeEach(async () => { + microsoft.subscriptions.reset(); + await messageChannelRepository().update(account.channelId, { + webhookSubscriptionExternalId: REMOVED_SUBSCRIPTION_ID, + webhookSubscriptionClientState: CLIENT_STATE, + webhookSubscriptionStatus: WebhookSubscriptionStatus.ACTIVE, + webhookSubscriptionExpiresAt: new Date(Date.now() + 3600 * 1000), + }); + }); + + afterAll(async () => { + await account?.cleanup().catch(() => undefined); + }); + + it('creates a replacement subscription when Microsoft reports it was removed', async () => { + await postLifecycleNotification('subscriptionRemoved').expect(200); + + await waitForAllJobsToFinish(); + + expect(microsoft.subscriptions.created).toHaveLength(1); + expect(microsoft.subscriptions.renewed).not.toContain( + REMOVED_SUBSCRIPTION_ID, + ); + + const channel = await readChannel(); + + expect(channel.webhookSubscriptionExternalId).not.toBe( + REMOVED_SUBSCRIPTION_ID, + ); + expect(channel.webhookSubscriptionStatus).toBe( + WebhookSubscriptionStatus.ACTIVE, + ); + }, 60000); + + it('creates only one replacement when the same removal is delivered twice', async () => { + await Promise.all([ + postLifecycleNotification('subscriptionRemoved'), + postLifecycleNotification('subscriptionRemoved'), + ]); + + await waitForAllJobsToFinish(); + + expect(microsoft.subscriptions.created).toHaveLength(1); + }, 60000); + + it('renews in place when Microsoft asks for reauthorization', async () => { + await postLifecycleNotification('reauthorizationRequired').expect(200); + + await waitForAllJobsToFinish(); + + expect(microsoft.subscriptions.renewed).toContain(REMOVED_SUBSCRIPTION_ID); + expect(microsoft.subscriptions.created).toHaveLength(0); + }, 60000); + + it('leaves the subscription untouched on a missed notification', async () => { + await postLifecycleNotification('missed').expect(200); + + await waitForAllJobsToFinish(); + + expect(microsoft.subscriptions.created).toHaveLength(0); + expect(microsoft.subscriptions.renewed).toHaveLength(0); + }, 60000); + + it('ignores a lifecycle event it does not recognize', async () => { + await postLifecycleNotification('someFutureLifecycleEvent').expect(200); + + await waitForAllJobsToFinish(); + + expect(microsoft.subscriptions.created).toHaveLength(0); + expect(microsoft.subscriptions.renewed).toHaveLength(0); + }, 60000); + + it('ignores a notification whose client state does not match', async () => { + await postLifecycleNotification('subscriptionRemoved', { + clientState: 'forged-client-state', + }).expect(200); + + await waitForAllJobsToFinish(); + + expect(microsoft.subscriptions.created).toHaveLength(0); + + const channel = await readChannel(); + + expect(channel.webhookSubscriptionExternalId).toBe(REMOVED_SUBSCRIPTION_ID); + }, 60000); +});