snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
import { randomUUID } from 'node:crypto';
import {
CalendarChannelSyncStage,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { microsoftCalendarEvent } from 'test/integration/microsoft/mocks/microsoft-calendar-event.util';
import { setupMicrosoftMock } from 'test/integration/microsoft/mocks/setup-microsoft-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { queryCalendarChannel } from 'test/integration/utils/query-messaging.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'microsoft-calendar-events-import@apple.dev';
describe('Microsoft calendar events import (integration)', () => {
const microsoft = setupMicrosoftMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.MICROSOFT,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('imports calendar events through the real delta-fetch and import pipeline', async () => {
const eventTitle = `Calendar event ${randomUUID()}`;
microsoft.serveCalendarEvents([
microsoftCalendarEvent({ subject: eventTitle }),
]);
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
);
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([
eventTitle,
]);
}, 60000);
it('imports a newly created event through the delta-token continuation', async () => {
const newEventTitle = `Calendar event ${randomUUID()}`;
microsoft.serveCalendarEvents(
[microsoftCalendarEvent({ subject: newEventTitle })],
{ deltaToken: 'mock-calendar-delta-token-2' },
);
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
);
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([newEventTitle])).toEqual([
newEventTitle,
]);
}, 60000);
});

View File

@@ -0,0 +1,166 @@
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
ConnectedAccountProvider,
} from 'twenty-shared/types';
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 { MICROSOFT_PERMANENT_ACCOUNT_ERROR_CODES } from 'src/modules/connected-account/constants/microsoft-permanent-account-error-codes.constant';
import {
type MicrosoftGraphFailure,
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 {
queryCalendarChannel,
queryConnectedAccount,
} from 'test/integration/utils/query-messaging.util';
import { resetCalendarChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'microsoft-calendar-error-mapping@apple.dev';
// 429 and 503 are retried inside the Graph client before the driver sees them,
// so they are exercised through the messaging suite instead.
const TEMPORARY_FAILURES: [string, MicrosoftGraphFailure][] = [
[
'a 500 response',
{ status: 500, code: 'InternalServerError', message: 'Internal error' },
],
];
describe('Microsoft calendar import error mapping (integration)', () => {
const microsoft = setupMicrosoftMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.MICROSOFT,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
beforeEach(async () => {
await resetCalendarChannelSyncState(channel.calendarChannelId, '');
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update({ id: channel.connectedAccountId }, { authFailedAt: null });
});
it.each(MICROSOFT_PERMANENT_ACCOUNT_ERROR_CODES)(
'fails the calendar channel as insufficient-permissions on the permanent account error %s',
async (code) => {
microsoft.failCalendarDelta({
status: 403,
code,
message: 'The mailbox is unavailable',
});
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(CalendarChannelSyncStage.FAILED);
const account = await queryConnectedAccount(channel.connectedAccountId);
expect(account.authFailedAt).not.toBeNull();
},
60000,
);
it.each(TEMPORARY_FAILURES)(
'throttles the calendar channel without failing it on %s',
async (_label, failure) => {
microsoft.failCalendarDelta(failure);
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
);
expect(channelState.syncStatus).not.toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
},
60000,
);
it('fails the calendar channel as unknown on a 400 bad request', async () => {
microsoft.failCalendarDelta({
status: 400,
code: 'BadRequest',
message: 'Invalid delta token',
});
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(CalendarChannelSyncStage.FAILED);
}, 60000);
it('leaves the calendar channel untouched on a 404 during list fetch', async () => {
microsoft.failCalendarDelta({
status: 404,
code: 'ResourceNotFound',
message: 'Resource not found',
});
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStatus).not.toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.throttleFailureCount).toBe(0);
const storedChannel = await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).findOneByOrFail({ id: channel.calendarChannelId });
expect(storedChannel.syncCursor).toBeFalsy();
}, 60000);
it('restarts the calendar channel from a clean cursor on a 410 expired delta token', async () => {
microsoft.failCalendarDelta({
status: 410,
code: 'syncStateNotFound',
message: 'The delta token is expired',
});
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
);
expect(channelState.throttleFailureCount).toBe(0);
const storedChannel = await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).findOneByOrFail({ id: channel.calendarChannelId });
expect(storedChannel.syncCursor).toBeFalsy();
}, 60000);
});

View File

@@ -0,0 +1,63 @@
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.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 {
queryCalendarChannel,
queryConnectedAccount,
} from 'test/integration/utils/query-messaging.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'microsoft-calendar-revoked@apple.dev';
const EXPIRED_CREDENTIALS_AT = new Date(Date.now() - 56 * 60 * 1000);
describe('Microsoft calendar token refresh failure (integration)', () => {
const microsoft = setupMicrosoftMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.MICROSOFT,
handle: HANDLE,
});
}, 120000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('fails the channel as insufficient-permissions when the refresh token is declined', async () => {
// resolveTokens short-circuits on a still-valid access token, so the refresh
// token is only consulted once lastCredentialsRefreshedAt is stale.
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update(
{ id: channel.connectedAccountId },
{ lastCredentialsRefreshedAt: EXPIRED_CREDENTIALS_AT },
);
microsoft.declineTokenRefresh();
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(CalendarChannelSyncStage.FAILED);
const account = await queryConnectedAccount(channel.connectedAccountId);
expect(account.authFailedAt).not.toBeNull();
}, 60000);
});

View File

