caldav-adapter 9.0.0 → 9.2.0

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.
@@ -11,8 +11,11 @@ const setDAVHeader = function (ctx) {
11
11
  '3',
12
12
  // 'extended-mkcol',
13
13
  'calendar-access',
14
- 'calendar-schedule'
15
- // 'calendar-auto-schedule',
14
+ 'calendar-schedule',
15
+ 'calendar-auto-schedule',
16
+ /* https://www.rfc-editor.org/rfc/rfc8607.html */
17
+ 'calendar-managed-attachments',
18
+ 'calendar-managed-attachments-no-recurrence'
16
19
  /* https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-proxy.txt */
17
20
  // 'calendar-proxy',
18
21
  // 'calendarserver-sharing',
package/common/tags.js CHANGED
@@ -340,7 +340,15 @@ module.exports = function (options) {
340
340
  },
341
341
  'schedule-inbox-URL': {
342
342
  doc: 'https://tools.ietf.org/html/rfc6638#section-2.2',
343
- async resp() {
343
+ async resp({ ctx }) {
344
+ if (ctx && ctx.state && ctx.state.calendarHomeUrl) {
345
+ return {
346
+ [buildTag(cal, 'schedule-inbox-URL')]: href(
347
+ ctx.state.calendarHomeUrl.replace(/\/$/, '') + '/inbox/'
348
+ )
349
+ };
350
+ }
351
+
344
352
  return {
345
353
  [buildTag(cal, 'schedule-inbox-URL')]: href('')
346
354
  };
@@ -348,7 +356,15 @@ module.exports = function (options) {
348
356
  },
349
357
  'schedule-outbox-URL': {
350
358
  doc: 'https://tools.ietf.org/html/rfc6638#section-2.1',
351
- async resp() {
359
+ async resp({ ctx }) {
360
+ if (ctx && ctx.state && ctx.state.calendarHomeUrl) {
361
+ return {
362
+ [buildTag(cal, 'schedule-outbox-URL')]: href(
363
+ ctx.state.calendarHomeUrl.replace(/\/$/, '') + '/outbox/'
364
+ )
365
+ };
366
+ }
367
+
352
368
  return {
353
369
  [buildTag(cal, 'schedule-outbox-URL')]: href('')
354
370
  };
@@ -368,6 +384,77 @@ module.exports = function (options) {
368
384
  };
369
385
  }
370
386
  }
387
+ },
388
+ 'schedule-default-calendar-URL': {
389
+ doc: 'https://tools.ietf.org/html/rfc6638#section-9.2',
390
+ async resp({ ctx }) {
391
+ if (ctx && ctx.state && ctx.state.calendarUrl) {
392
+ return {
393
+ [buildTag(cal, 'schedule-default-calendar-URL')]: href(
394
+ ctx.state.calendarUrl
395
+ )
396
+ };
397
+ }
398
+
399
+ return {
400
+ [buildTag(cal, 'schedule-default-calendar-URL')]: href('')
401
+ };
402
+ }
403
+ },
404
+ 'schedule-calendar-transp': {
405
+ doc: 'https://tools.ietf.org/html/rfc6638#section-9.1',
406
+ async resp({ resource, calendar }) {
407
+ if (resource === 'calendar') {
408
+ // Default to opaque (events affect free-busy)
409
+ const transp = calendar?.scheduleTransp || 'opaque';
410
+ return {
411
+ [buildTag(cal, 'schedule-calendar-transp')]: {
412
+ [buildTag(cal, transp)]: ''
413
+ }
414
+ };
415
+ }
416
+ }
417
+ },
418
+ 'schedule-tag': {
419
+ doc: 'https://tools.ietf.org/html/rfc6638#section-3.2.10',
420
+ async resp({ resource, event }) {
421
+ if (resource === 'event' && event?.scheduleTag) {
422
+ return {
423
+ [buildTag(cal, 'schedule-tag')]: event.scheduleTag
424
+ };
425
+ }
426
+ }
427
+ },
428
+ /* RFC 8607 Managed Attachments */
429
+ 'max-attachment-size': {
430
+ doc: 'https://www.rfc-editor.org/rfc/rfc8607.html#section-5.1',
431
+ async resp({ resource }) {
432
+ if (resource === 'calendar') {
433
+ return {
434
+ [buildTag(cal, 'max-attachment-size')]: '10485760'
435
+ };
436
+ }
437
+ }
438
+ },
439
+ 'max-attachments-per-resource': {
440
+ doc: 'https://www.rfc-editor.org/rfc/rfc8607.html#section-5.2',
441
+ async resp({ resource }) {
442
+ if (resource === 'calendar') {
443
+ return {
444
+ [buildTag(cal, 'max-attachments-per-resource')]: '10'
445
+ };
446
+ }
447
+ }
448
+ },
449
+ 'managed-attachments-server-URL': {
450
+ doc: 'https://www.rfc-editor.org/rfc/rfc8607.html#section-5.3',
451
+ async resp({ resource }) {
452
+ if (resource === 'calendar') {
453
+ return {
454
+ [buildTag(cal, 'managed-attachments-server-URL')]: ''
455
+ };
456
+ }
457
+ }
371
458
  }
372
459
  },
373
460
  [cs]: {
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": "9.0.0",
4
+ "version": "9.2.0",
5
5
  "author": "Sanders DeNardi and Forward Email LLC",
6
6
  "contributors": [
7
7
  "Sanders DeNardi <sedenardi@gmail.com> (http://www.sandersdenardi.com/)",
@@ -14,6 +14,7 @@ const routerCalGet = require('./calendar/get');
14
14
  const routerCalProppatch = require('./calendar/proppatch');
15
15
  const routerCalPut = require('./calendar/put');
16
16
  const routerCalDelete = require('./calendar/delete');
17
+ const routerScheduling = require('./scheduling');
17
18
 
18
19
  module.exports = function (options) {
19
20
  const log = winston({ ...options, label: 'calendar' });
@@ -31,10 +32,21 @@ module.exports = function (options) {
31
32
  mkcalendar: routeMkCalendar(options)
32
33
  };
33
34
 
35
+ // Initialize scheduling routes
36
+ const scheduling = routerScheduling(options);
37
+
34
38
  return async function (ctx) {
35
39
  const method = ctx.method.toLowerCase();
36
40
  const { calendarId } = ctx.state.params;
37
41
 
42
+ // Check for scheduling inbox/outbox routes
43
+ // These are special endpoints at /cal/:principalId/inbox/ and /cal/:principalId/outbox/
44
+ const urlLower = ctx.url.toLowerCase();
45
+ if (urlLower.includes('/inbox') || urlLower.includes('/outbox')) {
46
+ log.debug('Routing to scheduling handler', { url: ctx.url, method });
47
+ return scheduling.route(ctx);
48
+ }
49
+
38
50
  if (calendarId) {
39
51
  // Check calendar exists & user has access
40
52
  const calendar = await options.data.getCalendar(ctx, {
@@ -0,0 +1,370 @@
1
+ /**
2
+ * RFC 6638 Scheduling Extensions to CalDAV
3
+ * Scheduling inbox/outbox routes handler
4
+ *
5
+ * @see https://tools.ietf.org/html/rfc6638
6
+ */
7
+
8
+ const {
9
+ build,
10
+ buildTag,
11
+ href,
12
+ multistatus,
13
+ response,
14
+ status
15
+ } = require('../../common/x-build');
16
+ const { setMultistatusResponse, setOptions } = require('../../common/response');
17
+ const winston = require('../../common/winston');
18
+
19
+ const dav = 'DAV:';
20
+ const cal = 'urn:ietf:params:xml:ns:caldav';
21
+
22
+ module.exports = function (options) {
23
+ const log = winston({ ...options, label: 'scheduling' });
24
+
25
+ /**
26
+ * Handle POST to scheduling outbox for iTIP and free-busy queries
27
+ * POST /cal/:principalId/outbox/
28
+ *
29
+ * @see https://tools.ietf.org/html/rfc6638#section-3.2
30
+ */
31
+ async function postOutbox(ctx) {
32
+ log.debug('POST outbox request', { url: ctx.url });
33
+
34
+ const { body } = ctx.request;
35
+ if (!body) {
36
+ ctx.status = 400;
37
+ ctx.body = 'Missing request body';
38
+ return;
39
+ }
40
+
41
+ // Check if this is a free-busy query
42
+ if (body.includes('VFREEBUSY') && body.includes('METHOD:REQUEST')) {
43
+ return handleFreeBusyQuery(ctx, body);
44
+ }
45
+
46
+ // Handle iTIP scheduling request
47
+ return handleItipRequest(ctx, body);
48
+ }
49
+
50
+ /**
51
+ * Get free-busy data for a single attendee
52
+ */
53
+ async function getFreeBusyForAttendee(ctx, attendee) {
54
+ let scheduleStatus = '2.0'; // Success
55
+ let freeBusyData = '';
56
+
57
+ try {
58
+ freeBusyData =
59
+ typeof options.data.getFreeBusy === 'function'
60
+ ? await options.data.getFreeBusy(ctx, attendee)
61
+ : generateEmptyFreeBusy(attendee);
62
+ } catch (err) {
63
+ log.warn('Error getting free-busy data', {
64
+ attendee,
65
+ error: err.message
66
+ });
67
+ scheduleStatus = '3.7'; // Invalid calendar user
68
+ }
69
+
70
+ return {
71
+ [buildTag(cal, 'response')]: {
72
+ [buildTag(cal, 'recipient')]: href(`mailto:${attendee}`),
73
+ [buildTag(cal, 'request-status')]: scheduleStatus,
74
+ [buildTag(cal, 'calendar-data')]: freeBusyData
75
+ }
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Handle free-busy query
81
+ * @see https://tools.ietf.org/html/rfc6638#section-3.2.1
82
+ */
83
+ async function handleFreeBusyQuery(ctx, body) {
84
+ log.debug('Processing free-busy query');
85
+
86
+ // Extract attendees from the request
87
+ const attendeeMatches =
88
+ body.match(/attendee[^:]*:mailto:([^\r\n]+)/gi) || [];
89
+ const attendees = attendeeMatches
90
+ .map((match) => {
91
+ const email = match.match(/mailto:([^\r\n]+)/i);
92
+ return email ? email[1].toLowerCase() : null;
93
+ })
94
+ .filter(Boolean);
95
+
96
+ if (attendees.length === 0) {
97
+ ctx.status = 400;
98
+ ctx.body = 'No attendees specified in free-busy query';
99
+ return;
100
+ }
101
+
102
+ // Build schedule-response for each attendee (in parallel)
103
+ const responses = await Promise.all(
104
+ attendees.map((attendee) => getFreeBusyForAttendee(ctx, attendee))
105
+ );
106
+
107
+ setMultistatusResponse(ctx);
108
+ ctx.body = build(
109
+ multistatus([
110
+ {
111
+ [buildTag(cal, 'schedule-response')]: responses
112
+ }
113
+ ])
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Send scheduling message to a single attendee
119
+ */
120
+ async function sendMessageToAttendee(ctx, method, attendee, icalData) {
121
+ let scheduleStatus = '1.1'; // Pending - message queued for delivery
122
+
123
+ try {
124
+ if (typeof options.data.sendSchedulingMessage === 'function') {
125
+ await options.data.sendSchedulingMessage(ctx, {
126
+ method,
127
+ attendee,
128
+ icalData
129
+ });
130
+ scheduleStatus = '1.2'; // Delivered
131
+ }
132
+ } catch (err) {
133
+ log.warn('Error sending scheduling message', {
134
+ attendee,
135
+ error: err.message
136
+ });
137
+ scheduleStatus = '5.1'; // Could not complete delivery
138
+ }
139
+
140
+ return {
141
+ [buildTag(cal, 'response')]: {
142
+ [buildTag(cal, 'recipient')]: href(`mailto:${attendee}`),
143
+ [buildTag(cal, 'request-status')]: scheduleStatus
144
+ }
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Handle iTIP scheduling request (REQUEST, REPLY, CANCEL, etc.)
150
+ * @see https://tools.ietf.org/html/rfc6638#section-3.2.2
151
+ */
152
+ async function handleItipRequest(ctx, body) {
153
+ log.debug('Processing iTIP request');
154
+
155
+ // Extract METHOD from the iCalendar data
156
+ const methodMatch = body.match(/method:([a-z]+)/i);
157
+ const method = methodMatch ? methodMatch[1].toUpperCase() : 'REQUEST';
158
+
159
+ // Extract attendees
160
+ const attendeeMatches =
161
+ body.match(/attendee[^:]*:mailto:([^\r\n]+)/gi) || [];
162
+ const attendees = attendeeMatches
163
+ .map((match) => {
164
+ const email = match.match(/mailto:([^\r\n]+)/i);
165
+ return email ? email[1].toLowerCase() : null;
166
+ })
167
+ .filter(Boolean);
168
+
169
+ // Send messages in parallel
170
+ const responses = await Promise.all(
171
+ attendees.map((attendee) =>
172
+ sendMessageToAttendee(ctx, method, attendee, body)
173
+ )
174
+ );
175
+
176
+ setMultistatusResponse(ctx);
177
+ ctx.body = build(
178
+ multistatus([
179
+ {
180
+ [buildTag(cal, 'schedule-response')]: responses
181
+ }
182
+ ])
183
+ );
184
+ }
185
+
186
+ /**
187
+ * Handle PROPFIND on scheduling inbox
188
+ * PROPFIND /cal/:principalId/inbox/
189
+ *
190
+ * @see https://tools.ietf.org/html/rfc6638#section-2.2
191
+ */
192
+ async function propfindInbox(ctx) {
193
+ log.debug('PROPFIND inbox request', { url: ctx.url });
194
+
195
+ const props = [
196
+ {
197
+ [buildTag(dav, 'resourcetype')]: {
198
+ [buildTag(dav, 'collection')]: '',
199
+ [buildTag(cal, 'schedule-inbox')]: ''
200
+ }
201
+ },
202
+ { [buildTag(dav, 'displayname')]: 'Schedule Inbox' },
203
+ { [buildTag(cal, 'calendar-free-busy-set')]: '' },
204
+ {
205
+ [buildTag(dav, 'current-user-privilege-set')]: {
206
+ [buildTag(dav, 'privilege')]: [
207
+ { [buildTag(dav, 'read')]: '' },
208
+ { [buildTag(cal, 'schedule-deliver')]: '' }
209
+ ]
210
+ }
211
+ }
212
+ ];
213
+
214
+ setMultistatusResponse(ctx);
215
+ ctx.body = build(multistatus([response(ctx.url, status[200], props)]));
216
+ }
217
+
218
+ /**
219
+ * Handle GET on scheduling inboxbox - list scheduling messages
220
+ * GET /cal/:principalId/inbox/
221
+ *
222
+ * @see https://tools.ietf.org/html/rfc6638#section-2.2
223
+ */
224
+ async function getInbox(ctx) {
225
+ log.debug('GET inbox request', { url: ctx.url });
226
+
227
+ // Return empty collection by default
228
+ // Implementations can override via options.data.getSchedulingMessages
229
+ let messages = [];
230
+
231
+ try {
232
+ if (typeof options.data.getSchedulingMessages === 'function') {
233
+ messages = await options.data.getSchedulingMessages(ctx);
234
+ }
235
+ } catch (err) {
236
+ log.warn('Error getting scheduling messages', { error: err.message });
237
+ }
238
+
239
+ const responses = messages.map((msg) => {
240
+ return response(msg.href, status[200], [
241
+ { [buildTag(dav, 'getetag')]: msg.etag },
242
+ { [buildTag(dav, 'getcontenttype')]: 'text/calendar; charset=utf-8' },
243
+ { [buildTag(cal, 'calendar-data')]: msg.icalData }
244
+ ]);
245
+ });
246
+
247
+ // Add collection response
248
+ responses.unshift(
249
+ response(ctx.url, status[200], [
250
+ {
251
+ [buildTag(dav, 'resourcetype')]: {
252
+ [buildTag(dav, 'collection')]: '',
253
+ [buildTag(cal, 'schedule-inbox')]: ''
254
+ }
255
+ }
256
+ ])
257
+ );
258
+
259
+ setMultistatusResponse(ctx);
260
+ ctx.body = build(multistatus(responses));
261
+ }
262
+
263
+ /**
264
+ * Handle PROPFIND on scheduling outbox
265
+ * PROPFIND /cal/:principalId/outbox/
266
+ *
267
+ * @see https://tools.ietf.org/html/rfc6638#section-2.1
268
+ */
269
+ async function propfindOutbox(ctx) {
270
+ log.debug('PROPFIND outbox request', { url: ctx.url });
271
+
272
+ const props = [
273
+ {
274
+ [buildTag(dav, 'resourcetype')]: {
275
+ [buildTag(dav, 'collection')]: '',
276
+ [buildTag(cal, 'schedule-outbox')]: ''
277
+ }
278
+ },
279
+ { [buildTag(dav, 'displayname')]: 'Schedule Outbox' },
280
+ {
281
+ [buildTag(dav, 'current-user-privilege-set')]: {
282
+ [buildTag(dav, 'privilege')]: [
283
+ { [buildTag(dav, 'read')]: '' },
284
+ { [buildTag(cal, 'schedule-send')]: '' }
285
+ ]
286
+ }
287
+ }
288
+ ];
289
+
290
+ setMultistatusResponse(ctx);
291
+ ctx.body = build(multistatus([response(ctx.url, status[200], props)]));
292
+ }
293
+
294
+ /**
295
+ * Generate empty VFREEBUSY response
296
+ */
297
+ function generateEmptyFreeBusy(attendee) {
298
+ const now = new Date();
299
+ const dtstamp = now
300
+ .toISOString()
301
+ .replaceAll(/[-:]/g, '')
302
+ .replace(/\.\d{3}/, '');
303
+ const uid = `freebusy-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
304
+
305
+ return [
306
+ 'BEGIN:VCALENDAR',
307
+ 'VERSION:2.0',
308
+ 'PRODID:-//Forward Email//CalDAV Adapter//EN',
309
+ 'METHOD:REPLY',
310
+ 'BEGIN:VFREEBUSY',
311
+ `DTSTAMP:${dtstamp}`,
312
+ `UID:${uid}`,
313
+ `ATTENDEE:mailto:${attendee}`,
314
+ 'END:VFREEBUSY',
315
+ 'END:VCALENDAR'
316
+ ].join('\r\n');
317
+ }
318
+
319
+ return {
320
+ postOutbox,
321
+ propfindInbox,
322
+ getInbox,
323
+ propfindOutbox,
324
+
325
+ /**
326
+ * Route handler for scheduling endpoints
327
+ */
328
+ async route(ctx) {
329
+ const method = ctx.method.toLowerCase();
330
+ const url = ctx.url.toLowerCase();
331
+
332
+ // Determine if this is inbox or outbox
333
+ const isInbox = url.includes('/inbox');
334
+ const isOutbox = url.includes('/outbox');
335
+
336
+ if (method === 'options') {
337
+ if (isOutbox) {
338
+ setOptions(ctx, ['OPTIONS', 'POST', 'PROPFIND']);
339
+ } else if (isInbox) {
340
+ setOptions(ctx, ['OPTIONS', 'GET', 'PROPFIND', 'DELETE']);
341
+ }
342
+
343
+ return;
344
+ }
345
+
346
+ if (isOutbox) {
347
+ if (method === 'post') {
348
+ return postOutbox(ctx);
349
+ }
350
+
351
+ if (method === 'propfind') {
352
+ return propfindOutbox(ctx);
353
+ }
354
+ }
355
+
356
+ if (isInbox) {
357
+ if (method === 'get') {
358
+ return getInbox(ctx);
359
+ }
360
+
361
+ if (method === 'propfind') {
362
+ return propfindInbox(ctx);
363
+ }
364
+ }
365
+
366
+ ctx.status = 405;
367
+ ctx.body = 'Method Not Allowed';
368
+ }
369
+ };
370
+ };
@@ -0,0 +1,697 @@
1
+ const test = require('ava');
2
+
3
+ // Mock options for testing
4
+ const createMockOptions = (overrides = {}) => ({
5
+ data: {
6
+ getCalendarId: () => 'test-calendar',
7
+ buildICS: () => 'BEGIN:VCALENDAR\r\nEND:VCALENDAR',
8
+ getETag: () => '"test-etag"',
9
+ ...overrides.data
10
+ },
11
+ logEnabled: false,
12
+ ...overrides
13
+ });
14
+
15
+ // Mock Koa context
16
+ const createMockCtx = (overrides = {}) => ({
17
+ method: 'GET',
18
+ url: '/cal/user@example.com/',
19
+ status: 200,
20
+ body: '',
21
+ request: {
22
+ body: ''
23
+ },
24
+ state: {
25
+ user: {
26
+ principalId: 'user@example.com',
27
+ principalName: 'user@example.com',
28
+ email: 'user@example.com'
29
+ },
30
+ params: {
31
+ principalId: 'user@example.com'
32
+ },
33
+ calendarHomeUrl: '/cal/user@example.com/',
34
+ principalUrl: '/p/user@example.com/',
35
+ calendarUrl: '/cal/user@example.com/default/'
36
+ },
37
+ set() {},
38
+ ...overrides
39
+ });
40
+
41
+ // ============================================
42
+ // Tests for common/response.js DAV header
43
+ // ============================================
44
+
45
+ test('DAV header includes calendar-auto-schedule capability', (t) => {
46
+ const response = require('../common/response');
47
+
48
+ // Create a mock context to capture the header
49
+ let davHeader = '';
50
+ const ctx = {
51
+ status: 0,
52
+ body: '',
53
+ set(name, value) {
54
+ if (name === 'DAV') {
55
+ davHeader = value;
56
+ }
57
+ }
58
+ };
59
+
60
+ response.setOptions(ctx, ['OPTIONS']);
61
+
62
+ t.true(
63
+ davHeader.includes('calendar-auto-schedule'),
64
+ 'DAV header should include calendar-auto-schedule'
65
+ );
66
+ t.true(
67
+ davHeader.includes('calendar-schedule'),
68
+ 'DAV header should include calendar-schedule'
69
+ );
70
+ t.true(
71
+ davHeader.includes('calendar-access'),
72
+ 'DAV header should include calendar-access'
73
+ );
74
+ });
75
+
76
+ // ============================================
77
+ // Tests for common/tags.js scheduling properties
78
+ // ============================================
79
+
80
+ test('schedule-inbox-URL returns correct URL when calendarHomeUrl is set', async (t) => {
81
+ const options = createMockOptions();
82
+ const { tags } = require('../common/tags')(options);
83
+
84
+ const scheduleInboxTag =
85
+ tags['urn:ietf:params:xml:ns:caldav']['schedule-inbox-URL'];
86
+ t.is(typeof scheduleInboxTag, 'object');
87
+ t.is(typeof scheduleInboxTag.resp, 'function');
88
+
89
+ const ctx = createMockCtx();
90
+ const result = await scheduleInboxTag.resp({ ctx });
91
+
92
+ t.truthy(result);
93
+ const tagKey = Object.keys(result)[0];
94
+ t.true(tagKey.includes('schedule-inbox-URL'));
95
+ });
96
+
97
+ test('schedule-outbox-URL returns correct URL when calendarHomeUrl is set', async (t) => {
98
+ const options = createMockOptions();
99
+ const { tags } = require('../common/tags')(options);
100
+
101
+ const scheduleOutboxTag =
102
+ tags['urn:ietf:params:xml:ns:caldav']['schedule-outbox-URL'];
103
+ t.is(typeof scheduleOutboxTag, 'object');
104
+ t.is(typeof scheduleOutboxTag.resp, 'function');
105
+
106
+ const ctx = createMockCtx();
107
+ const result = await scheduleOutboxTag.resp({ ctx });
108
+
109
+ t.truthy(result);
110
+ const tagKey = Object.keys(result)[0];
111
+ t.true(tagKey.includes('schedule-outbox-URL'));
112
+ });
113
+
114
+ test('schedule-default-calendar-URL returns correct URL when calendarUrl is set', async (t) => {
115
+ const options = createMockOptions();
116
+ const { tags } = require('../common/tags')(options);
117
+
118
+ const scheduleDefaultCalTag =
119
+ tags['urn:ietf:params:xml:ns:caldav']['schedule-default-calendar-URL'];
120
+ t.is(typeof scheduleDefaultCalTag, 'object');
121
+ t.is(typeof scheduleDefaultCalTag.resp, 'function');
122
+
123
+ const ctx = createMockCtx();
124
+ const result = await scheduleDefaultCalTag.resp({ ctx });
125
+
126
+ t.truthy(result);
127
+ const tagKey = Object.keys(result)[0];
128
+ t.true(tagKey.includes('schedule-default-calendar-URL'));
129
+ });
130
+
131
+ test('schedule-calendar-transp returns opaque by default', async (t) => {
132
+ const options = createMockOptions();
133
+ const { tags } = require('../common/tags')(options);
134
+
135
+ const scheduleTranspTag =
136
+ tags['urn:ietf:params:xml:ns:caldav']['schedule-calendar-transp'];
137
+ t.is(typeof scheduleTranspTag, 'object');
138
+ t.is(typeof scheduleTranspTag.resp, 'function');
139
+
140
+ const calendar = { name: 'Test Calendar' };
141
+ const result = await scheduleTranspTag.resp({
142
+ resource: 'calendar',
143
+ calendar
144
+ });
145
+
146
+ t.truthy(result);
147
+ const tagKey = Object.keys(result)[0];
148
+ t.true(tagKey.includes('schedule-calendar-transp'));
149
+ });
150
+
151
+ test('schedule-calendar-transp respects calendar scheduleTransp property', async (t) => {
152
+ const options = createMockOptions();
153
+ const { tags } = require('../common/tags')(options);
154
+
155
+ const scheduleTranspTag =
156
+ tags['urn:ietf:params:xml:ns:caldav']['schedule-calendar-transp'];
157
+
158
+ const calendar = { name: 'Test Calendar', scheduleTransp: 'transparent' };
159
+ const result = await scheduleTranspTag.resp({
160
+ resource: 'calendar',
161
+ calendar
162
+ });
163
+
164
+ t.truthy(result);
165
+ // The result should contain the transparent value
166
+ const resultStr = JSON.stringify(result);
167
+ t.true(resultStr.includes('transparent'));
168
+ });
169
+
170
+ test('schedule-tag returns value when event has scheduleTag', async (t) => {
171
+ const options = createMockOptions();
172
+ const { tags } = require('../common/tags')(options);
173
+
174
+ const scheduleTagProp = tags['urn:ietf:params:xml:ns:caldav']['schedule-tag'];
175
+ t.is(typeof scheduleTagProp, 'object');
176
+ t.is(typeof scheduleTagProp.resp, 'function');
177
+
178
+ const event = { scheduleTag: '"schedule-tag-123"' };
179
+ const result = await scheduleTagProp.resp({ resource: 'event', event });
180
+
181
+ t.truthy(result);
182
+ const tagKey = Object.keys(result)[0];
183
+ t.true(tagKey.includes('schedule-tag'));
184
+ });
185
+
186
+ test('schedule-tag returns undefined when event has no scheduleTag', async (t) => {
187
+ const options = createMockOptions();
188
+ const { tags } = require('../common/tags')(options);
189
+
190
+ const scheduleTagProp = tags['urn:ietf:params:xml:ns:caldav']['schedule-tag'];
191
+
192
+ const event = { uid: 'test-event' };
193
+ const result = await scheduleTagProp.resp({ resource: 'event', event });
194
+
195
+ t.is(result, undefined);
196
+ });
197
+
198
+ // ============================================
199
+ // Tests for scheduling.js routes
200
+ // ============================================
201
+
202
+ test('scheduling module exports required functions', (t) => {
203
+ const options = createMockOptions();
204
+ const scheduling = require('../routes/calendar/scheduling')(options);
205
+
206
+ t.is(typeof scheduling.postOutbox, 'function');
207
+ t.is(typeof scheduling.propfindInbox, 'function');
208
+ t.is(typeof scheduling.getInbox, 'function');
209
+ t.is(typeof scheduling.propfindOutbox, 'function');
210
+ t.is(typeof scheduling.route, 'function');
211
+ });
212
+
213
+ test('scheduling route handler sets OPTIONS for outbox', async (t) => {
214
+ const options = createMockOptions();
215
+ const scheduling = require('../routes/calendar/scheduling')(options);
216
+
217
+ let allowHeader = '';
218
+ const ctx = createMockCtx({
219
+ method: 'OPTIONS',
220
+ url: '/cal/user@example.com/outbox/',
221
+ set(name, value) {
222
+ if (name === 'Allow') {
223
+ allowHeader = value;
224
+ }
225
+ }
226
+ });
227
+
228
+ await scheduling.route(ctx);
229
+
230
+ t.true(allowHeader.includes('POST'));
231
+ t.true(allowHeader.includes('PROPFIND'));
232
+ });
233
+
234
+ test('scheduling route handler sets OPTIONS for inbox', async (t) => {
235
+ const options = createMockOptions();
236
+ const scheduling = require('../routes/calendar/scheduling')(options);
237
+
238
+ let allowHeader = '';
239
+ const ctx = createMockCtx({
240
+ method: 'OPTIONS',
241
+ url: '/cal/user@example.com/inbox/',
242
+ set(name, value) {
243
+ if (name === 'Allow') {
244
+ allowHeader = value;
245
+ }
246
+ }
247
+ });
248
+
249
+ await scheduling.route(ctx);
250
+
251
+ t.true(allowHeader.includes('GET'));
252
+ t.true(allowHeader.includes('PROPFIND'));
253
+ t.true(allowHeader.includes('DELETE'));
254
+ });
255
+
256
+ test('propfindInbox returns schedule-inbox resourcetype', async (t) => {
257
+ const options = createMockOptions();
258
+ const scheduling = require('../routes/calendar/scheduling')(options);
259
+
260
+ const ctx = createMockCtx({
261
+ method: 'PROPFIND',
262
+ url: '/cal/user@example.com/inbox/'
263
+ });
264
+
265
+ await scheduling.propfindInbox(ctx);
266
+
267
+ t.is(ctx.status, 207);
268
+ t.truthy(ctx.body);
269
+ t.true(ctx.body.includes('schedule-inbox'));
270
+ });
271
+
272
+ test('propfindOutbox returns schedule-outbox resourcetype', async (t) => {
273
+ const options = createMockOptions();
274
+ const scheduling = require('../routes/calendar/scheduling')(options);
275
+
276
+ const ctx = createMockCtx({
277
+ method: 'PROPFIND',
278
+ url: '/cal/user@example.com/outbox/'
279
+ });
280
+
281
+ await scheduling.propfindOutbox(ctx);
282
+
283
+ t.is(ctx.status, 207);
284
+ t.truthy(ctx.body);
285
+ t.true(ctx.body.includes('schedule-outbox'));
286
+ });
287
+
288
+ test('getInbox returns empty collection by default', async (t) => {
289
+ const options = createMockOptions();
290
+ const scheduling = require('../routes/calendar/scheduling')(options);
291
+
292
+ const ctx = createMockCtx({
293
+ method: 'GET',
294
+ url: '/cal/user@example.com/inbox/'
295
+ });
296
+
297
+ await scheduling.getInbox(ctx);
298
+
299
+ t.is(ctx.status, 207);
300
+ t.truthy(ctx.body);
301
+ t.true(ctx.body.includes('schedule-inbox'));
302
+ });
303
+
304
+ test('getInbox uses options.data.getSchedulingMessages when available', async (t) => {
305
+ const mockMessages = [
306
+ {
307
+ href: '/cal/user@example.com/inbox/msg1.ics',
308
+ etag: '"etag1"',
309
+ icalData: 'BEGIN:VCALENDAR\r\nEND:VCALENDAR'
310
+ }
311
+ ];
312
+
313
+ const options = createMockOptions({
314
+ data: {
315
+ getSchedulingMessages: async () => mockMessages
316
+ }
317
+ });
318
+ const scheduling = require('../routes/calendar/scheduling')(options);
319
+
320
+ const ctx = createMockCtx({
321
+ method: 'GET',
322
+ url: '/cal/user@example.com/inbox/'
323
+ });
324
+
325
+ await scheduling.getInbox(ctx);
326
+
327
+ t.is(ctx.status, 207);
328
+ t.truthy(ctx.body);
329
+ t.true(ctx.body.includes('msg1.ics'));
330
+ });
331
+
332
+ test('postOutbox returns 400 when body is missing', async (t) => {
333
+ const options = createMockOptions();
334
+ const scheduling = require('../routes/calendar/scheduling')(options);
335
+
336
+ const ctx = createMockCtx({
337
+ method: 'POST',
338
+ url: '/cal/user@example.com/outbox/',
339
+ request: { body: null }
340
+ });
341
+
342
+ await scheduling.postOutbox(ctx);
343
+
344
+ t.is(ctx.status, 400);
345
+ });
346
+
347
+ test('postOutbox handles free-busy query', async (t) => {
348
+ const options = createMockOptions();
349
+ const scheduling = require('../routes/calendar/scheduling')(options);
350
+
351
+ const freeBusyRequest = [
352
+ 'BEGIN:VCALENDAR',
353
+ 'VERSION:2.0',
354
+ 'METHOD:REQUEST',
355
+ 'BEGIN:VFREEBUSY',
356
+ 'ORGANIZER:mailto:organizer@example.com',
357
+ 'ATTENDEE:mailto:attendee@example.com',
358
+ 'DTSTART:20260101T000000Z',
359
+ 'DTEND:20260102T000000Z',
360
+ 'END:VFREEBUSY',
361
+ 'END:VCALENDAR'
362
+ ].join('\r\n');
363
+
364
+ const ctx = createMockCtx({
365
+ method: 'POST',
366
+ url: '/cal/user@example.com/outbox/',
367
+ request: { body: freeBusyRequest }
368
+ });
369
+
370
+ await scheduling.postOutbox(ctx);
371
+
372
+ t.is(ctx.status, 207);
373
+ t.truthy(ctx.body);
374
+ t.true(ctx.body.includes('schedule-response'));
375
+ t.true(ctx.body.includes('attendee@example.com'));
376
+ });
377
+
378
+ test('postOutbox handles iTIP REQUEST', async (t) => {
379
+ const options = createMockOptions();
380
+ const scheduling = require('../routes/calendar/scheduling')(options);
381
+
382
+ const itipRequest = [
383
+ 'BEGIN:VCALENDAR',
384
+ 'VERSION:2.0',
385
+ 'METHOD:REQUEST',
386
+ 'BEGIN:VEVENT',
387
+ 'UID:test-event-123',
388
+ 'ORGANIZER:mailto:organizer@example.com',
389
+ 'ATTENDEE:mailto:attendee1@example.com',
390
+ 'ATTENDEE:mailto:attendee2@example.com',
391
+ 'SUMMARY:Test Meeting',
392
+ 'DTSTART:20260115T100000Z',
393
+ 'DTEND:20260115T110000Z',
394
+ 'END:VEVENT',
395
+ 'END:VCALENDAR'
396
+ ].join('\r\n');
397
+
398
+ const ctx = createMockCtx({
399
+ method: 'POST',
400
+ url: '/cal/user@example.com/outbox/',
401
+ request: { body: itipRequest }
402
+ });
403
+
404
+ await scheduling.postOutbox(ctx);
405
+
406
+ t.is(ctx.status, 207);
407
+ t.truthy(ctx.body);
408
+ t.true(ctx.body.includes('schedule-response'));
409
+ t.true(ctx.body.includes('attendee1@example.com'));
410
+ t.true(ctx.body.includes('attendee2@example.com'));
411
+ });
412
+
413
+ test('postOutbox calls sendSchedulingMessage when available', async (t) => {
414
+ const sentMessages = [];
415
+
416
+ const options = createMockOptions({
417
+ data: {
418
+ async sendSchedulingMessage(ctx, msg) {
419
+ sentMessages.push(msg);
420
+ }
421
+ }
422
+ });
423
+ const scheduling = require('../routes/calendar/scheduling')(options);
424
+
425
+ const itipRequest = [
426
+ 'BEGIN:VCALENDAR',
427
+ 'VERSION:2.0',
428
+ 'METHOD:REQUEST',
429
+ 'BEGIN:VEVENT',
430
+ 'UID:test-event-456',
431
+ 'ORGANIZER:mailto:organizer@example.com',
432
+ 'ATTENDEE:mailto:attendee@example.com',
433
+ 'SUMMARY:Test Meeting',
434
+ 'DTSTART:20260115T100000Z',
435
+ 'DTEND:20260115T110000Z',
436
+ 'END:VEVENT',
437
+ 'END:VCALENDAR'
438
+ ].join('\r\n');
439
+
440
+ const ctx = createMockCtx({
441
+ method: 'POST',
442
+ url: '/cal/user@example.com/outbox/',
443
+ request: { body: itipRequest }
444
+ });
445
+
446
+ await scheduling.postOutbox(ctx);
447
+
448
+ t.is(sentMessages.length, 1);
449
+ t.is(sentMessages[0].method, 'REQUEST');
450
+ t.is(sentMessages[0].attendee, 'attendee@example.com');
451
+ });
452
+
453
+ test('postOutbox uses custom getFreeBusy when available', async (t) => {
454
+ const customFreeBusy = [
455
+ 'BEGIN:VCALENDAR',
456
+ 'VERSION:2.0',
457
+ 'METHOD:REPLY',
458
+ 'BEGIN:VFREEBUSY',
459
+ 'FREEBUSY:20260101T090000Z/20260101T100000Z',
460
+ 'END:VFREEBUSY',
461
+ 'END:VCALENDAR'
462
+ ].join('\r\n');
463
+
464
+ const options = createMockOptions({
465
+ data: {
466
+ getFreeBusy: async () => customFreeBusy
467
+ }
468
+ });
469
+ const scheduling = require('../routes/calendar/scheduling')(options);
470
+
471
+ const freeBusyRequest = [
472
+ 'BEGIN:VCALENDAR',
473
+ 'VERSION:2.0',
474
+ 'METHOD:REQUEST',
475
+ 'BEGIN:VFREEBUSY',
476
+ 'ATTENDEE:mailto:attendee@example.com',
477
+ 'END:VFREEBUSY',
478
+ 'END:VCALENDAR'
479
+ ].join('\r\n');
480
+
481
+ const ctx = createMockCtx({
482
+ method: 'POST',
483
+ url: '/cal/user@example.com/outbox/',
484
+ request: { body: freeBusyRequest }
485
+ });
486
+
487
+ await scheduling.postOutbox(ctx);
488
+
489
+ t.is(ctx.status, 207);
490
+ t.true(ctx.body.includes('FREEBUSY'));
491
+ });
492
+
493
+ // ============================================
494
+ // Tests for calendar.js routing integration
495
+ // ============================================
496
+
497
+ test('calendar router routes inbox requests to scheduling handler', async (t) => {
498
+ // We need to test that the calendar.js properly routes to scheduling
499
+ // This is an integration test
500
+ const options = createMockOptions({
501
+ data: {
502
+ getCalendar: async () => null
503
+ }
504
+ });
505
+
506
+ const calendarRouter = require('../routes/calendar/calendar')(options);
507
+
508
+ const ctx = createMockCtx({
509
+ method: 'PROPFIND',
510
+ url: '/cal/user@example.com/inbox/'
511
+ });
512
+
513
+ await calendarRouter(ctx);
514
+
515
+ // If routing works, we should get a 207 response with schedule-inbox
516
+ t.is(ctx.status, 207);
517
+ t.true(ctx.body.includes('schedule-inbox'));
518
+ });
519
+
520
+ test('calendar router routes outbox requests to scheduling handler', async (t) => {
521
+ const options = createMockOptions({
522
+ data: {
523
+ getCalendar: async () => null
524
+ }
525
+ });
526
+
527
+ const calendarRouter = require('../routes/calendar/calendar')(options);
528
+
529
+ const ctx = createMockCtx({
530
+ method: 'PROPFIND',
531
+ url: '/cal/user@example.com/outbox/'
532
+ });
533
+
534
+ await calendarRouter(ctx);
535
+
536
+ t.is(ctx.status, 207);
537
+ t.true(ctx.body.includes('schedule-outbox'));
538
+ });
539
+
540
+ // ============================================
541
+ // Tests for edge cases and error handling
542
+ // ============================================
543
+
544
+ test('schedule-inbox-URL returns empty href when ctx is missing', async (t) => {
545
+ const options = createMockOptions();
546
+ const { tags } = require('../common/tags')(options);
547
+
548
+ const scheduleInboxTag =
549
+ tags['urn:ietf:params:xml:ns:caldav']['schedule-inbox-URL'];
550
+ const result = await scheduleInboxTag.resp({});
551
+
552
+ t.truthy(result);
553
+ // Should return empty href
554
+ const resultStr = JSON.stringify(result);
555
+ t.true(resultStr.includes('href'));
556
+ });
557
+
558
+ test('schedule-outbox-URL returns empty href when ctx is missing', async (t) => {
559
+ const options = createMockOptions();
560
+ const { tags } = require('../common/tags')(options);
561
+
562
+ const scheduleOutboxTag =
563
+ tags['urn:ietf:params:xml:ns:caldav']['schedule-outbox-URL'];
564
+ const result = await scheduleOutboxTag.resp({});
565
+
566
+ t.truthy(result);
567
+ const resultStr = JSON.stringify(result);
568
+ t.true(resultStr.includes('href'));
569
+ });
570
+
571
+ test('postOutbox handles errors in getFreeBusy gracefully', async (t) => {
572
+ const options = createMockOptions({
573
+ data: {
574
+ async getFreeBusy() {
575
+ throw new Error('Database error');
576
+ }
577
+ }
578
+ });
579
+ const scheduling = require('../routes/calendar/scheduling')(options);
580
+
581
+ const freeBusyRequest = [
582
+ 'BEGIN:VCALENDAR',
583
+ 'VERSION:2.0',
584
+ 'METHOD:REQUEST',
585
+ 'BEGIN:VFREEBUSY',
586
+ 'ATTENDEE:mailto:attendee@example.com',
587
+ 'END:VFREEBUSY',
588
+ 'END:VCALENDAR'
589
+ ].join('\r\n');
590
+
591
+ const ctx = createMockCtx({
592
+ method: 'POST',
593
+ url: '/cal/user@example.com/outbox/',
594
+ request: { body: freeBusyRequest }
595
+ });
596
+
597
+ await scheduling.postOutbox(ctx);
598
+
599
+ t.is(ctx.status, 207);
600
+ // Should return error status 3.7 for invalid calendar user
601
+ t.true(ctx.body.includes('3.7'));
602
+ });
603
+
604
+ test('postOutbox handles errors in sendSchedulingMessage gracefully', async (t) => {
605
+ const options = createMockOptions({
606
+ data: {
607
+ async sendSchedulingMessage() {
608
+ throw new Error('SMTP error');
609
+ }
610
+ }
611
+ });
612
+ const scheduling = require('../routes/calendar/scheduling')(options);
613
+
614
+ const itipRequest = [
615
+ 'BEGIN:VCALENDAR',
616
+ 'VERSION:2.0',
617
+ 'METHOD:REQUEST',
618
+ 'BEGIN:VEVENT',
619
+ 'UID:test-event',
620
+ 'ATTENDEE:mailto:attendee@example.com',
621
+ 'END:VEVENT',
622
+ 'END:VCALENDAR'
623
+ ].join('\r\n');
624
+
625
+ const ctx = createMockCtx({
626
+ method: 'POST',
627
+ url: '/cal/user@example.com/outbox/',
628
+ request: { body: itipRequest }
629
+ });
630
+
631
+ await scheduling.postOutbox(ctx);
632
+
633
+ t.is(ctx.status, 207);
634
+ // Should return error status 5.1 for delivery failure
635
+ t.true(ctx.body.includes('5.1'));
636
+ });
637
+
638
+ test('getInbox handles errors in getSchedulingMessages gracefully', async (t) => {
639
+ const options = createMockOptions({
640
+ data: {
641
+ async getSchedulingMessages() {
642
+ throw new Error('Database error');
643
+ }
644
+ }
645
+ });
646
+ const scheduling = require('../routes/calendar/scheduling')(options);
647
+
648
+ const ctx = createMockCtx({
649
+ method: 'GET',
650
+ url: '/cal/user@example.com/inbox/'
651
+ });
652
+
653
+ await scheduling.getInbox(ctx);
654
+
655
+ // Should still return 207 with empty collection
656
+ t.is(ctx.status, 207);
657
+ t.true(ctx.body.includes('schedule-inbox'));
658
+ });
659
+
660
+ test('scheduling route returns 405 for unsupported methods', async (t) => {
661
+ const options = createMockOptions();
662
+ const scheduling = require('../routes/calendar/scheduling')(options);
663
+
664
+ const ctx = createMockCtx({
665
+ method: 'PUT',
666
+ url: '/cal/user@example.com/outbox/'
667
+ });
668
+
669
+ await scheduling.route(ctx);
670
+
671
+ t.is(ctx.status, 405);
672
+ });
673
+
674
+ test('postOutbox returns 400 when no attendees in free-busy query', async (t) => {
675
+ const options = createMockOptions();
676
+ const scheduling = require('../routes/calendar/scheduling')(options);
677
+
678
+ const freeBusyRequest = [
679
+ 'BEGIN:VCALENDAR',
680
+ 'VERSION:2.0',
681
+ 'METHOD:REQUEST',
682
+ 'BEGIN:VFREEBUSY',
683
+ 'ORGANIZER:mailto:organizer@example.com',
684
+ 'END:VFREEBUSY',
685
+ 'END:VCALENDAR'
686
+ ].join('\r\n');
687
+
688
+ const ctx = createMockCtx({
689
+ method: 'POST',
690
+ url: '/cal/user@example.com/outbox/',
691
+ request: { body: freeBusyRequest }
692
+ });
693
+
694
+ await scheduling.postOutbox(ctx);
695
+
696
+ t.is(ctx.status, 400);
697
+ });