caldav-adapter 8.3.3 → 8.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "caldav-adapter",
3
3
  "description": "CalDAV server for Node.js and Koa. Modernized and maintained for Forward Email.",
4
- "version": "8.3.3",
4
+ "version": "8.3.5",
5
5
  "author": "Sanders DeNardi and Forward Email LLC",
6
6
  "contributors": [
7
7
  "Sanders DeNardi <sedenardi@gmail.com> (http://www.sandersdenardi.com/)",
@@ -12,6 +12,21 @@ module.exports = function (options) {
12
12
 
13
13
  return async function (ctx, events, calendar, children) {
14
14
  const eventActions = _.map(events, async (event) => {
15
+ //
16
+ // For deleted events without href, we cannot reliably construct the
17
+ // correct URL that the client originally used. Returning an incorrect
18
+ // URL causes Apple Calendar to fail with "Couldn't get a calendar item
19
+ // to remove" errors because the URL doesn't match its local cache.
20
+ //
21
+ // Skip these events in sync-collection responses. The client will
22
+ // eventually clean them up through other sync mechanisms.
23
+ //
24
+ // See: https://www.rfc-editor.org/rfc/rfc6578.html (sync-collection)
25
+ //
26
+ if (event.deleted_at && !event.href) {
27
+ return null;
28
+ }
29
+
15
30
  const misses = [];
16
31
  const propActions = _.map(children, async (child) => {
17
32
  return tags.getResponse({
@@ -36,7 +51,22 @@ module.exports = function (options) {
36
51
  // MUST NOT contain any DAV:propstat element.
37
52
  //
38
53
  const pRes = await Promise.all(propActions);
39
- const url = path.join(ctx.url, `${event.eventId}.ics`);
54
+
55
+ //
56
+ // Construct the event URL for the response.
57
+ //
58
+ // The URL must match the original resource path that the client used when
59
+ // creating the event. This is critical for sync-collection responses where
60
+ // deleted events are reported with a 404 status - the client needs to match
61
+ // the URL to its local cache to know which event to remove.
62
+ //
63
+ // Priority order:
64
+ // 1. event.href - the original resource path (if stored by the data layer)
65
+ // 2. Constructed from event.eventId - fallback for backwards compatibility
66
+ //
67
+ // See: https://www.rfc-editor.org/rfc/rfc6578.html (sync-collection)
68
+ //
69
+ const url = event.href || path.join(ctx.url, `${event.eventId}.ics`);
40
70
  const resp = event.deleted_at
41
71
  ? response(url, status[404], [], true)
42
72
  : response(url, status[200], _.compact(pRes));
@@ -50,6 +80,7 @@ module.exports = function (options) {
50
80
  return resp;
51
81
  });
52
82
  const responses = await Promise.all(eventActions);
53
- return { responses };
83
+ // Filter out null responses (deleted events without href)
84
+ return { responses: _.compact(responses) };
54
85
  };
55
86
  };
@@ -0,0 +1,375 @@
1
+ const test = require('ava');
2
+ const eventResponseFactory = require('../routes/calendar/calendar/event-response');
3
+
4
+ // Mock options with minimal required data functions
5
+ function createMockOptions() {
6
+ return {
7
+ data: {
8
+ getCalendarId: () => 'test-calendar-id',
9
+ buildICS: () => 'BEGIN:VCALENDAR\nEND:VCALENDAR',
10
+ getETag: () => '"test-etag"'
11
+ }
12
+ };
13
+ }
14
+
15
+ // Mock context
16
+ function createMockCtx(url = '/cal/user/calendar') {
17
+ return {
18
+ url,
19
+ state: {
20
+ params: {
21
+ principalId: 'user',
22
+ calendarId: 'calendar'
23
+ }
24
+ }
25
+ };
26
+ }
27
+
28
+ // Mock calendar
29
+ function createMockCalendar() {
30
+ return {
31
+ _id: 'calendar-id',
32
+ name: 'Test Calendar',
33
+ synctoken: 'http://example.com/sync/1'
34
+ };
35
+ }
36
+
37
+ test('event-response exports a function', (t) => {
38
+ const options = createMockOptions();
39
+ const eventResponse = eventResponseFactory(options);
40
+ t.is(typeof eventResponse, 'function');
41
+ });
42
+
43
+ test('event-response uses eventId to construct URL when href is not available', async (t) => {
44
+ const options = createMockOptions();
45
+ const eventResponse = eventResponseFactory(options);
46
+ const ctx = createMockCtx('/cal/user/calendar');
47
+ const calendar = createMockCalendar();
48
+
49
+ const events = [
50
+ {
51
+ eventId: 'test-event-123',
52
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
53
+ }
54
+ ];
55
+
56
+ // Empty children array - we just want to test URL construction
57
+ const children = [];
58
+
59
+ const result = await eventResponse(ctx, events, calendar, children);
60
+
61
+ t.truthy(result.responses);
62
+ t.is(result.responses.length, 1);
63
+
64
+ // Check that the response contains the correct URL
65
+ const responseObj = result.responses[0];
66
+ t.truthy(responseObj['D:href']);
67
+ t.is(responseObj['D:href'], '/cal/user/calendar/test-event-123.ics');
68
+ });
69
+
70
+ test('event-response uses event.href when available instead of constructing from eventId', async (t) => {
71
+ const options = createMockOptions();
72
+ const eventResponse = eventResponseFactory(options);
73
+ const ctx = createMockCtx('/cal/user/calendar');
74
+ const calendar = createMockCalendar();
75
+
76
+ // Event with href property (original resource path)
77
+ const events = [
78
+ {
79
+ eventId: 'modified_event_id',
80
+ href: '/cal/user/calendar/original@event.ics',
81
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
82
+ }
83
+ ];
84
+
85
+ const children = [];
86
+
87
+ const result = await eventResponse(ctx, events, calendar, children);
88
+
89
+ t.truthy(result.responses);
90
+ t.is(result.responses.length, 1);
91
+
92
+ // Check that the response uses href, not eventId
93
+ const responseObj = result.responses[0];
94
+ t.truthy(responseObj['D:href']);
95
+ t.is(responseObj['D:href'], '/cal/user/calendar/original@event.ics');
96
+ });
97
+
98
+ test('event-response returns 404 status for deleted events with href', async (t) => {
99
+ const options = createMockOptions();
100
+ const eventResponse = eventResponseFactory(options);
101
+ const ctx = createMockCtx('/cal/user/calendar');
102
+ const calendar = createMockCalendar();
103
+
104
+ // Deleted event WITH href - should be included in response
105
+ const events = [
106
+ {
107
+ eventId: 'deleted-event',
108
+ href: '/cal/user/calendar/deleted-event.ics',
109
+ deleted_at: new Date(),
110
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
111
+ }
112
+ ];
113
+
114
+ const children = [];
115
+
116
+ const result = await eventResponse(ctx, events, calendar, children);
117
+
118
+ t.truthy(result.responses);
119
+ t.is(result.responses.length, 1);
120
+
121
+ const responseObj = result.responses[0];
122
+ t.truthy(responseObj['D:href']);
123
+ t.is(responseObj['D:href'], '/cal/user/calendar/deleted-event.ics');
124
+
125
+ // Check for 404 status
126
+ t.truthy(responseObj['D:status']);
127
+ t.true(responseObj['D:status'].includes('404'));
128
+ });
129
+
130
+ test('event-response skips deleted events without href', async (t) => {
131
+ const options = createMockOptions();
132
+ const eventResponse = eventResponseFactory(options);
133
+ const ctx = createMockCtx('/cal/user/calendar');
134
+ const calendar = createMockCalendar();
135
+
136
+ // Deleted event WITHOUT href - should be skipped to prevent Apple sync errors
137
+ const events = [
138
+ {
139
+ eventId: 'deleted-event-no-href',
140
+ deleted_at: new Date(),
141
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
142
+ }
143
+ ];
144
+
145
+ const children = [];
146
+
147
+ const result = await eventResponse(ctx, events, calendar, children);
148
+
149
+ t.truthy(result.responses);
150
+ // Should be empty - deleted events without href are skipped
151
+ t.is(result.responses.length, 0);
152
+ });
153
+
154
+ test('event-response uses href for deleted events when available (critical for sync)', async (t) => {
155
+ const options = createMockOptions();
156
+ const eventResponse = eventResponseFactory(options);
157
+ const ctx = createMockCtx('/cal/user/calendar');
158
+ const calendar = createMockCalendar();
159
+
160
+ // Deleted event with href - simulates the case where eventId was modified
161
+ // (e.g., @ replaced with _) but we stored the original href
162
+ const events = [
163
+ {
164
+ eventId: 'event123_example.com',
165
+ href: '/cal/user/calendar/event123@example.com.ics',
166
+ deleted_at: new Date(),
167
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
168
+ }
169
+ ];
170
+
171
+ const children = [];
172
+
173
+ const result = await eventResponse(ctx, events, calendar, children);
174
+
175
+ t.truthy(result.responses);
176
+ t.is(result.responses.length, 1);
177
+
178
+ const responseObj = result.responses[0];
179
+ // Should use href, not eventId
180
+ t.is(responseObj['D:href'], '/cal/user/calendar/event123@example.com.ics');
181
+ t.truthy(responseObj['D:status']);
182
+ t.true(responseObj['D:status'].includes('404'));
183
+ });
184
+
185
+ test('event-response returns 200 status for non-deleted events', async (t) => {
186
+ const options = createMockOptions();
187
+ const eventResponse = eventResponseFactory(options);
188
+ const ctx = createMockCtx('/cal/user/calendar');
189
+ const calendar = createMockCalendar();
190
+
191
+ const events = [
192
+ {
193
+ eventId: 'active-event',
194
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
195
+ }
196
+ ];
197
+
198
+ const children = [];
199
+
200
+ const result = await eventResponse(ctx, events, calendar, children);
201
+
202
+ t.truthy(result.responses);
203
+ t.is(result.responses.length, 1);
204
+
205
+ const responseObj = result.responses[0];
206
+ // Non-deleted events should have propstat, not status
207
+ t.truthy(responseObj['D:propstat']);
208
+ });
209
+
210
+ test('event-response handles multiple events correctly', async (t) => {
211
+ const options = createMockOptions();
212
+ const eventResponse = eventResponseFactory(options);
213
+ const ctx = createMockCtx('/cal/user/calendar');
214
+ const calendar = createMockCalendar();
215
+
216
+ const events = [
217
+ {
218
+ eventId: 'event1',
219
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
220
+ },
221
+ {
222
+ eventId: 'event2',
223
+ href: '/cal/user/calendar/custom-path.ics',
224
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
225
+ },
226
+ {
227
+ eventId: 'event3',
228
+ href: '/cal/user/calendar/event3.ics',
229
+ deleted_at: new Date(),
230
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
231
+ }
232
+ ];
233
+
234
+ const children = [];
235
+
236
+ const result = await eventResponse(ctx, events, calendar, children);
237
+
238
+ t.truthy(result.responses);
239
+ t.is(result.responses.length, 3);
240
+
241
+ // Event 1: uses eventId
242
+ t.is(result.responses[0]['D:href'], '/cal/user/calendar/event1.ics');
243
+
244
+ // Event 2: uses href
245
+ t.is(result.responses[1]['D:href'], '/cal/user/calendar/custom-path.ics');
246
+
247
+ // Event 3: deleted with href
248
+ t.is(result.responses[2]['D:href'], '/cal/user/calendar/event3.ics');
249
+ t.true(result.responses[2]['D:status'].includes('404'));
250
+ });
251
+
252
+ test('event-response handles multiple events with mixed deleted states', async (t) => {
253
+ const options = createMockOptions();
254
+ const eventResponse = eventResponseFactory(options);
255
+ const ctx = createMockCtx('/cal/user/calendar');
256
+ const calendar = createMockCalendar();
257
+
258
+ // Mix of events: active, deleted with href, deleted without href
259
+ const events = [
260
+ {
261
+ eventId: 'active-event',
262
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
263
+ },
264
+ {
265
+ eventId: 'deleted-with-href',
266
+ href: '/cal/user/calendar/deleted-with-href.ics',
267
+ deleted_at: new Date(),
268
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
269
+ },
270
+ {
271
+ eventId: 'deleted-no-href',
272
+ deleted_at: new Date(),
273
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
274
+ }
275
+ ];
276
+
277
+ const children = [];
278
+
279
+ const result = await eventResponse(ctx, events, calendar, children);
280
+
281
+ t.truthy(result.responses);
282
+ // Only 2 responses - deleted without href is skipped
283
+ t.is(result.responses.length, 2);
284
+
285
+ // Active event
286
+ t.is(result.responses[0]['D:href'], '/cal/user/calendar/active-event.ics');
287
+
288
+ // Deleted event with href
289
+ t.is(
290
+ result.responses[1]['D:href'],
291
+ '/cal/user/calendar/deleted-with-href.ics'
292
+ );
293
+ t.true(result.responses[1]['D:status'].includes('404'));
294
+ });
295
+
296
+ test('event-response handles email-like eventId with @ symbol', async (t) => {
297
+ const options = createMockOptions();
298
+ const eventResponse = eventResponseFactory(options);
299
+ const ctx = createMockCtx('/cal/user/calendar');
300
+ const calendar = createMockCalendar();
301
+
302
+ // Event with email-like eventId (contains @)
303
+ const events = [
304
+ {
305
+ eventId: 'meeting@company.com',
306
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
307
+ }
308
+ ];
309
+
310
+ const children = [];
311
+
312
+ const result = await eventResponse(ctx, events, calendar, children);
313
+
314
+ t.truthy(result.responses);
315
+ t.is(result.responses.length, 1);
316
+
317
+ // Should preserve the @ in the URL
318
+ t.is(
319
+ result.responses[0]['D:href'],
320
+ '/cal/user/calendar/meeting@company.com.ics'
321
+ );
322
+ });
323
+
324
+ test('event-response handles special characters in eventId', async (t) => {
325
+ const options = createMockOptions();
326
+ const eventResponse = eventResponseFactory(options);
327
+ const ctx = createMockCtx('/cal/user/calendar');
328
+ const calendar = createMockCalendar();
329
+
330
+ // Event with special characters in eventId
331
+ const events = [
332
+ {
333
+ eventId: 'event-with-special_chars.123',
334
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
335
+ }
336
+ ];
337
+
338
+ const children = [];
339
+
340
+ const result = await eventResponse(ctx, events, calendar, children);
341
+
342
+ t.truthy(result.responses);
343
+ t.is(result.responses.length, 1);
344
+
345
+ t.is(
346
+ result.responses[0]['D:href'],
347
+ '/cal/user/calendar/event-with-special_chars.123.ics'
348
+ );
349
+ });
350
+
351
+ test('backwards compatibility: events without href use eventId', async (t) => {
352
+ const options = createMockOptions();
353
+ const eventResponse = eventResponseFactory(options);
354
+ const ctx = createMockCtx('/cal/user/calendar');
355
+ const calendar = createMockCalendar();
356
+
357
+ // Simulate existing event without href field (backwards compatibility)
358
+ const events = [
359
+ {
360
+ eventId: 'legacy-event-id',
361
+ ical: 'BEGIN:VCALENDAR\nEND:VCALENDAR'
362
+ // No href field - simulates existing events before this fix
363
+ }
364
+ ];
365
+
366
+ const children = [];
367
+
368
+ const result = await eventResponse(ctx, events, calendar, children);
369
+
370
+ t.truthy(result.responses);
371
+ t.is(result.responses.length, 1);
372
+
373
+ // Should fall back to constructing URL from eventId
374
+ t.is(result.responses[0]['D:href'], '/cal/user/calendar/legacy-event-id.ics');
375
+ });