@@ -0,0 +1,195 @@
import { randomUUID } from 'node:crypto';
import {
CalendarChannelSyncStage,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { CALENDAR_EVENT_WEBHOOK_SYNC_INLINE_IMPORT_MAX_EVENTS } from 'src/modules/connected-account-sync-webhooks/calendar-event-webhook-sync/constants/calendar-event-webhook-sync-inline-import-max-events.constant';
import { microsoftCalendarEvent } from 'test/integration/microsoft/mocks/microsoft-calendar-event.util';
import { setupMicrosoftMock } from 'test/integration/microsoft/mocks/setup-microsoft-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findCalendarEventAssociationChannelIds } from 'test/integration/utils/find-calendar-event-association-channel-ids.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { queryCalendarChannel } from 'test/integration/utils/query-messaging.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelWebhookSync } from 'test/integration/utils/run-calendar-channel-webhook-sync.util';
const HANDLE = 'microsoft-calendar-webhook-sync@apple.dev';
const deltaToken = () => `webhook-sync-delta-token-${randomUUID()}`;
describe('Microsoft calendar webhook sync (integration)', () => {
const microsoft = setupMicrosoftMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.MICROSOFT,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('imports a new event in a single job without a separate import run', async () => {
const eventTitle = `Webhook event ${randomUUID()}`;
microsoft.serveCalendarEvents(
[microsoftCalendarEvent({ subject: eventTitle })],
{ deltaToken: deltaToken() },
);
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([
eventTitle,
]);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
);
}, 60000);
it('applies an update to an already imported event', async () => {
const eventExternalId = `microsoft-calendar-event-${randomUUID()}`;
const originalTitle = `Webhook event ${randomUUID()}`;
const updatedTitle = `Webhook event updated ${randomUUID()}`;
microsoft.serveCalendarEvents(
[microsoftCalendarEvent({ id: eventExternalId, subject: originalTitle })],
{ deltaToken: deltaToken() },
);
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([originalTitle])).toEqual([
originalTitle,
]);
microsoft.serveCalendarEvents(
[microsoftCalendarEvent({ id: eventExternalId, subject: updatedTitle })],
{ deltaToken: deltaToken() },
);
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([updatedTitle])).toEqual([
updatedTitle,
]);
expect(await findImportedCalendarEventTitles([originalTitle])).toEqual([]);
}, 60000);
it('deletes the calendar event once the delta reports it removed', async () => {
const eventExternalId = `microsoft-calendar-event-${randomUUID()}`;
const eventTitle = `Webhook removable event ${randomUUID()}`;
microsoft.serveCalendarEvents(
[microsoftCalendarEvent({ id: eventExternalId, subject: eventTitle })],
{ deltaToken: deltaToken() },
);
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(
await findCalendarEventAssociationChannelIds(eventExternalId),
).toEqual([channel.calendarChannelId]);
microsoft.serveCalendarEvents([], {
deltaToken: deltaToken(),
removedEventIds: [eventExternalId],
});
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(
await findCalendarEventAssociationChannelIds(eventExternalId),
).toEqual([]);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([]);
}, 60000);
it('keeps the events that the delta did not report removed', async () => {
const removedExternalId = `microsoft-calendar-event-${randomUUID()}`;
const removedTitle = `Webhook removed ${randomUUID()}`;
const survivingTitle = `Webhook surviving ${randomUUID()}`;
microsoft.serveCalendarEvents(
[
microsoftCalendarEvent({
id: removedExternalId,
subject: removedTitle,
}),
microsoftCalendarEvent({ subject: survivingTitle }),
],
{ deltaToken: deltaToken() },
);
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(
await findImportedCalendarEventTitles([removedTitle, survivingTitle]),
).toEqual([removedTitle, survivingTitle].sort());
microsoft.serveCalendarEvents([], {
deltaToken: deltaToken(),
removedEventIds: [removedExternalId],
});
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([removedTitle])).toEqual([]);
expect(await findImportedCalendarEventTitles([survivingTitle])).toEqual([
survivingTitle,
]);
}, 60000);
it('leaves a delta larger than the inline cap to the import cron', async () => {
const eventTitles = Array.from(
{ length: CALENDAR_EVENT_WEBHOOK_SYNC_INLINE_IMPORT_MAX_EVENTS + 1 },
() => `Webhook bulk event ${randomUUID()}`,
);
microsoft.serveCalendarEvents(
eventTitles.map((subject) => microsoftCalendarEvent({ subject })),
{ deltaToken: deltaToken() },
);
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles(eventTitles)).toEqual([]);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
);
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles(eventTitles)).toEqual(
[...eventTitles].sort(),
);
}, 120000);
it('does not sync when the channel is already being synced', async () => {
const eventTitle = `Webhook event ${randomUUID()}`;
microsoft.serveCalendarEvents(
[microsoftCalendarEvent({ subject: eventTitle })],
{ deltaToken: deltaToken() },
);
await runCalendarChannelWebhookSync(
channel.calendarChannelId,
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([]);
}, 60000);
});

View File

@@ -0,0 +1,91 @@
import { randomUUID } from 'node:crypto';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { setupMicrosoftMock } from 'test/integration/microsoft/mocks/setup-microsoft-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedMessageSubjects } from 'test/integration/utils/find-imported-records.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'microsoft-cross-folder-move@apple.dev';
const MOVED_SUBJECT = `Microsoft moved message ${randomUUID()}`;
const DELETED_SUBJECT = `Microsoft deleted message ${randomUUID()}`;
const FOLDERS = [
{ id: 'inbox', displayName: 'Inbox' },
{ id: 'sentitems', displayName: 'Sent Items' },
{ id: 'archive', displayName: 'Archive' },
];
const inboxMessage = (id: string, subject: string) => ({
id,
subject,
body: { contentType: 'text', content: subject },
receivedDateTime: '2026-08-13T00:00:00.000Z',
internetMessageId: `<${id}@example.com>`,
conversationId: `${id}-conversation`,
parentFolderId: 'inbox',
isDraft: false,
from: { emailAddress: { address: 'sender@external.test' } },
toRecipients: [{ emailAddress: { address: HANDLE } }],
});
const MOVED_MESSAGE = inboxMessage(
'microsoft-cross-folder-moved-message',
MOVED_SUBJECT,
);
const DELETED_MESSAGE = inboxMessage(
'microsoft-cross-folder-deleted-message',
DELETED_SUBJECT,
);
describe('Microsoft cross-folder move (integration)', () => {
const microsoftMock = setupMicrosoftMock({
handle: HANDLE,
folders: FOLDERS,
messages: [MOVED_MESSAGE, DELETED_MESSAGE],
});
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let subjectsAfterInitialSync: string[];
let subjectsAfterMoveAndDelete: string[];
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.MICROSOFT,
handle: HANDLE,
});
await runMessageChannelSync(channel.channelId);
subjectsAfterInitialSync = await findImportedMessageSubjects([
MOVED_SUBJECT,
DELETED_SUBJECT,
]);
microsoftMock.moveMessageToFolder(MOVED_MESSAGE.id, 'archive');
microsoftMock.deleteMessage(DELETED_MESSAGE.id);
await runMessageChannelSync(channel.channelId);
subjectsAfterMoveAndDelete = await findImportedMessageSubjects([
MOVED_SUBJECT,
DELETED_SUBJECT,
]);
}, 180000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('imports both inbox messages on the first sync', () => {
expect(subjectsAfterInitialSync).toEqual(
[MOVED_SUBJECT, DELETED_SUBJECT].sort(),
);
});
it('keeps the moved message the archive delta still returns and deletes the one no folder returns', () => {
expect(subjectsAfterMoveAndDelete).toEqual([MOVED_SUBJECT]);
});
});

