CalDAV refactor (#20180)
Original CalDAV driver was written almost a year ago and code quality, patterns were not up to the mark including having no test coverage, this PR does the following: - Splits the monolithic driver into isolated utilities with test coverage - Adds support for syncing legacy servers by checking if server supports `syncCollection` and branches into two sync methods `fetchEventsViaSyncCollection` or `fetchEventsViaCtagEtag` with this I believe our driver is feature complete Real testing report | Provider | Server | Sync method | Auth | | --------- | ----------------- | -------------------- | ------ | | iCloud | Apple's CalDAV | sync-collection | Basic | | Nextcloud | sabre/dav | sync-collection | Basic | | all-inkl | sabre/dav (older) | ctag + etag fallback | Digest |
This commit is contained in:
+2
@@ -8,6 +8,7 @@ import { MessageQueueModule } from 'src/engine/core-modules/message-queue/messag
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
import { CalDavDriverModule } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/caldav-driver.module';
|
||||
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
|
||||
import { IMAPAPIsModule } from 'src/modules/connected-account/imap-api/imap-apis.module';
|
||||
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
|
||||
@@ -30,6 +31,7 @@ import { ImapSmtpCaldavService } from './services/imap-smtp-caldav-connection.se
|
||||
ImapSmtpCaldavValidatorModule,
|
||||
PermissionsModule,
|
||||
SecureHttpClientModule,
|
||||
CalDavDriverModule,
|
||||
],
|
||||
providers: [ImapSmtpCaldavResolver, ImapSmtpCaldavService],
|
||||
exports: [ImapSmtpCaldavService],
|
||||
|
||||
+55
-40
@@ -1,55 +1,49 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
|
||||
import { type DAVClient } from 'tsdav';
|
||||
|
||||
import { ImapSmtpCaldavService } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection.service';
|
||||
import { type ConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
|
||||
jest.mock(
|
||||
'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client',
|
||||
() => ({
|
||||
CalDAVClient: jest.fn().mockImplementation(() => ({
|
||||
listCalendars: jest.fn().mockResolvedValue([]),
|
||||
validateSyncCollectionSupport: jest.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
|
||||
|
||||
const MockCalDAVClient = jest.mocked(CalDAVClient);
|
||||
import { CalDavClientService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client.service';
|
||||
import { CalDavFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service';
|
||||
|
||||
describe('ImapSmtpCaldavService', () => {
|
||||
let service: ImapSmtpCaldavService;
|
||||
|
||||
const mockSsrfSafeFetch = jest.fn();
|
||||
const mockClient = {} as DAVClient;
|
||||
|
||||
const mockSecureHttpClientService = {
|
||||
createSsrfSafeFetch: jest.fn().mockReturnValue(mockSsrfSafeFetch),
|
||||
const mockCalDavClientService = {
|
||||
getClient: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCalDavFetchEventsService = {
|
||||
listEventCalendars: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockSecureHttpClientService.createSsrfSafeFetch.mockReturnValue(
|
||||
mockSsrfSafeFetch,
|
||||
);
|
||||
mockCalDavClientService.getClient.mockResolvedValue(mockClient);
|
||||
mockCalDavFetchEventsService.listEventCalendars.mockResolvedValue([
|
||||
{ url: 'https://caldav.example.com/calendars/user/default/' },
|
||||
]);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ImapSmtpCaldavService,
|
||||
{ provide: GlobalWorkspaceOrmManager, useValue: {} },
|
||||
{ provide: getRepositoryToken(ConnectedAccountEntity), useValue: {} },
|
||||
{ provide: SecureHttpClientService, useValue: {} },
|
||||
{
|
||||
provide: GlobalWorkspaceOrmManager,
|
||||
useValue: {},
|
||||
provide: CalDavClientService,
|
||||
useValue: mockCalDavClientService,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ConnectedAccountEntity),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: SecureHttpClientService,
|
||||
useValue: mockSecureHttpClientService,
|
||||
provide: CalDavFetchEventsService,
|
||||
useValue: mockCalDavFetchEventsService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
@@ -58,22 +52,43 @@ describe('ImapSmtpCaldavService', () => {
|
||||
});
|
||||
|
||||
describe('testCaldavConnection', () => {
|
||||
it('should pass SSRF-safe fetch to CalDAVClient', async () => {
|
||||
const params: ConnectionParameters = {
|
||||
host: 'https://caldav.example.com',
|
||||
port: 443,
|
||||
username: 'user@example.com',
|
||||
password: 'password123',
|
||||
};
|
||||
const params: ConnectionParameters = {
|
||||
host: 'https://caldav.example.com',
|
||||
port: 443,
|
||||
username: 'user@example.com',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
it('builds a CalDAV client and lists its event calendars', async () => {
|
||||
await service.testCaldavConnection('user@example.com', params);
|
||||
|
||||
expect(mockCalDavClientService.getClient).toHaveBeenCalledWith({
|
||||
serverUrl: 'https://caldav.example.com',
|
||||
username: 'user@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
expect(
|
||||
mockSecureHttpClientService.createSsrfSafeFetch,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(MockCalDAVClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fetch: mockSsrfSafeFetch }),
|
||||
mockCalDavFetchEventsService.listEventCalendars,
|
||||
).toHaveBeenCalledWith(mockClient);
|
||||
});
|
||||
|
||||
it('falls back to the handle when CALDAV.username is missing', async () => {
|
||||
await service.testCaldavConnection('handle@example.com', {
|
||||
...params,
|
||||
username: undefined,
|
||||
});
|
||||
|
||||
expect(mockCalDavClientService.getClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ username: 'handle@example.com' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when no event calendars are found', async () => {
|
||||
mockCalDavFetchEventsService.listEventCalendars.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
service.testCaldavConnection('user@example.com', params),
|
||||
).rejects.toThrow('No calendar with event support found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+22
-17
@@ -16,7 +16,8 @@ import {
|
||||
} from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { CalDAVClient } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/lib/caldav.client';
|
||||
import { CalDavClientService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-client.service';
|
||||
import { CalDavFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/drivers/caldav/services/caldav-fetch-events.service';
|
||||
|
||||
@Injectable()
|
||||
export class ImapSmtpCaldavService {
|
||||
@@ -27,6 +28,8 @@ export class ImapSmtpCaldavService {
|
||||
@InjectRepository(ConnectedAccountEntity)
|
||||
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
|
||||
private readonly secureHttpClientService: SecureHttpClientService,
|
||||
private readonly caldavClientService: CalDavClientService,
|
||||
private readonly caldavFetchEventsService: CalDavFetchEventsService,
|
||||
) {}
|
||||
|
||||
async testImapConnection(
|
||||
@@ -132,29 +135,31 @@ export class ImapSmtpCaldavService {
|
||||
handle: string,
|
||||
params: ConnectionParameters,
|
||||
): Promise<boolean> {
|
||||
const ssrfSafeFetch = this.secureHttpClientService.createSsrfSafeFetch();
|
||||
const client = new CalDAVClient({
|
||||
serverUrl: params.host,
|
||||
username: params.username ?? handle,
|
||||
password: params.password,
|
||||
fetch: ssrfSafeFetch,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.listCalendars();
|
||||
await client.validateSyncCollectionSupport();
|
||||
const client = await this.caldavClientService.getClient({
|
||||
serverUrl: params.host,
|
||||
username: params.username ?? handle,
|
||||
password: params.password,
|
||||
});
|
||||
|
||||
const calendars =
|
||||
await this.caldavFetchEventsService.listEventCalendars(client);
|
||||
|
||||
if (calendars.length === 0) {
|
||||
throw new UserInputError('No calendar with event support found', {
|
||||
userFriendlyMessage: msg`We couldn't find any calendars on your CalDAV server. Please make sure your account has at least one calendar.`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof UserInputError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
`CALDAV connection failed: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
|
||||
if (error.message?.includes('CALDAV_SYNC_COLLECTION_NOT_SUPPORTED')) {
|
||||
throw new UserInputError(`CALDAV connection failed: ${error.message}`, {
|
||||
userFriendlyMessage: msg`Your CalDAV server does not support incremental sync (RFC 6578). Please use a compatible provider such as Nextcloud, iCloud, or Fastmail.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'FailedToOpenSocket') {
|
||||
throw new UserInputError(`CALDAV connection failed: ${error.message}`, {
|
||||
userFriendlyMessage: msg`We couldn't connect to your CalDAV server. Please check your server settings and try again.`,
|
||||
|
||||
Reference in New Issue
Block a user