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.
- package/common/response.js +5 -2
- package/common/tags.js +89 -2
- package/package.json +1 -1
- package/routes/calendar/calendar.js +12 -0
- package/routes/calendar/scheduling.js +370 -0
- package/test/scheduling.test.js +697 -0
package/common/response.js
CHANGED
|
@@ -11,8 +11,11 @@ const setDAVHeader = function (ctx) {
|
|
|
11
11
|
'3',
|
|
12
12
|
// 'extended-mkcol',
|
|
13
13
|
'calendar-access',
|
|
14
|
-
'calendar-schedule'
|
|
15
|
-
|
|
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.
|
|
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
|
+
});
|