View File

@@ -0,0 +1,40 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { setupMicrosoftMock } from 'test/integration/microsoft/mocks/setup-microsoft-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { queryMessageFolders } from 'test/integration/utils/query-messaging.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'microsoft-folder-discovery@apple.dev';
describe('Microsoft folder discovery (integration)', () => {
setupMicrosoftMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.MICROSOFT,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('discovers Microsoft mail folders through the Graph delta sync', async () => {
await runMessageChannelSync(channel.channelId);
const folders = await queryMessageFolders(channel.channelId);
expect(
Object.fromEntries(
folders.map((folder) => [folder.name, folder.isSynced]),
),
).toEqual({
Inbox: true,
'Sent Items': true,
});
}, 60000);
});

View File

@@ -0,0 +1,109 @@
import { randomUUID } from 'node:crypto';
import {
ConnectedAccountProvider,
MessageFolderPendingSyncAction,
} from 'twenty-shared/types';
import { setupMicrosoftMock } from 'test/integration/microsoft/mocks/setup-microsoft-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedMessageSubjects } from 'test/integration/utils/find-imported-records.util';
import {
type MessageFolderDto,
queryMessageFolders,
} from 'test/integration/utils/query-messaging.util';
import {
FOLDER_ACTION_NAMES,
MESSAGE_COUNT_ABOVE_V8_SPREAD_ARGUMENT_LIMIT,
runFolderActions,
runFolderActionsWithoutPendingActions,
} from 'test/integration/utils/run-folder-actions.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'microsoft-folder-actions@apple.dev';
const DELETED_FOLDER_NAME = 'Inbox';
const IMPORTED_SUBJECT = `Microsoft inbox message ${randomUUID()}`;
const INBOX_MESSAGE = {
id: 'microsoft-folder-actions-message',
subject: IMPORTED_SUBJECT,
body: { contentType: 'text', content: 'Microsoft inbox body' },
receivedDateTime: '2026-08-13T00:00:00.000Z',
internetMessageId: '<microsoft-folder-actions@example.com>',
conversationId: 'microsoft-folder-actions-conversation',
parentFolderId: 'inbox',
isDraft: false,
from: { emailAddress: { address: 'sender@external.test' } },
toRecipients: [{ emailAddress: { address: HANDLE } }],
};
describe('Microsoft folder actions (integration)', () => {
setupMicrosoftMock({ handle: HANDLE, messages: [INBOX_MESSAGE] });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let messageExternalIdsToImport: string[];
let messageExternalIdsWithoutPendingActions: string[];
let folders: MessageFolderDto[];
let subjectsBeforeFolderDeletion: string[];
let subjectsAfterFolderDeletion: string[];
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.MICROSOFT,
handle: HANDLE,
});
await runMessageChannelSync(channel.channelId);
subjectsBeforeFolderDeletion = await findImportedMessageSubjects([
IMPORTED_SUBJECT,
]);
messageExternalIdsToImport = await runFolderActions({
messageChannelId: channel.channelId,
folderNameToDelete: DELETED_FOLDER_NAME,
});
subjectsAfterFolderDeletion = await findImportedMessageSubjects([
IMPORTED_SUBJECT,
]);
folders = await queryMessageFolders(channel.channelId);
messageExternalIdsWithoutPendingActions =
await runFolderActionsWithoutPendingActions(channel.channelId);
}, 180000);
afterAll(async () => {
jest.restoreAllMocks();
await channel?.cleanup().catch(() => undefined);
});
it('imports every message id of a folder larger than the spread-argument limit, deduplicated', () => {
expect(messageExternalIdsToImport).toHaveLength(
MESSAGE_COUNT_ABOVE_V8_SPREAD_ARGUMENT_LIMIT,
);
});
it('clears the pending action it processed, keeps the one it failed on, and drops the deleted folder', () => {
expect(
Object.fromEntries(
folders.map((folder) => [folder.name, folder.pendingSyncAction]),
),
).toEqual({
'Sent Items': MessageFolderPendingSyncAction.NONE,
[FOLDER_ACTION_NAMES.imported]: MessageFolderPendingSyncAction.NONE,
[FOLDER_ACTION_NAMES.failing]:
MessageFolderPendingSyncAction.FOLDER_IMPORT,
[FOLDER_ACTION_NAMES.untouched]: MessageFolderPendingSyncAction.NONE,
});
});
it('deletes the messages of the folder marked for deletion', () => {
expect(subjectsBeforeFolderDeletion).toEqual([IMPORTED_SUBJECT]);
expect(subjectsAfterFolderDeletion).toEqual([]);
});
it('imports nothing when no folder carries a pending action', () => {
expect(messageExternalIdsWithoutPendingActions).toEqual([]);
});
});

View File

