caldav-adapter 8.3.0 → 8.3.2
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/parse-body.js +12 -1
- package/common/x-build.js +4 -1
- package/common/xml.js +3 -1
- package/package.json +1 -1
- package/routes/calendar/calendar/proppatch.js +388 -45
- package/routes/calendar/calendar/put.js +7 -4
- package/routes/calendar/calendar/report.js +18 -0
- package/routes/calendar/user/propfind.js +11 -0
- package/test/proppatch.test.js +1254 -0
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
const test = require('ava');
|
|
2
|
+
const { DOMParser } = require('@xmldom/xmldom');
|
|
3
|
+
const xpath = require('xpath');
|
|
4
|
+
|
|
5
|
+
// Use the same namespace configuration as caldav-adapter
|
|
6
|
+
const namespaces = {
|
|
7
|
+
D: 'DAV:',
|
|
8
|
+
CAL: 'urn:ietf:params:xml:ns:caldav',
|
|
9
|
+
CS: 'http://calendarserver.org/ns/',
|
|
10
|
+
ICAL: 'http://apple.com/ns/ical/'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const select = xpath.useNamespaces(namespaces);
|
|
14
|
+
|
|
15
|
+
// Helper to convert NodeList to array (xmldom NodeList is not iterable with spread)
|
|
16
|
+
function nodeListToArray(nodeList) {
|
|
17
|
+
const result = [];
|
|
18
|
+
if (nodeList && nodeList.length > 0) {
|
|
19
|
+
for (let i = 0; i < nodeList.length; i++) {
|
|
20
|
+
result.push(nodeList.item(i));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Helper to parse XML and get children like proppatch.js does
|
|
28
|
+
function getProppatchChildren(
|
|
29
|
+
xmlBody,
|
|
30
|
+
path = '/D:propertyupdate/D:set/D:prop'
|
|
31
|
+
) {
|
|
32
|
+
const parser = new DOMParser();
|
|
33
|
+
const doc = parser.parseFromString(xmlBody, 'text/xml');
|
|
34
|
+
const propNode = select(path, doc);
|
|
35
|
+
return propNode[0] ? nodeListToArray(propNode[0].childNodes) : [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Helper to get both set and remove children
|
|
39
|
+
function getProppatchAllChildren(xmlBody) {
|
|
40
|
+
const parser = new DOMParser();
|
|
41
|
+
const doc = parser.parseFromString(xmlBody, 'text/xml');
|
|
42
|
+
|
|
43
|
+
const setNode = select('/D:propertyupdate/D:set/D:prop', doc);
|
|
44
|
+
const removeNode = select('/D:propertyupdate/D:remove/D:prop', doc);
|
|
45
|
+
|
|
46
|
+
const setChildren = setNode[0] ? nodeListToArray(setNode[0].childNodes) : [];
|
|
47
|
+
const removeChildren = removeNode[0]
|
|
48
|
+
? nodeListToArray(removeNode[0].childNodes)
|
|
49
|
+
: [];
|
|
50
|
+
|
|
51
|
+
return { setChildren, removeChildren };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Protected properties per RFC 4791
|
|
55
|
+
const PROTECTED_PROPERTIES = new Set([
|
|
56
|
+
'supported-calendar-component-set',
|
|
57
|
+
'supported-calendar-data',
|
|
58
|
+
'max-resource-size',
|
|
59
|
+
'min-date-time',
|
|
60
|
+
'max-date-time',
|
|
61
|
+
'max-instances',
|
|
62
|
+
'max-attendees-per-instance',
|
|
63
|
+
'getctag',
|
|
64
|
+
'getetag',
|
|
65
|
+
'getcontenttype',
|
|
66
|
+
'getcontentlength',
|
|
67
|
+
'getlastmodified',
|
|
68
|
+
'creationdate',
|
|
69
|
+
'resourcetype',
|
|
70
|
+
'sync-token',
|
|
71
|
+
'current-user-privilege-set',
|
|
72
|
+
'owner',
|
|
73
|
+
'supported-report-set'
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
// Modifiable properties
|
|
77
|
+
const MODIFIABLE_PROPERTIES = new Set([
|
|
78
|
+
'displayname',
|
|
79
|
+
'calendar-description',
|
|
80
|
+
'calendar-timezone',
|
|
81
|
+
'calendar-color',
|
|
82
|
+
'calendar-order'
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
// Helper to simulate the fixed proppatch logic
|
|
86
|
+
function simulateProppatch(setChildren, removeChildren = []) {
|
|
87
|
+
const updates = {};
|
|
88
|
+
|
|
89
|
+
// Process set children
|
|
90
|
+
for (const child of setChildren) {
|
|
91
|
+
if (!child.localName) continue;
|
|
92
|
+
if (PROTECTED_PROPERTIES.has(child.localName)) continue;
|
|
93
|
+
|
|
94
|
+
switch (child.localName) {
|
|
95
|
+
case 'displayname': {
|
|
96
|
+
updates.name = child.textContent;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
case 'calendar-description': {
|
|
101
|
+
updates.description = child.textContent;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case 'calendar-timezone': {
|
|
106
|
+
updates.timezone = child.textContent;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case 'calendar-color': {
|
|
111
|
+
updates.color = child.textContent;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
case 'calendar-order': {
|
|
116
|
+
updates.order = child.textContent
|
|
117
|
+
? Number.parseInt(child.textContent, 10)
|
|
118
|
+
: null;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// No default
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Process remove children
|
|
127
|
+
for (const child of removeChildren) {
|
|
128
|
+
if (!child.localName) continue;
|
|
129
|
+
if (PROTECTED_PROPERTIES.has(child.localName)) continue;
|
|
130
|
+
|
|
131
|
+
switch (child.localName) {
|
|
132
|
+
case 'displayname': {
|
|
133
|
+
updates.name = '';
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case 'calendar-description': {
|
|
138
|
+
updates.description = '';
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
case 'calendar-timezone': {
|
|
143
|
+
updates.timezone = '';
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case 'calendar-color': {
|
|
148
|
+
updates.color = '';
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case 'calendar-order': {
|
|
153
|
+
updates.order = null;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// No default
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return updates;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// =============================================================================
|
|
165
|
+
// RFC 4918 Section 9.2 - PROPPATCH Method Tests
|
|
166
|
+
// =============================================================================
|
|
167
|
+
|
|
168
|
+
test('RFC 4918 9.2: PROPPATCH should process set instructions', (t) => {
|
|
169
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
170
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
171
|
+
<D:set>
|
|
172
|
+
<D:prop>
|
|
173
|
+
<D:displayname>My Calendar</D:displayname>
|
|
174
|
+
</D:prop>
|
|
175
|
+
</D:set>
|
|
176
|
+
</D:propertyupdate>`;
|
|
177
|
+
|
|
178
|
+
const children = getProppatchChildren(xml);
|
|
179
|
+
const updates = simulateProppatch(children);
|
|
180
|
+
|
|
181
|
+
t.is(updates.name, 'My Calendar');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('RFC 4918 9.2: PROPPATCH should process remove instructions', (t) => {
|
|
185
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
186
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
187
|
+
<D:remove>
|
|
188
|
+
<D:prop>
|
|
189
|
+
<C:calendar-description/>
|
|
190
|
+
</D:prop>
|
|
191
|
+
</D:remove>
|
|
192
|
+
</D:propertyupdate>`;
|
|
193
|
+
|
|
194
|
+
const { removeChildren } = getProppatchAllChildren(xml);
|
|
195
|
+
const updates = simulateProppatch([], removeChildren);
|
|
196
|
+
|
|
197
|
+
t.true('description' in updates);
|
|
198
|
+
t.is(updates.description, '');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('RFC 4918 9.2: PROPPATCH should process both set and remove in same request', (t) => {
|
|
202
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
203
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
204
|
+
<D:set>
|
|
205
|
+
<D:prop>
|
|
206
|
+
<D:displayname>Work Calendar</D:displayname>
|
|
207
|
+
</D:prop>
|
|
208
|
+
</D:set>
|
|
209
|
+
<D:remove>
|
|
210
|
+
<D:prop>
|
|
211
|
+
<C:calendar-description/>
|
|
212
|
+
</D:prop>
|
|
213
|
+
</D:remove>
|
|
214
|
+
</D:propertyupdate>`;
|
|
215
|
+
|
|
216
|
+
const { setChildren, removeChildren } = getProppatchAllChildren(xml);
|
|
217
|
+
const updates = simulateProppatch(setChildren, removeChildren);
|
|
218
|
+
|
|
219
|
+
t.is(updates.name, 'Work Calendar');
|
|
220
|
+
t.is(updates.description, '');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// =============================================================================
|
|
224
|
+
// RFC 4918 Section 14.26 - set XML Element Tests
|
|
225
|
+
// =============================================================================
|
|
226
|
+
|
|
227
|
+
test('RFC 4918 14.26: Empty property value should be valid (self-closing)', (t) => {
|
|
228
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
229
|
+
<A:propertyupdate xmlns:A="DAV:"><A:set><A:prop><B:calendar-description xmlns:B="urn:ietf:params:xml:ns:caldav"/></A:prop></A:set></A:propertyupdate>`;
|
|
230
|
+
|
|
231
|
+
const children = getProppatchChildren(xml);
|
|
232
|
+
t.is(children.length, 1);
|
|
233
|
+
t.is(children[0].localName, 'calendar-description');
|
|
234
|
+
t.is(children[0].textContent, '');
|
|
235
|
+
|
|
236
|
+
const updates = simulateProppatch(children);
|
|
237
|
+
t.true('description' in updates);
|
|
238
|
+
t.is(updates.description, '');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('RFC 4918 14.26: Empty property value should be valid (explicit empty)', (t) => {
|
|
242
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
243
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
244
|
+
<D:set>
|
|
245
|
+
<D:prop>
|
|
246
|
+
<C:calendar-description></C:calendar-description>
|
|
247
|
+
</D:prop>
|
|
248
|
+
</D:set>
|
|
249
|
+
</D:propertyupdate>`;
|
|
250
|
+
|
|
251
|
+
const children = getProppatchChildren(xml);
|
|
252
|
+
const descChild = children.find(
|
|
253
|
+
(c) => c.localName === 'calendar-description'
|
|
254
|
+
);
|
|
255
|
+
t.truthy(descChild);
|
|
256
|
+
t.is(descChild.textContent, '');
|
|
257
|
+
|
|
258
|
+
const updates = simulateProppatch(children);
|
|
259
|
+
t.true('description' in updates);
|
|
260
|
+
t.is(updates.description, '');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('RFC 4918 14.26: Setting property should replace existing value', (t) => {
|
|
264
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
265
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
266
|
+
<D:set>
|
|
267
|
+
<D:prop>
|
|
268
|
+
<C:calendar-description>New Description</C:calendar-description>
|
|
269
|
+
</D:prop>
|
|
270
|
+
</D:set>
|
|
271
|
+
</D:propertyupdate>`;
|
|
272
|
+
|
|
273
|
+
const children = getProppatchChildren(xml);
|
|
274
|
+
const updates = simulateProppatch(children);
|
|
275
|
+
|
|
276
|
+
t.is(updates.description, 'New Description');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// =============================================================================
|
|
280
|
+
// RFC 4918 Section 14.23 - remove XML Element Tests
|
|
281
|
+
// =============================================================================
|
|
282
|
+
|
|
283
|
+
test('RFC 4918 14.23: Remove instruction should clear property', (t) => {
|
|
284
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
285
|
+
<D:propertyupdate xmlns:D="DAV:">
|
|
286
|
+
<D:remove>
|
|
287
|
+
<D:prop>
|
|
288
|
+
<D:displayname/>
|
|
289
|
+
</D:prop>
|
|
290
|
+
</D:remove>
|
|
291
|
+
</D:propertyupdate>`;
|
|
292
|
+
|
|
293
|
+
const { removeChildren } = getProppatchAllChildren(xml);
|
|
294
|
+
const updates = simulateProppatch([], removeChildren);
|
|
295
|
+
|
|
296
|
+
t.true('name' in updates);
|
|
297
|
+
t.is(updates.name, '');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('RFC 4918 14.23: Remove elements should be empty (only names needed)', (t) => {
|
|
301
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
302
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
303
|
+
<D:remove>
|
|
304
|
+
<D:prop>
|
|
305
|
+
<C:calendar-description/>
|
|
306
|
+
<C:calendar-timezone/>
|
|
307
|
+
</D:prop>
|
|
308
|
+
</D:remove>
|
|
309
|
+
</D:propertyupdate>`;
|
|
310
|
+
|
|
311
|
+
const { removeChildren } = getProppatchAllChildren(xml);
|
|
312
|
+
const elementChildren = removeChildren.filter((c) => c.localName);
|
|
313
|
+
|
|
314
|
+
// All remove elements should be empty
|
|
315
|
+
for (const child of elementChildren) {
|
|
316
|
+
t.is(child.textContent, '');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const updates = simulateProppatch([], removeChildren);
|
|
320
|
+
t.is(updates.description, '');
|
|
321
|
+
t.is(updates.timezone, '');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('RFC 4918 14.23: Removing non-existent property should not error', (t) => {
|
|
325
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
326
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:X="http://example.com/ns/">
|
|
327
|
+
<D:remove>
|
|
328
|
+
<D:prop>
|
|
329
|
+
<X:nonexistent-property/>
|
|
330
|
+
</D:prop>
|
|
331
|
+
</D:remove>
|
|
332
|
+
</D:propertyupdate>`;
|
|
333
|
+
|
|
334
|
+
const { removeChildren } = getProppatchAllChildren(xml);
|
|
335
|
+
|
|
336
|
+
// Should not throw
|
|
337
|
+
t.notThrows(() => {
|
|
338
|
+
simulateProppatch([], removeChildren);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// =============================================================================
|
|
343
|
+
// RFC 4791 Section 5.2.1 - calendar-description Property Tests
|
|
344
|
+
// =============================================================================
|
|
345
|
+
|
|
346
|
+
test('RFC 4791 5.2.1: calendar-description allows #PCDATA (empty content)', (t) => {
|
|
347
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
348
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
349
|
+
<D:set>
|
|
350
|
+
<D:prop>
|
|
351
|
+
<C:calendar-description/>
|
|
352
|
+
</D:prop>
|
|
353
|
+
</D:set>
|
|
354
|
+
</D:propertyupdate>`;
|
|
355
|
+
|
|
356
|
+
const children = getProppatchChildren(xml);
|
|
357
|
+
const updates = simulateProppatch(children);
|
|
358
|
+
|
|
359
|
+
t.is(updates.description, '');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('RFC 4791 5.2.1: calendar-description with content', (t) => {
|
|
363
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
364
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
365
|
+
<D:set>
|
|
366
|
+
<D:prop>
|
|
367
|
+
<C:calendar-description>My Calendar Description</C:calendar-description>
|
|
368
|
+
</D:prop>
|
|
369
|
+
</D:set>
|
|
370
|
+
</D:propertyupdate>`;
|
|
371
|
+
|
|
372
|
+
const children = getProppatchChildren(xml);
|
|
373
|
+
const updates = simulateProppatch(children);
|
|
374
|
+
|
|
375
|
+
t.is(updates.description, 'My Calendar Description');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// =============================================================================
|
|
379
|
+
// RFC 4791 Section 5.2.2 - calendar-timezone Property Tests
|
|
380
|
+
// =============================================================================
|
|
381
|
+
|
|
382
|
+
test('RFC 4791 5.2.2: calendar-timezone with VTIMEZONE content', (t) => {
|
|
383
|
+
const vtimezone = `BEGIN:VCALENDAR
|
|
384
|
+
VERSION:2.0
|
|
385
|
+
BEGIN:VTIMEZONE
|
|
386
|
+
TZID:America/New_York
|
|
387
|
+
END:VTIMEZONE
|
|
388
|
+
END:VCALENDAR`;
|
|
389
|
+
|
|
390
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
391
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
392
|
+
<D:set>
|
|
393
|
+
<D:prop>
|
|
394
|
+
<C:calendar-timezone>${vtimezone}</C:calendar-timezone>
|
|
395
|
+
</D:prop>
|
|
396
|
+
</D:set>
|
|
397
|
+
</D:propertyupdate>`;
|
|
398
|
+
|
|
399
|
+
const children = getProppatchChildren(xml);
|
|
400
|
+
const updates = simulateProppatch(children);
|
|
401
|
+
|
|
402
|
+
t.true(updates.timezone.includes('VTIMEZONE'));
|
|
403
|
+
t.true(updates.timezone.includes('America/New_York'));
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// =============================================================================
|
|
407
|
+
// RFC 4791 Section 5.2.3 - Protected Properties Tests
|
|
408
|
+
// =============================================================================
|
|
409
|
+
|
|
410
|
+
test('RFC 4791 5.2.3: supported-calendar-component-set is protected', (t) => {
|
|
411
|
+
t.true(PROTECTED_PROPERTIES.has('supported-calendar-component-set'));
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('RFC 4791 5.2.3: Protected properties should be rejected', (t) => {
|
|
415
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
416
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
417
|
+
<D:set>
|
|
418
|
+
<D:prop>
|
|
419
|
+
<C:supported-calendar-component-set>
|
|
420
|
+
<C:comp name="VEVENT"/>
|
|
421
|
+
</C:supported-calendar-component-set>
|
|
422
|
+
</D:prop>
|
|
423
|
+
</D:set>
|
|
424
|
+
</D:propertyupdate>`;
|
|
425
|
+
|
|
426
|
+
const children = getProppatchChildren(xml);
|
|
427
|
+
const updates = simulateProppatch(children);
|
|
428
|
+
|
|
429
|
+
// Protected property should not be in updates
|
|
430
|
+
t.false('supported-calendar-component-set' in updates);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// =============================================================================
|
|
434
|
+
// Apple/CalendarServer Extensions Tests
|
|
435
|
+
// =============================================================================
|
|
436
|
+
|
|
437
|
+
test('Apple Extension: calendar-color with value', (t) => {
|
|
438
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
439
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">
|
|
440
|
+
<D:set>
|
|
441
|
+
<D:prop>
|
|
442
|
+
<A:calendar-color>#FF5733FF</A:calendar-color>
|
|
443
|
+
</D:prop>
|
|
444
|
+
</D:set>
|
|
445
|
+
</D:propertyupdate>`;
|
|
446
|
+
|
|
447
|
+
const children = getProppatchChildren(xml);
|
|
448
|
+
const updates = simulateProppatch(children);
|
|
449
|
+
|
|
450
|
+
t.is(updates.color, '#FF5733FF');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('Apple Extension: calendar-color empty (clear color)', (t) => {
|
|
454
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
455
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">
|
|
456
|
+
<D:set>
|
|
457
|
+
<D:prop>
|
|
458
|
+
<A:calendar-color/>
|
|
459
|
+
</D:prop>
|
|
460
|
+
</D:set>
|
|
461
|
+
</D:propertyupdate>`;
|
|
462
|
+
|
|
463
|
+
const children = getProppatchChildren(xml);
|
|
464
|
+
const updates = simulateProppatch(children);
|
|
465
|
+
|
|
466
|
+
t.true('color' in updates);
|
|
467
|
+
t.is(updates.color, '');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('Apple Extension: calendar-order with integer value', (t) => {
|
|
471
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
472
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">
|
|
473
|
+
<D:set>
|
|
474
|
+
<D:prop>
|
|
475
|
+
<A:calendar-order>5</A:calendar-order>
|
|
476
|
+
</D:prop>
|
|
477
|
+
</D:set>
|
|
478
|
+
</D:propertyupdate>`;
|
|
479
|
+
|
|
480
|
+
const children = getProppatchChildren(xml);
|
|
481
|
+
const updates = simulateProppatch(children);
|
|
482
|
+
|
|
483
|
+
t.is(updates.order, 5);
|
|
484
|
+
t.is(typeof updates.order, 'number');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test('Apple Extension: calendar-order empty (set to null)', (t) => {
|
|
488
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
489
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">
|
|
490
|
+
<D:set>
|
|
491
|
+
<D:prop>
|
|
492
|
+
<A:calendar-order/>
|
|
493
|
+
</D:prop>
|
|
494
|
+
</D:set>
|
|
495
|
+
</D:propertyupdate>`;
|
|
496
|
+
|
|
497
|
+
const children = getProppatchChildren(xml);
|
|
498
|
+
const updates = simulateProppatch(children);
|
|
499
|
+
|
|
500
|
+
t.true('order' in updates);
|
|
501
|
+
t.is(updates.order, null);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// =============================================================================
|
|
505
|
+
// DAV:displayname Property Tests
|
|
506
|
+
// =============================================================================
|
|
507
|
+
|
|
508
|
+
test('DAV:displayname with value', (t) => {
|
|
509
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
510
|
+
<D:propertyupdate xmlns:D="DAV:">
|
|
511
|
+
<D:set>
|
|
512
|
+
<D:prop>
|
|
513
|
+
<D:displayname>Work Calendar</D:displayname>
|
|
514
|
+
</D:prop>
|
|
515
|
+
</D:set>
|
|
516
|
+
</D:propertyupdate>`;
|
|
517
|
+
|
|
518
|
+
const children = getProppatchChildren(xml);
|
|
519
|
+
const updates = simulateProppatch(children);
|
|
520
|
+
|
|
521
|
+
t.is(updates.name, 'Work Calendar');
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test('DAV:displayname empty', (t) => {
|
|
525
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
526
|
+
<D:propertyupdate xmlns:D="DAV:">
|
|
527
|
+
<D:set>
|
|
528
|
+
<D:prop>
|
|
529
|
+
<D:displayname/>
|
|
530
|
+
</D:prop>
|
|
531
|
+
</D:set>
|
|
532
|
+
</D:propertyupdate>`;
|
|
533
|
+
|
|
534
|
+
const children = getProppatchChildren(xml);
|
|
535
|
+
const updates = simulateProppatch(children);
|
|
536
|
+
|
|
537
|
+
t.true('name' in updates);
|
|
538
|
+
t.is(updates.name, '');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// =============================================================================
|
|
542
|
+
// Multiple Properties Tests
|
|
543
|
+
// =============================================================================
|
|
544
|
+
|
|
545
|
+
test('Multiple properties in single PROPPATCH', (t) => {
|
|
546
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
547
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="http://apple.com/ns/ical/">
|
|
548
|
+
<D:set>
|
|
549
|
+
<D:prop>
|
|
550
|
+
<D:displayname>Work Calendar</D:displayname>
|
|
551
|
+
<C:calendar-description>My work events</C:calendar-description>
|
|
552
|
+
<A:calendar-color>#0000FFFF</A:calendar-color>
|
|
553
|
+
<A:calendar-order>1</A:calendar-order>
|
|
554
|
+
</D:prop>
|
|
555
|
+
</D:set>
|
|
556
|
+
</D:propertyupdate>`;
|
|
557
|
+
|
|
558
|
+
const children = getProppatchChildren(xml);
|
|
559
|
+
const updates = simulateProppatch(children);
|
|
560
|
+
|
|
561
|
+
t.is(updates.name, 'Work Calendar');
|
|
562
|
+
t.is(updates.description, 'My work events');
|
|
563
|
+
t.is(updates.color, '#0000FFFF');
|
|
564
|
+
t.is(updates.order, 1);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test('Mixed empty and non-empty properties', (t) => {
|
|
568
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
569
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="http://apple.com/ns/ical/">
|
|
570
|
+
<D:set>
|
|
571
|
+
<D:prop>
|
|
572
|
+
<D:displayname>My Calendar</D:displayname>
|
|
573
|
+
<C:calendar-description/>
|
|
574
|
+
<A:calendar-color>#FF0000FF</A:calendar-color>
|
|
575
|
+
</D:prop>
|
|
576
|
+
</D:set>
|
|
577
|
+
</D:propertyupdate>`;
|
|
578
|
+
|
|
579
|
+
const children = getProppatchChildren(xml);
|
|
580
|
+
const updates = simulateProppatch(children);
|
|
581
|
+
|
|
582
|
+
t.is(updates.name, 'My Calendar');
|
|
583
|
+
t.is(updates.description, '');
|
|
584
|
+
t.is(updates.color, '#FF0000FF');
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// =============================================================================
|
|
588
|
+
// Whitespace Handling Tests
|
|
589
|
+
// =============================================================================
|
|
590
|
+
|
|
591
|
+
test('Whitespace preservation in property values', (t) => {
|
|
592
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
593
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
594
|
+
<D:set>
|
|
595
|
+
<D:prop>
|
|
596
|
+
<C:calendar-description> spaced </C:calendar-description>
|
|
597
|
+
</D:prop>
|
|
598
|
+
</D:set>
|
|
599
|
+
</D:propertyupdate>`;
|
|
600
|
+
|
|
601
|
+
const children = getProppatchChildren(xml);
|
|
602
|
+
const updates = simulateProppatch(children);
|
|
603
|
+
|
|
604
|
+
t.is(updates.description, ' spaced ');
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// =============================================================================
|
|
608
|
+
// Namespace Handling Tests
|
|
609
|
+
// =============================================================================
|
|
610
|
+
|
|
611
|
+
test('Different namespace prefixes for same namespace', (t) => {
|
|
612
|
+
// Using 'B' instead of 'C' for caldav namespace
|
|
613
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
614
|
+
<A:propertyupdate xmlns:A="DAV:">
|
|
615
|
+
<A:set>
|
|
616
|
+
<A:prop>
|
|
617
|
+
<B:calendar-description xmlns:B="urn:ietf:params:xml:ns:caldav">Test Description</B:calendar-description>
|
|
618
|
+
</A:prop>
|
|
619
|
+
</A:set>
|
|
620
|
+
</A:propertyupdate>`;
|
|
621
|
+
|
|
622
|
+
const children = getProppatchChildren(xml);
|
|
623
|
+
const updates = simulateProppatch(children);
|
|
624
|
+
|
|
625
|
+
t.is(updates.description, 'Test Description');
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test('Unknown namespace properties should be ignored gracefully', (t) => {
|
|
629
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
630
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:X="http://example.com/ns/">
|
|
631
|
+
<D:set>
|
|
632
|
+
<D:prop>
|
|
633
|
+
<D:displayname>My Calendar</D:displayname>
|
|
634
|
+
<X:unknown-property>some value</X:unknown-property>
|
|
635
|
+
</D:prop>
|
|
636
|
+
</D:set>
|
|
637
|
+
</D:propertyupdate>`;
|
|
638
|
+
|
|
639
|
+
const children = getProppatchChildren(xml);
|
|
640
|
+
const updates = simulateProppatch(children);
|
|
641
|
+
|
|
642
|
+
// Should still process known properties
|
|
643
|
+
t.is(updates.name, 'My Calendar');
|
|
644
|
+
// Unknown property should not be in updates
|
|
645
|
+
t.false('unknown-property' in updates);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// =============================================================================
|
|
649
|
+
// Document Order Processing Tests (RFC 4918 Section 9.2)
|
|
650
|
+
// =============================================================================
|
|
651
|
+
|
|
652
|
+
test('RFC 4918 9.2: Instructions processed in document order', (t) => {
|
|
653
|
+
// Set then remove should result in empty
|
|
654
|
+
const xml1 = `<?xml version="1.0" encoding="UTF-8"?>
|
|
655
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
656
|
+
<D:set>
|
|
657
|
+
<D:prop>
|
|
658
|
+
<C:calendar-description>First Value</C:calendar-description>
|
|
659
|
+
</D:prop>
|
|
660
|
+
</D:set>
|
|
661
|
+
<D:remove>
|
|
662
|
+
<D:prop>
|
|
663
|
+
<C:calendar-description/>
|
|
664
|
+
</D:prop>
|
|
665
|
+
</D:remove>
|
|
666
|
+
</D:propertyupdate>`;
|
|
667
|
+
|
|
668
|
+
const { setChildren: set1, removeChildren: remove1 } =
|
|
669
|
+
getProppatchAllChildren(xml1);
|
|
670
|
+
const updates1 = simulateProppatch(set1, remove1);
|
|
671
|
+
|
|
672
|
+
// Remove comes after set, so should be empty
|
|
673
|
+
t.is(updates1.description, '');
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// =============================================================================
|
|
677
|
+
// Protected Properties Complete List Tests
|
|
678
|
+
// =============================================================================
|
|
679
|
+
|
|
680
|
+
test('All RFC 4791 protected properties are in PROTECTED_PROPERTIES set', (t) => {
|
|
681
|
+
const rfc4791Protected = [
|
|
682
|
+
'supported-calendar-component-set',
|
|
683
|
+
'supported-calendar-data',
|
|
684
|
+
'max-resource-size',
|
|
685
|
+
'min-date-time',
|
|
686
|
+
'max-date-time',
|
|
687
|
+
'max-instances',
|
|
688
|
+
'max-attendees-per-instance'
|
|
689
|
+
];
|
|
690
|
+
|
|
691
|
+
for (const prop of rfc4791Protected) {
|
|
692
|
+
t.true(
|
|
693
|
+
PROTECTED_PROPERTIES.has(prop),
|
|
694
|
+
`${prop} should be in PROTECTED_PROPERTIES`
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
test('All DAV protected properties are in PROTECTED_PROPERTIES set', (t) => {
|
|
700
|
+
const davProtected = [
|
|
701
|
+
'getetag',
|
|
702
|
+
'getcontenttype',
|
|
703
|
+
'getcontentlength',
|
|
704
|
+
'getlastmodified',
|
|
705
|
+
'creationdate',
|
|
706
|
+
'resourcetype',
|
|
707
|
+
'sync-token',
|
|
708
|
+
'current-user-privilege-set',
|
|
709
|
+
'owner',
|
|
710
|
+
'supported-report-set'
|
|
711
|
+
];
|
|
712
|
+
|
|
713
|
+
for (const prop of davProtected) {
|
|
714
|
+
t.true(
|
|
715
|
+
PROTECTED_PROPERTIES.has(prop),
|
|
716
|
+
`${prop} should be in PROTECTED_PROPERTIES`
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// =============================================================================
|
|
722
|
+
// Modifiable Properties Complete List Tests
|
|
723
|
+
// =============================================================================
|
|
724
|
+
|
|
725
|
+
test('All modifiable properties are in MODIFIABLE_PROPERTIES set', (t) => {
|
|
726
|
+
const modifiable = [
|
|
727
|
+
'displayname',
|
|
728
|
+
'calendar-description',
|
|
729
|
+
'calendar-timezone',
|
|
730
|
+
'calendar-color',
|
|
731
|
+
'calendar-order'
|
|
732
|
+
];
|
|
733
|
+
|
|
734
|
+
for (const prop of modifiable) {
|
|
735
|
+
t.true(
|
|
736
|
+
MODIFIABLE_PROPERTIES.has(prop),
|
|
737
|
+
`${prop} should be in MODIFIABLE_PROPERTIES`
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
// =============================================================================
|
|
743
|
+
// Edge Cases Tests
|
|
744
|
+
// =============================================================================
|
|
745
|
+
|
|
746
|
+
test('Empty propertyupdate should not throw', (t) => {
|
|
747
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
748
|
+
<D:propertyupdate xmlns:D="DAV:">
|
|
749
|
+
<D:set>
|
|
750
|
+
<D:prop>
|
|
751
|
+
</D:prop>
|
|
752
|
+
</D:set>
|
|
753
|
+
</D:propertyupdate>`;
|
|
754
|
+
|
|
755
|
+
const children = getProppatchChildren(xml);
|
|
756
|
+
|
|
757
|
+
t.notThrows(() => {
|
|
758
|
+
simulateProppatch(children);
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test('Whitespace-only text nodes should be ignored', (t) => {
|
|
763
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
764
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
765
|
+
<D:set>
|
|
766
|
+
<D:prop>
|
|
767
|
+
<C:calendar-description>Test</C:calendar-description>
|
|
768
|
+
</D:prop>
|
|
769
|
+
</D:set>
|
|
770
|
+
</D:propertyupdate>`;
|
|
771
|
+
|
|
772
|
+
const children = getProppatchChildren(xml);
|
|
773
|
+
// Filter to only element nodes (nodeType 1)
|
|
774
|
+
const elementChildren = children.filter((c) => c.nodeType === 1);
|
|
775
|
+
|
|
776
|
+
t.is(elementChildren.length, 1);
|
|
777
|
+
t.is(elementChildren[0].localName, 'calendar-description');
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test('Special characters in property values should be preserved', (t) => {
|
|
781
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
782
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
783
|
+
<D:set>
|
|
784
|
+
<D:prop>
|
|
785
|
+
<C:calendar-description>Test & Description <with> "special" chars</C:calendar-description>
|
|
786
|
+
</D:prop>
|
|
787
|
+
</D:set>
|
|
788
|
+
</D:propertyupdate>`;
|
|
789
|
+
|
|
790
|
+
const children = getProppatchChildren(xml);
|
|
791
|
+
const updates = simulateProppatch(children);
|
|
792
|
+
|
|
793
|
+
t.is(updates.description, 'Test & Description <with> "special" chars');
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test('Unicode characters in property values', (t) => {
|
|
797
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
798
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
799
|
+
<D:set>
|
|
800
|
+
<D:prop>
|
|
801
|
+
<C:calendar-description>日本語カレンダー 📅</C:calendar-description>
|
|
802
|
+
</D:prop>
|
|
803
|
+
</D:set>
|
|
804
|
+
</D:propertyupdate>`;
|
|
805
|
+
|
|
806
|
+
const children = getProppatchChildren(xml);
|
|
807
|
+
const updates = simulateProppatch(children);
|
|
808
|
+
|
|
809
|
+
t.is(updates.description, '日本語カレンダー 📅');
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test('Newlines in property values should be preserved', (t) => {
|
|
813
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
814
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
815
|
+
<D:set>
|
|
816
|
+
<D:prop>
|
|
817
|
+
<C:calendar-description>Line 1
|
|
818
|
+
Line 2
|
|
819
|
+
Line 3</C:calendar-description>
|
|
820
|
+
</D:prop>
|
|
821
|
+
</D:set>
|
|
822
|
+
</D:propertyupdate>`;
|
|
823
|
+
|
|
824
|
+
const children = getProppatchChildren(xml);
|
|
825
|
+
const updates = simulateProppatch(children);
|
|
826
|
+
|
|
827
|
+
t.true(updates.description.includes('\n'));
|
|
828
|
+
t.true(updates.description.includes('Line 1'));
|
|
829
|
+
t.true(updates.description.includes('Line 2'));
|
|
830
|
+
t.true(updates.description.includes('Line 3'));
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
// =============================================================================
|
|
834
|
+
// Remove Multiple Properties Tests
|
|
835
|
+
// =============================================================================
|
|
836
|
+
|
|
837
|
+
test('Remove multiple properties at once', (t) => {
|
|
838
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
839
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="http://apple.com/ns/ical/">
|
|
840
|
+
<D:remove>
|
|
841
|
+
<D:prop>
|
|
842
|
+
<D:displayname/>
|
|
843
|
+
<C:calendar-description/>
|
|
844
|
+
<A:calendar-color/>
|
|
845
|
+
<A:calendar-order/>
|
|
846
|
+
</D:prop>
|
|
847
|
+
</D:remove>
|
|
848
|
+
</D:propertyupdate>`;
|
|
849
|
+
|
|
850
|
+
const { removeChildren } = getProppatchAllChildren(xml);
|
|
851
|
+
const updates = simulateProppatch([], removeChildren);
|
|
852
|
+
|
|
853
|
+
t.is(updates.name, '');
|
|
854
|
+
t.is(updates.description, '');
|
|
855
|
+
t.is(updates.color, '');
|
|
856
|
+
t.is(updates.order, null);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// =============================================================================
|
|
860
|
+
// Complex Mixed Operations Tests
|
|
861
|
+
// =============================================================================
|
|
862
|
+
|
|
863
|
+
test('Complex: Set some properties, remove others', (t) => {
|
|
864
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
865
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="http://apple.com/ns/ical/">
|
|
866
|
+
<D:set>
|
|
867
|
+
<D:prop>
|
|
868
|
+
<D:displayname>New Name</D:displayname>
|
|
869
|
+
<A:calendar-color>#123456FF</A:calendar-color>
|
|
870
|
+
</D:prop>
|
|
871
|
+
</D:set>
|
|
872
|
+
<D:remove>
|
|
873
|
+
<D:prop>
|
|
874
|
+
<C:calendar-description/>
|
|
875
|
+
<A:calendar-order/>
|
|
876
|
+
</D:prop>
|
|
877
|
+
</D:remove>
|
|
878
|
+
</D:propertyupdate>`;
|
|
879
|
+
|
|
880
|
+
const { setChildren, removeChildren } = getProppatchAllChildren(xml);
|
|
881
|
+
const updates = simulateProppatch(setChildren, removeChildren);
|
|
882
|
+
|
|
883
|
+
t.is(updates.name, 'New Name');
|
|
884
|
+
t.is(updates.color, '#123456FF');
|
|
885
|
+
t.is(updates.description, '');
|
|
886
|
+
t.is(updates.order, null);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// =============================================================================
|
|
890
|
+
// RFC 4791 Section 5.2.2 - calendar-timezone Validation Tests
|
|
891
|
+
// =============================================================================
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Validate VTIMEZONE content per RFC 4791 Section 5.2.2
|
|
895
|
+
*/
|
|
896
|
+
function validateCalendarTimezone(value) {
|
|
897
|
+
if (!value || value.trim() === '') {
|
|
898
|
+
return { valid: true, error: null };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (!value.includes('BEGIN:VCALENDAR') || !value.includes('END:VCALENDAR')) {
|
|
902
|
+
return {
|
|
903
|
+
valid: false,
|
|
904
|
+
error:
|
|
905
|
+
'calendar-timezone must be a valid iCalendar object (missing VCALENDAR)'
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const vtimezoneCount = (value.match(/BEGIN:VTIMEZONE/g) || []).length;
|
|
910
|
+
if (vtimezoneCount === 0) {
|
|
911
|
+
return {
|
|
912
|
+
valid: false,
|
|
913
|
+
error: 'calendar-timezone must contain a VTIMEZONE component'
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (vtimezoneCount > 1) {
|
|
918
|
+
return {
|
|
919
|
+
valid: false,
|
|
920
|
+
error: 'calendar-timezone must contain exactly one VTIMEZONE component'
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const endVtimezoneCount = (value.match(/END:VTIMEZONE/g) || []).length;
|
|
925
|
+
if (vtimezoneCount !== endVtimezoneCount) {
|
|
926
|
+
return {
|
|
927
|
+
valid: false,
|
|
928
|
+
error: 'calendar-timezone has malformed VTIMEZONE component'
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (!value.includes('TZID:') && !value.includes('TZID;')) {
|
|
933
|
+
return {
|
|
934
|
+
valid: false,
|
|
935
|
+
error: 'VTIMEZONE component must have a TZID property'
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return { valid: true, error: null };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
test('RFC 4791 5.2.2: Empty timezone is valid (clearing)', (t) => {
|
|
943
|
+
const result = validateCalendarTimezone('');
|
|
944
|
+
t.true(result.valid);
|
|
945
|
+
t.is(result.error, null);
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
test('RFC 4791 5.2.2: Valid VTIMEZONE passes validation', (t) => {
|
|
949
|
+
const validTimezone = `BEGIN:VCALENDAR
|
|
950
|
+
VERSION:2.0
|
|
951
|
+
PRODID:-//Test//Test//EN
|
|
952
|
+
BEGIN:VTIMEZONE
|
|
953
|
+
TZID:America/New_York
|
|
954
|
+
BEGIN:STANDARD
|
|
955
|
+
DTSTART:19701101T020000
|
|
956
|
+
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
|
|
957
|
+
TZOFFSETFROM:-0400
|
|
958
|
+
TZOFFSETTO:-0500
|
|
959
|
+
TZNAME:EST
|
|
960
|
+
END:STANDARD
|
|
961
|
+
BEGIN:DAYLIGHT
|
|
962
|
+
DTSTART:19700308T020000
|
|
963
|
+
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
|
|
964
|
+
TZOFFSETFROM:-0500
|
|
965
|
+
TZOFFSETTO:-0400
|
|
966
|
+
TZNAME:EDT
|
|
967
|
+
END:DAYLIGHT
|
|
968
|
+
END:VTIMEZONE
|
|
969
|
+
END:VCALENDAR`;
|
|
970
|
+
|
|
971
|
+
const result = validateCalendarTimezone(validTimezone);
|
|
972
|
+
t.true(result.valid);
|
|
973
|
+
t.is(result.error, null);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
test('RFC 4791 5.2.2: Missing VCALENDAR wrapper fails validation', (t) => {
|
|
977
|
+
const invalidTimezone = `BEGIN:VTIMEZONE
|
|
978
|
+
TZID:America/New_York
|
|
979
|
+
END:VTIMEZONE`;
|
|
980
|
+
|
|
981
|
+
const result = validateCalendarTimezone(invalidTimezone);
|
|
982
|
+
t.false(result.valid);
|
|
983
|
+
t.true(result.error.includes('VCALENDAR'));
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
test('RFC 4791 5.2.2: Missing VTIMEZONE component fails validation', (t) => {
|
|
987
|
+
const invalidTimezone = `BEGIN:VCALENDAR
|
|
988
|
+
VERSION:2.0
|
|
989
|
+
END:VCALENDAR`;
|
|
990
|
+
|
|
991
|
+
const result = validateCalendarTimezone(invalidTimezone);
|
|
992
|
+
t.false(result.valid);
|
|
993
|
+
t.true(result.error.includes('VTIMEZONE'));
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
test('RFC 4791 5.2.2: Multiple VTIMEZONE components fails validation', (t) => {
|
|
997
|
+
const invalidTimezone = `BEGIN:VCALENDAR
|
|
998
|
+
VERSION:2.0
|
|
999
|
+
BEGIN:VTIMEZONE
|
|
1000
|
+
TZID:America/New_York
|
|
1001
|
+
END:VTIMEZONE
|
|
1002
|
+
BEGIN:VTIMEZONE
|
|
1003
|
+
TZID:Europe/London
|
|
1004
|
+
END:VTIMEZONE
|
|
1005
|
+
END:VCALENDAR`;
|
|
1006
|
+
|
|
1007
|
+
const result = validateCalendarTimezone(invalidTimezone);
|
|
1008
|
+
t.false(result.valid);
|
|
1009
|
+
t.true(result.error.includes('exactly one'));
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
test('RFC 4791 5.2.2: Missing TZID property fails validation', (t) => {
|
|
1013
|
+
const invalidTimezone = `BEGIN:VCALENDAR
|
|
1014
|
+
VERSION:2.0
|
|
1015
|
+
BEGIN:VTIMEZONE
|
|
1016
|
+
BEGIN:STANDARD
|
|
1017
|
+
DTSTART:19701101T020000
|
|
1018
|
+
TZOFFSETFROM:-0400
|
|
1019
|
+
TZOFFSETTO:-0500
|
|
1020
|
+
END:STANDARD
|
|
1021
|
+
END:VTIMEZONE
|
|
1022
|
+
END:VCALENDAR`;
|
|
1023
|
+
|
|
1024
|
+
const result = validateCalendarTimezone(invalidTimezone);
|
|
1025
|
+
t.false(result.valid);
|
|
1026
|
+
t.true(result.error.includes('TZID'));
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
// =============================================================================
|
|
1030
|
+
// RFC 4791 Section 5.2.1 - xml:lang Attribute Tests
|
|
1031
|
+
// =============================================================================
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Extract xml:lang attribute from an element
|
|
1035
|
+
*/
|
|
1036
|
+
function extractXmlLang(child) {
|
|
1037
|
+
if (child.getAttributeNS) {
|
|
1038
|
+
const lang = child.getAttributeNS(
|
|
1039
|
+
'http://www.w3.org/XML/1998/namespace',
|
|
1040
|
+
'lang'
|
|
1041
|
+
);
|
|
1042
|
+
if (lang) return lang;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (child.getAttribute) {
|
|
1046
|
+
const lang = child.getAttribute('xml:lang');
|
|
1047
|
+
if (lang) return lang;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
let parent = child.parentNode;
|
|
1051
|
+
while (parent && parent.getAttribute) {
|
|
1052
|
+
const lang = parent.getAttribute('xml:lang');
|
|
1053
|
+
if (lang) return lang;
|
|
1054
|
+
parent = parent.parentNode;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
test('RFC 4791 5.2.1: xml:lang attribute on element is extracted', (t) => {
|
|
1061
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1062
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
1063
|
+
<D:set>
|
|
1064
|
+
<D:prop>
|
|
1065
|
+
<C:calendar-description xml:lang="en-US">English Description</C:calendar-description>
|
|
1066
|
+
</D:prop>
|
|
1067
|
+
</D:set>
|
|
1068
|
+
</D:propertyupdate>`;
|
|
1069
|
+
|
|
1070
|
+
const children = getProppatchChildren(xml);
|
|
1071
|
+
const descChild = children.find(
|
|
1072
|
+
(c) => c.localName === 'calendar-description'
|
|
1073
|
+
);
|
|
1074
|
+
t.truthy(descChild);
|
|
1075
|
+
|
|
1076
|
+
const lang = extractXmlLang(descChild);
|
|
1077
|
+
t.is(lang, 'en-US');
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
test('RFC 4791 5.2.1: xml:lang attribute inherited from parent', (t) => {
|
|
1081
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1082
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xml:lang="de-DE">
|
|
1083
|
+
<D:set>
|
|
1084
|
+
<D:prop>
|
|
1085
|
+
<C:calendar-description>German Description</C:calendar-description>
|
|
1086
|
+
</D:prop>
|
|
1087
|
+
</D:set>
|
|
1088
|
+
</D:propertyupdate>`;
|
|
1089
|
+
|
|
1090
|
+
const parser = new DOMParser();
|
|
1091
|
+
const doc = parser.parseFromString(xml, 'text/xml');
|
|
1092
|
+
const propNode = select('/D:propertyupdate/D:set/D:prop', doc);
|
|
1093
|
+
const children = propNode[0] ? nodeListToArray(propNode[0].childNodes) : [];
|
|
1094
|
+
const descChild = children.find(
|
|
1095
|
+
(c) => c.localName === 'calendar-description'
|
|
1096
|
+
);
|
|
1097
|
+
t.truthy(descChild);
|
|
1098
|
+
|
|
1099
|
+
const lang = extractXmlLang(descChild);
|
|
1100
|
+
t.is(lang, 'de-DE');
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
test('RFC 4791 5.2.1: No xml:lang returns null', (t) => {
|
|
1104
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1105
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
1106
|
+
<D:set>
|
|
1107
|
+
<D:prop>
|
|
1108
|
+
<C:calendar-description>No Language</C:calendar-description>
|
|
1109
|
+
</D:prop>
|
|
1110
|
+
</D:set>
|
|
1111
|
+
</D:propertyupdate>`;
|
|
1112
|
+
|
|
1113
|
+
const children = getProppatchChildren(xml);
|
|
1114
|
+
const descChild = children.find(
|
|
1115
|
+
(c) => c.localName === 'calendar-description'
|
|
1116
|
+
);
|
|
1117
|
+
t.truthy(descChild);
|
|
1118
|
+
|
|
1119
|
+
const lang = extractXmlLang(descChild);
|
|
1120
|
+
t.is(lang, null);
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
// =============================================================================
|
|
1124
|
+
// RFC 4918 Section 9.2 - Atomicity Tests
|
|
1125
|
+
// =============================================================================
|
|
1126
|
+
|
|
1127
|
+
test('RFC 4918 9.2: Atomicity - all properties should fail if one is protected', (t) => {
|
|
1128
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1129
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
1130
|
+
<D:set>
|
|
1131
|
+
<D:prop>
|
|
1132
|
+
<D:displayname>New Name</D:displayname>
|
|
1133
|
+
<C:supported-calendar-component-set>
|
|
1134
|
+
<C:comp name="VEVENT"/>
|
|
1135
|
+
</C:supported-calendar-component-set>
|
|
1136
|
+
</D:prop>
|
|
1137
|
+
</D:set>
|
|
1138
|
+
</D:propertyupdate>`;
|
|
1139
|
+
|
|
1140
|
+
const children = getProppatchChildren(xml);
|
|
1141
|
+
|
|
1142
|
+
// Check that we have both a modifiable and protected property
|
|
1143
|
+
const hasDisplayname = children.some((c) => c.localName === 'displayname');
|
|
1144
|
+
const hasProtected = children.some(
|
|
1145
|
+
(c) => c.localName === 'supported-calendar-component-set'
|
|
1146
|
+
);
|
|
1147
|
+
|
|
1148
|
+
t.true(hasDisplayname);
|
|
1149
|
+
t.true(hasProtected);
|
|
1150
|
+
|
|
1151
|
+
// In a real implementation, the presence of the protected property
|
|
1152
|
+
// would cause the entire request to fail (atomicity)
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
test('RFC 4918 9.2: Atomicity - validation error should fail all properties', (t) => {
|
|
1156
|
+
const invalidTimezone = 'NOT A VALID TIMEZONE';
|
|
1157
|
+
const validation = validateCalendarTimezone(invalidTimezone);
|
|
1158
|
+
|
|
1159
|
+
t.false(validation.valid);
|
|
1160
|
+
// In a real implementation, this would cause all properties in the
|
|
1161
|
+
// PROPPATCH request to fail due to atomicity requirements
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
// =============================================================================
|
|
1165
|
+
// Integration-style Tests
|
|
1166
|
+
// =============================================================================
|
|
1167
|
+
|
|
1168
|
+
test('Integration: Full PROPPATCH with xml:lang and valid timezone', (t) => {
|
|
1169
|
+
const validTimezone = `BEGIN:VCALENDAR
|
|
1170
|
+
VERSION:2.0
|
|
1171
|
+
BEGIN:VTIMEZONE
|
|
1172
|
+
TZID:Europe/Paris
|
|
1173
|
+
END:VTIMEZONE
|
|
1174
|
+
END:VCALENDAR`;
|
|
1175
|
+
|
|
1176
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1177
|
+
<D:propertyupdate xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="http://apple.com/ns/ical/">
|
|
1178
|
+
<D:set>
|
|
1179
|
+
<D:prop>
|
|
1180
|
+
<D:displayname xml:lang="fr-FR">Mon Calendrier</D:displayname>
|
|
1181
|
+
<C:calendar-description xml:lang="fr-FR">Description en français</C:calendar-description>
|
|
1182
|
+
<C:calendar-timezone>${validTimezone}</C:calendar-timezone>
|
|
1183
|
+
<A:calendar-color>#0000FFFF</A:calendar-color>
|
|
1184
|
+
</D:prop>
|
|
1185
|
+
</D:set>
|
|
1186
|
+
</D:propertyupdate>`;
|
|
1187
|
+
|
|
1188
|
+
const children = getProppatchChildren(xml);
|
|
1189
|
+
|
|
1190
|
+
// Verify all properties are present
|
|
1191
|
+
const displayname = children.find((c) => c.localName === 'displayname');
|
|
1192
|
+
const description = children.find(
|
|
1193
|
+
(c) => c.localName === 'calendar-description'
|
|
1194
|
+
);
|
|
1195
|
+
const timezone = children.find((c) => c.localName === 'calendar-timezone');
|
|
1196
|
+
const color = children.find((c) => c.localName === 'calendar-color');
|
|
1197
|
+
|
|
1198
|
+
t.truthy(displayname);
|
|
1199
|
+
t.truthy(description);
|
|
1200
|
+
t.truthy(timezone);
|
|
1201
|
+
t.truthy(color);
|
|
1202
|
+
|
|
1203
|
+
// Verify xml:lang extraction
|
|
1204
|
+
t.is(extractXmlLang(displayname), 'fr-FR');
|
|
1205
|
+
t.is(extractXmlLang(description), 'fr-FR');
|
|
1206
|
+
|
|
1207
|
+
// Verify timezone validation
|
|
1208
|
+
const timezoneValidation = validateCalendarTimezone(timezone.textContent);
|
|
1209
|
+
t.true(timezoneValidation.valid);
|
|
1210
|
+
|
|
1211
|
+
// Verify values
|
|
1212
|
+
t.is(displayname.textContent, 'Mon Calendrier');
|
|
1213
|
+
t.is(description.textContent, 'Description en français');
|
|
1214
|
+
t.is(color.textContent, '#0000FFFF');
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
// =============================================================================
|
|
1218
|
+
// Additional Tests for Stack Trace Issues
|
|
1219
|
+
// =============================================================================
|
|
1220
|
+
|
|
1221
|
+
test('xml.get returns empty array for null document', (t) => {
|
|
1222
|
+
const xml = require('../common/xml');
|
|
1223
|
+
const result = xml.get('/D:propfind/D:prop', null);
|
|
1224
|
+
t.deepEqual(result, []);
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
test('xml.get returns empty array for undefined document', (t) => {
|
|
1228
|
+
const xml = require('../common/xml');
|
|
1229
|
+
const result = xml.get('/D:propfind/D:prop', undefined);
|
|
1230
|
+
t.deepEqual(result, []);
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
test('xml.get returns empty array for non-object document', (t) => {
|
|
1234
|
+
const xml = require('../common/xml');
|
|
1235
|
+
const result = xml.get('/D:propfind/D:prop', 'not an object');
|
|
1236
|
+
t.deepEqual(result, []);
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
test('xml.getWithChildren handles null document gracefully', (t) => {
|
|
1240
|
+
const xml = require('../common/xml');
|
|
1241
|
+
const result = xml.getWithChildren('/D:propfind/D:prop', null);
|
|
1242
|
+
t.deepEqual(result.propNode, []);
|
|
1243
|
+
t.deepEqual(result.children, []);
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
test('preconditionFail generates valid XML error response', (t) => {
|
|
1247
|
+
const { preconditionFail } = require('../common/x-build');
|
|
1248
|
+
const result = preconditionFail(
|
|
1249
|
+
'/dav/user/calendar/event.ics',
|
|
1250
|
+
'no-uid-conflict'
|
|
1251
|
+
);
|
|
1252
|
+
t.true(result.includes('D:error'));
|
|
1253
|
+
t.true(result.includes('no-uid-conflict'));
|
|
1254
|
+
});
|