@@ -0,0 +1,228 @@
import { randomUUID } from 'node:crypto';
import { isNonEmptyString } from '@sniptt/guards';
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
MessageChannelSyncStatus,
} from 'twenty-shared/types';
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 { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { MICROSOFT_PERMANENT_ACCOUNT_ERROR_CODES } from 'src/modules/connected-account/constants/microsoft-permanent-account-error-codes.constant';
import {
type MicrosoftGraphFailure,
setupMicrosoftMock,
} from 'test/integration/microsoft/mocks/setup-microsoft-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedMessageSubjects } from 'test/integration/utils/find-imported-records.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
queryConnectedAccount,
queryMessageChannel,
} from 'test/integration/utils/query-messaging.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'microsoft-import-error-mapping@apple.dev';
const FETCHABLE_SUBJECT = `Microsoft fetchable message ${randomUUID()}`;
const UNFETCHABLE_SUBJECT = `Microsoft unfetchable message ${randomUUID()}`;
const inboxMessage = (id: string, subject: string) => ({
id,
subject,
body: { contentType: 'text', content: subject },
receivedDateTime: '2026-08-13T00:00:00.000Z',
internetMessageId: `<${id}@example.com>`,
conversationId: `${id}-conversation`,
parentFolderId: 'inbox',
isDraft: false,
from: { emailAddress: { address: 'sender@external.test' } },
toRecipients: [{ emailAddress: { address: HANDLE } }],
});
const FETCHABLE_MESSAGE = inboxMessage(
'microsoft-import-error-mapping-fetchable-message',
FETCHABLE_SUBJECT,
);
const UNFETCHABLE_MESSAGE = inboxMessage(
'microsoft-import-error-mapping-unfetchable-message',
UNFETCHABLE_SUBJECT,
);
const TEMPORARY_FAILURES: [string, MicrosoftGraphFailure][] = [
[
'a 429 throttled response',
{
status: 429,
code: 'ApplicationThrottled',
message: 'Too many requests',
},
],
[
'a 500 response',
{ status: 500, code: 'InternalServerError', message: 'Internal error' },
],
[
'a 503 response',
{ status: 503, code: 'ServiceUnavailable', message: 'Service unavailable' },
],
];
describe('Microsoft messaging import error mapping (integration)', () => {
const microsoft = setupMicrosoftMock({
handle: HANDLE,
messages: [FETCHABLE_MESSAGE, UNFETCHABLE_MESSAGE],
});
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.MICROSOFT,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
beforeEach(async () => {
await resetMessageChannelSyncState(channel.channelId, '');
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update({ id: channel.connectedAccountId }, { authFailedAt: null });
});
it.each(MICROSOFT_PERMANENT_ACCOUNT_ERROR_CODES)(
'fails the channel as insufficient-permissions on the permanent account error %s',
async (code) => {
microsoft.failMessageDelta({
status: 403,
code,
message: 'The mailbox is unavailable',
});
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
const account = await queryConnectedAccount(channel.connectedAccountId);
expect(account.authFailedAt).not.toBeNull();
},
60000,
);
it.each(TEMPORARY_FAILURES)(
'throttles the channel without failing it on %s',
async (_label, failure) => {
microsoft.failMessageDelta(failure);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
expect(channelState.syncStatus).not.toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
},
60000,
);
it('fails the channel as unknown on a 400 bad request', async () => {
microsoft.failMessageDelta({
status: 400,
code: 'BadRequest',
message: 'Invalid delta token',
});
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
it('fails the channel as unknown when the message list is not found', async () => {
microsoft.failMessageDelta({
status: 404,
code: 'ResourceNotFound',
message: 'Resource not found',
});
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
it('restarts the channel from a clean cursor on a 410 expired delta token', async () => {
microsoft.failMessageDelta({
status: 410,
code: 'syncStateNotFound',
message: 'The delta token is expired',
});
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
expect(channelState.syncStatus).not.toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.throttleFailureCount).toBe(0);
const storedChannel = await getCoreRepository<MessageChannelEntity>(
MessageChannelEntity,
).findOneByOrFail({ id: channel.channelId });
expect(storedChannel.syncCursor).toBeFalsy();
}, 60000);
it('imports the rest of the batch and keeps the folder cursors when one listed message is not found on fetch', async () => {
microsoft.failMessageFetch(UNFETCHABLE_MESSAGE.id);
await runMessageChannelSync(channel.channelId);
expect(
await findImportedMessageSubjects([
FETCHABLE_SUBJECT,
UNFETCHABLE_SUBJECT,
]),
).toEqual([FETCHABLE_SUBJECT]);
const syncedFolders = await getCoreRepository<MessageFolderEntity>(
MessageFolderEntity,
).findBy({ messageChannelId: channel.channelId, isSynced: true });
expect(syncedFolders.length).toBeGreaterThan(0);
expect(
syncedFolders.every((folder) => isNonEmptyString(folder.syncCursor)),
).toBe(true);
}, 60000);
});

View File

@@ -0,0 +1,335 @@
import { randomUUID } from 'node:crypto';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { setupMicrosoftMock } from 'test/integration/microsoft/mocks/setup-microsoft-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { createCalendarEvent } from 'test/integration/utils/create-calendar-event.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { findRecordNodesByFilter } from 'test/integration/utils/find-records-by-filter.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
import { sendEmail } from 'test/integration/utils/send-email.util';
const HANDLE = 'microsoft-outbound@apple.dev';
const ALIAS = 'microsoft-outbound-alias@apple.dev';
const RECIPIENTS = {
to: 'to-recipient@example.com',
cc: 'cc-recipient@example.com',
bcc: 'bcc-recipient@example.com',
};
const DRAFT_SUBJECT = `Microsoft draft ${randomUUID()}`;
const DRAFT_MESSAGE = {
id: 'microsoft-draft-message',
subject: DRAFT_SUBJECT,
body: { contentType: 'text', content: 'Microsoft draft body' },
receivedDateTime: '2026-08-13T00:00:00.000Z',
internetMessageId: '<microsoft-draft@example.com>',
conversationId: 'microsoft-draft-conversation',
parentFolderId: 'drafts',
isDraft: true,
from: { emailAddress: { address: HANDLE } },
toRecipients: [{ emailAddress: { address: RECIPIENTS.to } }],
ccRecipients: [{ emailAddress: { address: RECIPIENTS.cc } }],
bccRecipients: [{ emailAddress: { address: RECIPIENTS.bcc } }],
};
describe('Microsoft outbound messaging and calendar creation (integration)', () => {
const microsoft = setupMicrosoftMock({
handle: HANDLE,
aliases: [ALIAS],
folders: [
{ id: 'inbox', displayName: 'Inbox' },
{ id: 'sentitems', displayName: 'Sent Items' },
{ id: 'drafts', displayName: 'Drafts' },
],
messages: [DRAFT_MESSAGE],
});
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.MICROSOFT,
handle: HANDLE,
});
await runMessageChannelSync(channel.channelId);
}, 60000);
beforeEach(() => {
microsoft.createdMessages.length = 0;
microsoft.patchedMessages.length = 0;
microsoft.sentMessageIds.length = 0;
microsoft.createdCalendarEvents.length = 0;
});
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('sends a reply through Graph with To, CC, and BCC recipients', async () => {
const subject = `Microsoft outbound ${randomUUID()}`;
const result = await sendEmail({
connectedAccountId: channel.connectedAccountId,
to: RECIPIENTS.to,
cc: RECIPIENTS.cc,
bcc: RECIPIENTS.bcc,
subject,
body: '<p>Microsoft reply body</p>',
inReplyTo: '<microsoft-parent@example.com>',
});
expect(result).toMatchObject({ success: true });
expect(microsoft.patchedMessages).toEqual([
expect.objectContaining({
subject,
toRecipients: [{ emailAddress: { address: RECIPIENTS.to } }],
ccRecipients: [{ emailAddress: { address: RECIPIENTS.cc } }],
bccRecipients: [{ emailAddress: { address: RECIPIENTS.bcc } }],
}),
]);
expect(microsoft.sentMessageIds).toEqual(['microsoft-reply-message']);
const [message] = await findRecordNodesByFilter<{
id: string;
isDraft: boolean;
messageThreadId: string | null;
text: string | null;
}>('message', 'messages', 'id isDraft messageThreadId text', {
subject: { eq: subject },
});
expect(message).toMatchObject({
isDraft: false,
messageThreadId: expect.any(String),
text: 'Microsoft reply body',
});
expect(
await findRecordNodesByFilter<{
messageChannelId: string;
messageExternalId: string;
}>(
'messageChannelMessageAssociation',
'messageChannelMessageAssociations',
'messageChannelId messageExternalId',
{ messageId: { eq: message.id } },
),
).toEqual([
expect.objectContaining({
messageChannelId: channel.channelId,
messageExternalId: 'microsoft-reply-message',
}),
]);
expect(
await findRecordNodesByFilter<{ handle: string; role: string }>(
'messageParticipant',
'messageParticipants',
'handle role',
{ messageId: { eq: message.id } },
),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ handle: RECIPIENTS.to, role: 'TO' }),
expect.objectContaining({ handle: RECIPIENTS.cc, role: 'CC' }),
expect.objectContaining({ handle: RECIPIENTS.bcc, role: 'BCC' }),
]),
);
}, 60000);
it('sends a synced Microsoft draft through GraphQL and replaces it in the database', async () => {
const [draft] = await findRecordNodesByFilter<{
id: string;
isDraft: boolean;
}>('message', 'messages', 'id isDraft', {
subject: { eq: DRAFT_SUBJECT },
});
expect(draft).toMatchObject({
isDraft: true,
});
expect(
await findRecordNodesByFilter<{
messageChannelId: string;
messageExternalId: string;
}>(
'messageChannelMessageAssociation',
'messageChannelMessageAssociations',
'messageChannelId messageExternalId',
{ messageId: { eq: draft.id } },
),
).toEqual([
expect.objectContaining({
messageChannelId: channel.channelId,
messageExternalId: DRAFT_MESSAGE.id,
}),
]);
expect(
await findRecordNodesByFilter<{ handle: string; role: string }>(
'messageParticipant',
'messageParticipants',
'handle role',
{ messageId: { eq: draft.id } },
),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ handle: RECIPIENTS.to, role: 'TO' }),
expect.objectContaining({ handle: RECIPIENTS.cc, role: 'CC' }),
expect.objectContaining({ handle: RECIPIENTS.bcc, role: 'BCC' }),
]),
);
const result = await sendEmail({
connectedAccountId: channel.connectedAccountId,
to: RECIPIENTS.to,
cc: RECIPIENTS.cc,
bcc: RECIPIENTS.bcc,
subject: DRAFT_SUBJECT,
body: 'Microsoft draft body',
draftMessageId: draft.id,
});
expect(result).toMatchObject({ success: true });
expect(microsoft.createdMessages).toEqual([
expect.objectContaining({
subject: DRAFT_SUBJECT,
toRecipients: [{ emailAddress: { address: RECIPIENTS.to } }],
ccRecipients: [{ emailAddress: { address: RECIPIENTS.cc } }],
bccRecipients: [{ emailAddress: { address: RECIPIENTS.bcc } }],
}),
]);
expect(microsoft.sentMessageIds).toEqual(['microsoft-message-1']);
const [sentMessage] = await findRecordNodesByFilter<{
id: string;
isDraft: boolean;
messageThreadId: string | null;
text: string | null;
}>('message', 'messages', 'id isDraft messageThreadId text', {
subject: { eq: DRAFT_SUBJECT },
});
expect(sentMessage).toMatchObject({
isDraft: false,
messageThreadId: expect.any(String),
text: 'Microsoft draft body',
});
expect(
await findRecordNodesByFilter<{
messageChannelId: string;
messageExternalId: string;
}>(
'messageChannelMessageAssociation',
'messageChannelMessageAssociations',
'messageChannelId messageExternalId',
{ messageId: { eq: sentMessage.id } },
),
).toEqual([
expect.objectContaining({
messageChannelId: channel.channelId,
messageExternalId: 'microsoft-message-1',
}),
]);
expect(result.messageThreadId).toEqual(expect.any(String));
}, 60000);
it('imports the verified aliases during sync and sends from one of them', async () => {
const connectedAccount = await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).findOneByOrFail({ id: channel.connectedAccountId });
expect(connectedAccount.handleAliases).toEqual([ALIAS]);
const subject = `Microsoft alias outbound ${randomUUID()}`;
const result = await sendEmail({
connectedAccountId: channel.connectedAccountId,
fromHandle: ALIAS,
to: RECIPIENTS.to,
subject,
body: '<p>Microsoft alias body</p>',
});
expect(result).toMatchObject({ success: true });
expect(microsoft.createdMessages).toEqual([
expect.objectContaining({
subject,
from: { emailAddress: { address: ALIAS } },
}),
]);
}, 60000);
it('replies from a verified alias by patching the reply draft', async () => {
const subject = `Microsoft alias reply ${randomUUID()}`;
const result = await sendEmail({
connectedAccountId: channel.connectedAccountId,
fromHandle: ALIAS,
to: RECIPIENTS.to,
subject,
body: '<p>Microsoft alias reply body</p>',
inReplyTo: '<microsoft-parent@example.com>',
});
expect(result).toMatchObject({ success: true });
expect(microsoft.patchedMessages).toEqual([
expect.objectContaining({
subject,
from: { emailAddress: { address: ALIAS } },
}),
]);
}, 60000);
it('refuses to send from an address the account has not verified', async () => {
const result = await sendEmail({
connectedAccountId: channel.connectedAccountId,
fromHandle: 'not-my-alias@apple.dev',
to: RECIPIENTS.to,
subject: `Microsoft rejected sender ${randomUUID()}`,
body: '<p>Microsoft rejected body</p>',
});
expect(result).toMatchObject({
success: false,
error: expect.stringContaining(
'is not the connected account handle nor one of its verified aliases',
),
});
expect(microsoft.createdMessages).toEqual([]);
}, 60000);
it('creates and persists a calendar event with invitations and conferencing', async () => {
const title = `Microsoft calendar outbound ${randomUUID()}`;
const result = await createCalendarEvent({
connectedAccountId: channel.connectedAccountId,
title,
description: 'Planning meeting',
location: 'Room 101',
startsAt: '2026-08-13T09:00:00Z',
endsAt: '2026-08-13T10:00:00Z',
timeZone: 'UTC',
attendees: RECIPIENTS.to,
sendInvitations: true,
addConferencing: true,
});
expect(result).toMatchObject({ success: true });
expect(result.iCalUid).toContain('@microsoft.com');
expect(microsoft.createdCalendarEvents).toEqual([
expect.objectContaining({
subject: title,
location: { displayName: 'Room 101' },
attendees: [
{
emailAddress: { address: RECIPIENTS.to },
type: 'required',
},
],
isOnlineMeeting: true,
onlineMeetingProvider: 'teamsForBusiness',
}),
]);
expect(await findImportedCalendarEventTitles([title])).toEqual([title]);
}, 60000);
});

View File

@@ -0,0 +1,36 @@
import { http, HttpResponse } from 'msw';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
export const MICROSOFT_TOKEN_URL =
'https://login.microsoftonline.com/common/oauth2/v2.0/token';
export const microsoftAuthHandlers = (
handle: string,
aliases: string[] = [],
): MswHandler[] => [
http.post(MICROSOFT_TOKEN_URL, () =>
HttpResponse.json({
token_type: 'Bearer',
access_token: 'mock-access-token',
refresh_token: 'mock-refresh-token',
expires_in: 3600,
scope:
'openid profile email offline_access Mail.Send Calendars.ReadWrite',
}),
),
http.get('https://graph.microsoft.com/v1.0/me', () =>
HttpResponse.json({
id: 'microsoft-user-id',
displayName: 'Jane Austen',
givenName: 'Jane',
surname: 'Austen',
mail: handle,
userPrincipalName: handle,
proxyAddresses: [
`SMTP:${handle}`,
...aliases.map((alias) => `smtp:${alias}`),
],
}),
),
];

View File

@@ -0,0 +1,23 @@
import { randomUUID } from 'node:crypto';
import { type Event } from '@microsoft/microsoft-graph-types';
export const microsoftCalendarEvent = (
overrides: Partial<Event> = {},
): Event => {
const id = overrides.id ?? `microsoft-calendar-event-${randomUUID()}`;
return {
id,
iCalUId: `${id}@microsoft.com`,
subject: `Calendar event ${id}`,
isCancelled: false,
isAllDay: false,
start: { dateTime: '2023-11-15T10:00:00.000Z', timeZone: 'UTC' },
end: { dateTime: '2023-11-15T11:00:00.000Z', timeZone: 'UTC' },
createdDateTime: '2023-11-01T00:00:00.000Z',
lastModifiedDateTime: '2023-11-01T00:00:00.000Z',
attendees: [],
...overrides,
};
};

View File

@@ -0,0 +1,28 @@
import { type Event } from '@microsoft/microsoft-graph-types';
import { http, HttpResponse } from 'msw';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
export const microsoftCalendarEventsHandlers = (
events: Event[],
deltaToken: string,
removedEventIds: string[] = [],
): MswHandler[] => [
http.get('*/me/calendar/events/delta', () =>
HttpResponse.json({
value: [
...events.map((event) => ({ id: event.id })),
...removedEventIds.map((id) => ({
id,
'@removed': { reason: 'deleted' },
})),
],
'@odata.deltaLink': `https://graph.microsoft.com/beta/me/calendar/events/delta?$deltatoken=${deltaToken}`,
}),
),
...events.map((event) =>
http.get(`*/me/calendar/events/${event.id}`, () =>
HttpResponse.json(event),
),
),
];

View File

@@ -0,0 +1,66 @@
import { type MailFolder } from '@microsoft/microsoft-graph-types';
import { http, HttpResponse } from 'msw';
import { isDefined } from 'twenty-shared/utils';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
import { type MockEntityStore } from 'test/integration/utils/mock-entity-store.util';
export const microsoftMailboxHandlers = (
folderStore: MockEntityStore<MailFolder>,
messages: Array<Record<string, unknown>> = [],
removedMessageIdsByFolderId: Record<string, string[]> = {},
{ unfetchableMessageIds = [] }: { unfetchableMessageIds?: string[] } = {},
): MswHandler[] => [
http.get('*/me/mailFolders', () =>
HttpResponse.json<{ value: MailFolder[] }>({ value: folderStore.list() }),
),
http.get('*/messages/delta', () =>
HttpResponse.json({
value: [],
'@odata.deltaLink':
'https://graph.microsoft.com/beta/me/mailfolders/inbox/messages/delta?$deltatoken=mock-delta-token',
}),
),
http.post('*/$batch', async ({ request }) => {
const { requests } = (await request.json()) as {
requests: Array<{ id: string; url: string }>;
};
return HttpResponse.json({
responses: requests.map(({ id, url }) => {
if (url.includes('/messages/delta')) {
const folderId = url.match(/mailfolders\/([^/]+)\//)?.[1];
return {
id,
status: 200,
body: {
value: [
...messages
.filter((message) => message.parentFolderId === folderId)
.map((message) => ({ id: message.id })),
...(removedMessageIdsByFolderId[folderId ?? ''] ?? []).map(
(removedMessageId) => ({
id: removedMessageId,
'@removed': { reason: 'deleted' },
}),
),
],
'@odata.deltaLink': `https://graph.microsoft.com/beta${url}`,
},
};
}
const messageId = url.match(/\/me\/messages\/([^?]+)/)?.[1];
const message = messages.find(
(candidate) => candidate.id === messageId,
);
return isDefined(message) &&
!unfetchableMessageIds.includes(messageId ?? '')
? { id, status: 200, body: message }
: { id, status: 404, body: { error: { message: 'Not Found' } } };
}),
});
}),
];

View File

@@ -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<Subscription>({
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 });
}),
];

View File

@@ -0,0 +1,283 @@
import { type Event, type MailFolder } from '@microsoft/microsoft-graph-types';
import { isNonEmptyString } from '@sniptt/guards';
import { http, HttpResponse } from 'msw';
import { isDefined } from 'twenty-shared/utils';
import { setupHttpMock } from 'test/integration/utils/http-mock.util';
import {
MICROSOFT_TOKEN_URL,
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,
} from 'test/integration/utils/mock-entity-store.util';
const DEFAULT_FOLDERS: MailFolder[] = [
{ id: 'inbox', displayName: 'Inbox' },
{ id: 'sentitems', displayName: 'Sent Items' },
];
export type MicrosoftMock = {
folders: MockEntityStore<MailFolder>;
subscriptions: MicrosoftSubscriptionStore;
createdMessages: Array<Record<string, unknown>>;
patchedMessages: Array<Record<string, unknown>>;
createdAttachments: Array<Record<string, unknown>>;
sentMessageIds: string[];
createdCalendarEvents: Event[];
serveCalendarEvents: (
events: Event[],
options?: { deltaToken?: string; removedEventIds?: string[] },
) => void;
moveMessageToFolder: (messageId: string, targetFolderId: string) => void;
deleteMessage: (messageId: string) => void;
failMessageFetch: (messageId: string) => void;
failSubscriptionRenewal: () => void;
failMessageDelta: (failure: MicrosoftGraphFailure) => void;
failCalendarDelta: (failure: MicrosoftGraphFailure) => void;
declineTokenRefresh: () => void;
};
export type MicrosoftGraphFailure = {
status: number;
code: string;
message: string;
};
const microsoftGraphErrorResponse = ({
status,
code,
message,
}: MicrosoftGraphFailure) =>
HttpResponse.json({ error: { code, message } }, { status });
export const setupMicrosoftMock = ({
handle,
aliases = [],
folders = DEFAULT_FOLDERS,
messages = [],
}: {
handle: string;
aliases?: string[];
folders?: MailFolder[];
messages?: Array<Record<string, unknown>>;
}): MicrosoftMock => {
const folderStore = createMockEntityStore(
folders,
(folder) => folder.id ?? '',
);
const subscriptionStore = createMicrosoftSubscriptionStore();
const createdMessages: Array<Record<string, unknown>> = [];
const patchedMessages: Array<Record<string, unknown>> = [];
const createdAttachments: Array<Record<string, unknown>> = [];
const sentMessageIds: string[] = [];
const createdCalendarEvents: Event[] = [];
const removedMessageIdsByFolderId: Record<string, string[]> = {};
const recordFolderRemoval = (messageId: string) => {
const message = messages.find((candidate) => candidate.id === messageId);
if (!isDefined(message)) {
throw new Error(`No mocked Microsoft message with id ${messageId}`);
}
const folderId = message.parentFolderId;
if (!isNonEmptyString(folderId)) {
throw new Error(
`Mocked Microsoft message ${messageId} has no parentFolderId`,
);
}
removedMessageIdsByFolderId[folderId] = [
...(removedMessageIdsByFolderId[folderId] ?? []),
messageId,
];
return message;
};
const httpMock = setupHttpMock(
...microsoftAuthHandlers(handle, aliases),
...microsoftMailboxHandlers(
folderStore,
messages,
removedMessageIdsByFolderId,
),
...microsoftWebhookSubscriptionHandlers(subscriptionStore),
http.post('*/me/messages', async ({ request }) => {
const message = (await request.json()) as Record<string, unknown>;
const id = `microsoft-message-${createdMessages.length + 1}`;
createdMessages.push(message);
return HttpResponse.json({
id,
internetMessageId: `<${id}@example.com>`,
conversationId: `microsoft-conversation-${createdMessages.length}`,
});
}),
http.post(
'*/me/messages/:messageId/attachments',
async ({ request, params }) => {
const attachment = (await request.json()) as Record<string, unknown>;
createdAttachments.push({
messageId: params.messageId as string,
...attachment,
});
return HttpResponse.json({
id: `microsoft-attachment-${createdAttachments.length}`,
...attachment,
});
},
),
http.post('*/me/messages/:messageId/send', ({ params }) => {
sentMessageIds.push(params.messageId as string);
return new HttpResponse(null, { status: 202 });
}),
http.delete(
'*/me/messages/:messageId',
() => new HttpResponse(null, { status: 204 }),
),
http.get('*/me/messages', () =>
HttpResponse.json({
value: [
{
id: 'microsoft-parent-message',
internetMessageId: '<microsoft-parent@example.com>',
},
],
}),
),
http.post('*/me/messages/:messageId/createReply', () =>
HttpResponse.json({
id: 'microsoft-reply-message',
internetMessageId: '<microsoft-reply-message@example.com>',
conversationId: 'microsoft-parent-conversation',
}),
),
http.patch('*/me/messages/:messageId', async ({ request }) => {
const message = (await request.json()) as Record<string, unknown>;
patchedMessages.push(message);
return HttpResponse.json({
internetMessageId: '<microsoft-reply-message@example.com>',
conversationId: 'microsoft-parent-conversation',
});
}),
http.post('*/me/calendar/events', async ({ request }) => {
const event = (await request.json()) as Event;
const id = `microsoft-created-calendar-event-${createdCalendarEvents.length + 1}`;
const createdEvent: Event = {
...event,
id,
iCalUId: `${id}@microsoft.com`,
isCancelled: false,
createdDateTime: '2026-08-13T00:00:00.000Z',
lastModifiedDateTime: '2026-08-13T00:00:00.000Z',
};
createdCalendarEvents.push(createdEvent);
return HttpResponse.json(createdEvent);
}),
);
return {
folders: folderStore,
subscriptions: subscriptionStore,
createdMessages,
patchedMessages,
createdAttachments,
sentMessageIds,
createdCalendarEvents,
serveCalendarEvents: (
events,
{ deltaToken = 'mock-calendar-delta-token', removedEventIds = [] } = {},
) =>
httpMock.use(
...microsoftCalendarEventsHandlers(events, deltaToken, removedEventIds),
),
moveMessageToFolder: (messageId, targetFolderId) => {
const message = recordFolderRemoval(messageId);
message.parentFolderId = targetFolderId;
},
deleteMessage: (messageId) => {
const message = recordFolderRemoval(messageId);
messages.splice(messages.indexOf(message), 1);
},
failMessageFetch: (messageId) =>
httpMock.use(
...microsoftMailboxHandlers(
folderStore,
messages,
removedMessageIdsByFolderId,
{ unfetchableMessageIds: [messageId] },
),
),
failSubscriptionRenewal: () =>
httpMock.use(
...microsoftWebhookSubscriptionHandlers(subscriptionStore, {
renewalFails: true,
}),
),
// The driver issues the per-folder delta requests inside a /$batch POST, so
// the failure has to come back as the status on each sub-response rather
// than on the delta route itself.
failMessageDelta: (failure) =>
httpMock.use(
http.post('*/$batch', async ({ request }) => {
const { requests } = (await request.json()) as {
requests: { id: string }[];
};
return HttpResponse.json({
responses: requests.map(({ id }) => ({
id,
status: failure.status,
body: {
error: { code: failure.code, message: failure.message },
},
})),
});
}),
http.get('*/messages/delta', () =>
microsoftGraphErrorResponse(failure),
),
),
failCalendarDelta: (failure) =>
httpMock.use(
http.get('*/me/calendar/events/delta', () =>
microsoftGraphErrorResponse(failure),
),
),
declineTokenRefresh: () =>
httpMock.use(
http.post(MICROSOFT_TOKEN_URL, () =>
HttpResponse.json(
{
error: 'invalid_grant',
error_description:
'AADSTS50173: The provided grant has expired due to it being revoked',
},
{ status: 400 },
),
),
),
};
};

View File

@@ -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<ReturnType<typeof connectMessagingAccount>>;
const calendarChannelRepository = () =>
getCoreRepository<CalendarChannelEntity>(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);
});

View File

@@ -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<ReturnType<typeof connectMessagingAccount>>;
const messageChannelRepository = () =>
getCoreRepository<MessageChannelEntity>(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);
});

View File

@@ -0,0 +1,136 @@
import {
ConnectedAccountProvider,
WebhookSubscriptionStatus,
} from 'twenty-shared/types';
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 { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { WebhookSubscriptionRenewalCronJob } from 'src/modules/connected-account/webhook-subscription-manager/crons/jobs/webhook-subscription-renewal.cron.job';
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 { runSyncCron } from 'test/integration/utils/run-sync-cron.util';
const HANDLE = 'microsoft-webhook-renewal-auth@apple.dev';
const CLIENT_STATE = 'renewal-auth-client-state';
const SUBSCRIPTION_ID = 'subscription-due-for-renewal';
describe('Microsoft webhook subscription renewal (integration)', () => {
const microsoft = setupMicrosoftMock({ handle: HANDLE });
let account: Awaited<ReturnType<typeof connectMessagingAccount>>;
let storedRefreshToken: EncryptedString | null;
const calendarChannelRepository = () =>
getCoreRepository<CalendarChannelEntity>(CalendarChannelEntity);
const connectedAccountRepository = () =>
getCoreRepository<ConnectedAccountEntity>(ConnectedAccountEntity);
const readChannel = async () =>
await calendarChannelRepository().findOneOrFail({
where: { id: account.calendarChannelId },
});
const makeSubscriptionDueForRenewal = async () => {
await calendarChannelRepository().update(account.calendarChannelId, {
webhookSubscriptionExternalId: SUBSCRIPTION_ID,
webhookSubscriptionClientState: CLIENT_STATE,
webhookSubscriptionStatus: WebhookSubscriptionStatus.ACTIVE,
webhookSubscriptionExpiresAt: new Date(Date.now() + 60 * 1000),
});
};
// resolveTokens short-circuits on a still-valid access token, so the refresh
// token is only consulted once lastCredentialsRefreshedAt is stale.
const revokeRefreshToken = async () => {
await connectedAccountRepository().update(account.connectedAccountId, {
refreshToken: null,
lastCredentialsRefreshedAt: null,
});
};
const restoreRefreshToken = async () => {
await connectedAccountRepository().update(account.connectedAccountId, {
refreshToken: storedRefreshToken,
lastCredentialsRefreshedAt: null,
});
};
beforeAll(async () => {
account = await connectMessagingAccount({
provider: ConnectedAccountProvider.MICROSOFT,
handle: HANDLE,
});
const connectedAccount = await connectedAccountRepository().findOneOrFail({
where: { id: account.connectedAccountId },
});
storedRefreshToken = connectedAccount.refreshToken;
}, 120000);
beforeEach(async () => {
microsoft.subscriptions.reset();
await makeSubscriptionDueForRenewal();
});
afterAll(async () => {
await account?.cleanup().catch(() => undefined);
});
it('renews a subscription that is close to expiring', async () => {
await runSyncCron(WebhookSubscriptionRenewalCronJob);
expect(microsoft.subscriptions.renewed).toContain(SUBSCRIPTION_ID);
const channel = await readChannel();
expect(channel.webhookSubscriptionStatus).toBe(
WebhookSubscriptionStatus.ACTIVE,
);
}, 60000);
describe('when the connected account has no usable refresh token', () => {
beforeEach(async () => {
await revokeRefreshToken();
});
it('expires the channel rather than marking it failed', async () => {
await runSyncCron(WebhookSubscriptionRenewalCronJob);
const channel = await readChannel();
expect(channel.webhookSubscriptionStatus).toBe(
WebhookSubscriptionStatus.EXPIRED,
);
expect(microsoft.subscriptions.renewed).not.toContain(SUBSCRIPTION_ID);
}, 60000);
// Restoring the token before the second run is what makes this meaningful:
// a re-selected channel would reach the provider and get a subscription.
it('stops selecting the channel on subsequent cron runs', async () => {
await runSyncCron(WebhookSubscriptionRenewalCronJob);
expect((await readChannel()).webhookSubscriptionStatus).toBe(
WebhookSubscriptionStatus.EXPIRED,
);
await restoreRefreshToken();
microsoft.subscriptions.reset();
await runSyncCron(WebhookSubscriptionRenewalCronJob);
const channel = await readChannel();
expect(channel.webhookSubscriptionStatus).toBe(
WebhookSubscriptionStatus.EXPIRED,
);
expect(channel.webhookSubscriptionExternalId).toBe(SUBSCRIPTION_ID);
expect(microsoft.subscriptions.created).toHaveLength(0);
expect(microsoft.subscriptions.renewed).not.toContain(SUBSCRIPTION_ID);
}, 60000);
});
});