async-caldav 1.0.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.
@@ -0,0 +1,1152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "async/caldav"
6
+ require 'securerandom'
7
+
8
+ module Async
9
+ module Caldav
10
+ class Server
11
+ def initialize(storage:)
12
+ @storage = storage
13
+ end
14
+
15
+ def call(env)
16
+ method = env['REQUEST_METHOD']
17
+ raw_path = env['PATH_INFO'] || '/'
18
+ path = Protocol::Caldav::Path.new(raw_path, storage_class: @storage)
19
+
20
+ # OPTIONS doesn't require auth
21
+ return Handlers::Options.call(path: path, storage: @storage) if method == 'OPTIONS'
22
+
23
+ # Auth check
24
+ user = env['dav.user']
25
+ unless user && !user.to_s.empty?
26
+ return [401, { 'content-type' => 'text/plain', 'www-authenticate' => 'Basic realm="caldav"' }, ['Unauthorized']]
27
+ end
28
+
29
+ # Path sanitization
30
+ if raw_path.include?('..')
31
+ return [400, { 'content-type' => 'text/plain' }, ['Bad Request']]
32
+ end
33
+
34
+ resource_type = resource_type_for(path)
35
+ body = read_body(env)
36
+ headers = extract_headers(env)
37
+
38
+ dispatch(method, path: path, body: body, storage: @storage, user: user,
39
+ headers: headers, resource_type: resource_type)
40
+ end
41
+
42
+ private
43
+
44
+ def dispatch(method, **ctx)
45
+ case method
46
+ when 'PROPFIND' then Handlers::Propfind.call(**ctx)
47
+ when 'PROPPATCH' then Handlers::Proppatch.call(**ctx)
48
+ when 'MKCALENDAR' then Handlers::Mkcol.call(method: 'MKCALENDAR', **ctx)
49
+ when 'MKCOL' then Handlers::Mkcol.call(method: 'MKCOL', **ctx)
50
+ when 'GET' then Handlers::Get.call(**ctx)
51
+ when 'HEAD' then Handlers::Head.call(**ctx)
52
+ when 'PUT' then handle_put(**ctx)
53
+ when 'DELETE' then Handlers::Delete.call(**ctx)
54
+ when 'MOVE' then Handlers::Move.call(**ctx)
55
+ when 'REPORT' then Handlers::Report.call(**ctx)
56
+ else
57
+ [405, { 'content-type' => 'text/plain' }, ['Method Not Allowed']]
58
+ end
59
+ end
60
+
61
+ def handle_put(path:, body:, storage:, resource_type: nil, **ctx)
62
+ col_path = path.to_s
63
+ col_path_slash = col_path.end_with?('/') ? col_path : "#{col_path}/"
64
+ collection = storage.get_collection(col_path_slash)
65
+
66
+ if collection || col_path.end_with?('/')
67
+ # Whole calendar/addressbook PUT
68
+ return put_whole_collection(path: col_path_slash, body: body, storage: storage, resource_type: resource_type)
69
+ end
70
+
71
+ Handlers::Put.call(path: path, body: body, storage: storage, resource_type: resource_type, **ctx)
72
+ end
73
+
74
+ def put_whole_collection(path:, body:, storage:, resource_type:)
75
+ return [400, { 'content-type' => 'text/plain' }, ['Empty body']] if body.nil? || body.strip.empty?
76
+
77
+ # Delete existing items in this collection
78
+ storage.list_items(path).each { |item_path, _| storage.delete_item(item_path) }
79
+
80
+ # Ensure collection exists
81
+ unless storage.get_collection(path)
82
+ type = resource_type || :collection
83
+ storage.create_collection(path, type: type)
84
+ end
85
+
86
+ if resource_type == :addressbook || body.start_with?('BEGIN:VCARD')
87
+ items = split_vcards(body)
88
+ items.each do |uid, vcard_body|
89
+ item_path = "#{path}#{uid}.vcf"
90
+ storage.put_item(item_path, vcard_body, 'text/vcard')
91
+ end
92
+ else
93
+ items = split_vcalendar(body)
94
+ items.each do |uid, cal_body|
95
+ item_path = "#{path}#{uid}.ics"
96
+ storage.put_item(item_path, cal_body, 'text/calendar')
97
+ end
98
+ end
99
+
100
+ [201, { 'content-type' => 'text/plain' }, ['']]
101
+ end
102
+
103
+ def split_vcalendar(body)
104
+ # Extract preamble (VCALENDAR properties before first component)
105
+ lines = body.gsub("\r\n", "\n").split("\n")
106
+ preamble = []
107
+ components = []
108
+ current = nil
109
+ depth = 0
110
+
111
+ lines.each do |line|
112
+ if line =~ /^BEGIN:(VEVENT|VTODO|VJOURNAL|VFREEBUSY)/i
113
+ current = [line]
114
+ depth = 1
115
+ elsif current
116
+ current << line
117
+ depth += 1 if line =~ /^BEGIN:/i
118
+ depth -= 1 if line =~ /^END:/i
119
+ if depth == 0
120
+ components << current
121
+ current = nil
122
+ end
123
+ else
124
+ preamble << line unless line =~ /^END:VCALENDAR/i
125
+ end
126
+ end
127
+
128
+ # Group by UID
129
+ grouped = {}
130
+ components.each do |comp_lines|
131
+ uid_line = comp_lines.find { |l| l =~ /^UID:/i }
132
+ uid = uid_line ? uid_line.sub(/^UID:/i, '').strip : SecureRandom.uuid
133
+ grouped[uid] ||= []
134
+ grouped[uid] << comp_lines
135
+ end
136
+
137
+ # If any components had no UID, ensure UID is injected
138
+ grouped.map do |uid, comp_groups|
139
+ comp_body_parts = comp_groups.map do |comp_lines|
140
+ unless comp_lines.any? { |l| l =~ /^UID:/i }
141
+ # Insert UID after first BEGIN line
142
+ comp_lines.insert(1, "UID:#{uid}")
143
+ end
144
+ comp_lines.join("\r\n")
145
+ end
146
+ cal_body = preamble.join("\r\n") + "\r\n" + comp_body_parts.join("\r\n") + "\r\nEND:VCALENDAR"
147
+ [uid, cal_body]
148
+ end
149
+ end
150
+
151
+ def split_vcards(body)
152
+ cards = body.scan(/BEGIN:VCARD.*?END:VCARD/mi)
153
+ cards.map do |card|
154
+ uid_match = card.match(/^UID:(.+)/i)
155
+ uid = uid_match ? uid_match[1].strip : SecureRandom.uuid
156
+ unless uid_match
157
+ # Inject UID
158
+ card = card.sub("BEGIN:VCARD\r\n", "BEGIN:VCARD\r\nUID:#{uid}\r\n")
159
+ end
160
+ [uid, card]
161
+ end
162
+ end
163
+
164
+ def resource_type_for(path)
165
+ if path.start_with?('/calendars/')
166
+ :calendar
167
+ elsif path.start_with?('/addressbooks/')
168
+ :addressbook
169
+ else
170
+ nil
171
+ end
172
+ end
173
+
174
+ def read_body(env)
175
+ input = env['rack.input']
176
+ return '' unless input
177
+ input.rewind if input.respond_to?(:rewind)
178
+ input.read || ''
179
+ end
180
+
181
+ def extract_headers(env)
182
+ h = {}
183
+ h['depth'] = env['HTTP_DEPTH'] if env['HTTP_DEPTH']
184
+ h['if-match'] = env['HTTP_IF_MATCH'] if env['HTTP_IF_MATCH']
185
+ h['if-none-match'] = env['HTTP_IF_NONE_MATCH'] if env['HTTP_IF_NONE_MATCH']
186
+ h['destination'] = env['HTTP_DESTINATION'] if env['HTTP_DESTINATION']
187
+ h['overwrite'] = env['HTTP_OVERWRITE'] if env['HTTP_OVERWRITE']
188
+ h['content-type'] = env['CONTENT_TYPE'] if env['CONTENT_TYPE']
189
+ h
190
+ end
191
+ end
192
+ end
193
+ end
194
+
195
+ require 'stringio'
196
+
197
+ test do
198
+ def make_server
199
+ s = Async::Caldav::Storage::Mock.new
200
+ [Async::Caldav::Server.new(storage: s), s]
201
+ end
202
+
203
+ def env(method, path, body: '', user: 'admin', headers: {})
204
+ e = {
205
+ 'REQUEST_METHOD' => method,
206
+ 'PATH_INFO' => path,
207
+ 'rack.input' => StringIO.new(body),
208
+ 'dav.user' => user
209
+ }
210
+ headers.each { |k, v| e["HTTP_#{k.upcase.tr('-', '_')}"] = v }
211
+ e
212
+ end
213
+
214
+ describe "Async::Caldav::Server" do
215
+ it "OPTIONS returns 200 without auth" do
216
+ server, = make_server
217
+ status, headers, = server.call(env('OPTIONS', '/calendars/admin/', user: nil))
218
+ status.should.equal 200
219
+ headers['dav'].should.include 'calendar-access'
220
+ end
221
+
222
+ it "returns 401 without auth for non-OPTIONS" do
223
+ server, = make_server
224
+ status, = server.call(env('PROPFIND', '/', user: nil))
225
+ status.should.equal 401
226
+ end
227
+
228
+ it "MKCALENDAR + PROPFIND round-trip" do
229
+ server, storage = make_server
230
+ status, = server.call(env('MKCALENDAR', '/calendars/admin/work/', body: '<d:displayname>Work</d:displayname>'))
231
+ status.should.equal 201
232
+
233
+ status, _, body = server.call(env('PROPFIND', '/calendars/admin/work/', headers: { 'Depth' => '0' }))
234
+ status.should.equal 207
235
+ body[0].should.include 'Work'
236
+ end
237
+
238
+ it "PUT + GET round-trip" do
239
+ server, storage = make_server
240
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
241
+
242
+ ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Test\r\nEND:VEVENT\r\nEND:VCALENDAR"
243
+ status, headers, = server.call(env('PUT', '/calendars/admin/cal/ev.ics', body: ical))
244
+ status.should.equal 201
245
+ headers['etag'].should.not.be.nil
246
+
247
+ status, _, body = server.call(env('GET', '/calendars/admin/cal/ev.ics'))
248
+ status.should.equal 200
249
+ body[0].should.include 'VEVENT'
250
+ end
251
+
252
+ it "PUT + DELETE + GET returns 404" do
253
+ server, storage = make_server
254
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
255
+
256
+ server.call(env('PUT', '/calendars/admin/cal/ev.ics', body: "BEGIN:VCALENDAR\r\nEND:VCALENDAR"))
257
+ server.call(env('DELETE', '/calendars/admin/cal/ev.ics'))
258
+ status, = server.call(env('GET', '/calendars/admin/cal/ev.ics'))
259
+ status.should.equal 404
260
+ end
261
+
262
+ it "PROPFIND on root returns discovery info" do
263
+ server, = make_server
264
+ status, _, body = server.call(env('PROPFIND', '/'))
265
+ status.should.equal 207
266
+ body[0].should.include 'current-user-principal'
267
+ body[0].should.include 'calendar-home-set'
268
+ end
269
+
270
+ it "PROPPATCH updates collection properties" do
271
+ server, storage = make_server
272
+ storage.create_collection('/calendars/admin/cal/', type: :calendar, displayname: 'Old')
273
+ status, = server.call(env('PROPPATCH', '/calendars/admin/cal/',
274
+ body: '<d:set><d:prop><d:displayname>New</d:displayname></d:prop></d:set>'))
275
+ status.should.equal 207
276
+ storage.get_collection('/calendars/admin/cal/')[:displayname].should.equal 'New'
277
+ end
278
+
279
+ it "REPORT returns filtered items" do
280
+ server, storage = make_server
281
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
282
+ storage.put_item('/calendars/admin/cal/ev.ics',
283
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
284
+ storage.put_item('/calendars/admin/cal/td.ics',
285
+ "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nSUMMARY:Task\r\nEND:VTODO\r\nEND:VCALENDAR", 'text/calendar')
286
+
287
+ filter_xml = <<~XML
288
+ <c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
289
+ <c:comp-filter name="VCALENDAR">
290
+ <c:comp-filter name="VEVENT"/>
291
+ </c:comp-filter>
292
+ </c:filter>
293
+ XML
294
+
295
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/', body: filter_xml))
296
+ status.should.equal 207
297
+ body[0].should.include 'ev.ics'
298
+ body[0].should.not.include 'td.ics'
299
+ end
300
+
301
+ it "MOVE relocates an item" do
302
+ server, storage = make_server
303
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
304
+ storage.put_item('/calendars/admin/cal/a.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
305
+
306
+ status, = server.call(env('MOVE', '/calendars/admin/cal/a.ics',
307
+ headers: { 'Destination' => 'http://localhost/calendars/admin/cal/b.ics' }))
308
+ status.should.equal 201
309
+ storage.get_item('/calendars/admin/cal/a.ics').should.be.nil
310
+ storage.get_item('/calendars/admin/cal/b.ics').should.not.be.nil
311
+ end
312
+
313
+ it "rejects path traversal" do
314
+ server, = make_server
315
+ status, = server.call(env('GET', '/calendars/../etc/passwd'))
316
+ status.should.equal 400
317
+ end
318
+
319
+ it "HEAD returns empty body" do
320
+ server, storage = make_server
321
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
322
+ storage.put_item('/calendars/admin/cal/ev.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
323
+
324
+ status, headers, body = server.call(env('HEAD', '/calendars/admin/cal/ev.ics'))
325
+ status.should.equal 200
326
+ headers['content-type'].should.equal 'text/calendar'
327
+ body.should.equal []
328
+ end
329
+
330
+ it "unknown method returns 405" do
331
+ server, = make_server
332
+ status, = server.call(env('PATCH', '/calendars/admin/cal/'))
333
+ status.should.equal 405
334
+ end
335
+
336
+ it "MKCOL creates addressbook collection" do
337
+ server, storage = make_server
338
+ storage.create_collection('/addressbooks/admin/')
339
+ status, = server.call(env('MKCOL', '/addressbooks/admin/contacts/',
340
+ body: '<resourcetype><addressbook/></resourcetype>'))
341
+ status.should.equal 201
342
+ storage.get_collection('/addressbooks/admin/contacts/')[:type].should.equal :addressbook
343
+ end
344
+
345
+ it "DELETE collection removes all items" do
346
+ server, storage = make_server
347
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
348
+ storage.put_item('/calendars/admin/cal/ev.ics', "BEGIN:VCALENDAR\r\nEND:VCALENDAR", 'text/calendar')
349
+
350
+ status, = server.call(env('DELETE', '/calendars/admin/cal/'))
351
+ status.should.equal 204
352
+
353
+ status, = server.call(env('GET', '/calendars/admin/cal/ev.ics'))
354
+ status.should.equal 404
355
+ end
356
+
357
+ it "PROPFIND / returns current-user-principal with user path" do
358
+ server, = make_server
359
+ status, _, body = server.call(env('PROPFIND', '/', headers: { 'Depth' => '0' }))
360
+ status.should.equal 207
361
+ body[0].should.include 'current-user-principal'
362
+ body[0].should.include '/admin/'
363
+ end
364
+
365
+ it "PROPFIND / returns calendar-home-set" do
366
+ server, = make_server
367
+ status, _, body = server.call(env('PROPFIND', '/', headers: { 'Depth' => '0' }))
368
+ status.should.equal 207
369
+ body[0].should.include 'calendar-home-set'
370
+ body[0].should.include '/calendars/admin/'
371
+ end
372
+
373
+ it "PROPFIND / returns addressbook-home-set" do
374
+ server, = make_server
375
+ status, _, body = server.call(env('PROPFIND', '/', headers: { 'Depth' => '0' }))
376
+ status.should.equal 207
377
+ body[0].should.include 'addressbook-home-set'
378
+ body[0].should.include '/addressbooks/admin/'
379
+ end
380
+
381
+ it "full discovery: current-user-principal -> calendar-home-set -> list calendars" do
382
+ server, = make_server
383
+ server.call(env('MKCALENDAR', '/calendars/admin/work/', body: '<d:displayname>Work</d:displayname>'))
384
+
385
+ status, _, body = server.call(env('PROPFIND', '/', headers: { 'Depth' => '0' }))
386
+ status.should.equal 207
387
+ body[0].should.include '/admin/'
388
+ body[0].should.include '/calendars/admin/'
389
+
390
+ status, _, body = server.call(env('PROPFIND', '/calendars/admin/', headers: { 'Depth' => '1' }))
391
+ status.should.equal 207
392
+ body[0].should.include 'Work'
393
+ body[0].should.include 'c:calendar'
394
+ end
395
+
396
+ it "full discovery: current-user-principal -> addressbook-home-set -> list addressbooks" do
397
+ server, storage = make_server
398
+ storage.create_collection('/addressbooks/admin/')
399
+ server.call(env('MKCOL', '/addressbooks/admin/contacts/', body: '<resourcetype><addressbook/></resourcetype><d:displayname>Contacts</d:displayname>'))
400
+
401
+ status, _, body = server.call(env('PROPFIND', '/', headers: { 'Depth' => '0' }))
402
+ status.should.equal 207
403
+ body[0].should.include '/addressbooks/admin/'
404
+
405
+ status, _, body = server.call(env('PROPFIND', '/addressbooks/admin/', headers: { 'Depth' => '1' }))
406
+ status.should.equal 207
407
+ body[0].should.include 'Contacts'
408
+ body[0].should.include 'cr:addressbook'
409
+ end
410
+
411
+ it "normalizes double slashes in path" do
412
+ server, = make_server
413
+ status, = server.call(env('PROPFIND', '//calendars//admin//', headers: { 'Depth' => '0' }))
414
+ [207, 301].should.include status
415
+ end
416
+
417
+ it "PUT returns ETag, If-Match with correct ETag updates, wrong ETag rejects" do
418
+ server, storage = make_server
419
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
420
+
421
+ ev = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:etag-test\r\nSUMMARY:V1\r\nEND:VEVENT\r\nEND:VCALENDAR"
422
+ status, headers, = server.call(env('PUT', '/calendars/admin/cal/ev.ics', body: ev))
423
+ status.should.equal 201
424
+ etag = headers['etag']
425
+ etag.should.not.be.nil
426
+
427
+ ev2 = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:etag-test\r\nSUMMARY:V2\r\nEND:VEVENT\r\nEND:VCALENDAR"
428
+ status, = server.call(env('PUT', '/calendars/admin/cal/ev.ics', body: ev2, headers: { 'If-Match' => '"wrong"' }))
429
+ status.should.equal 412
430
+
431
+ status, = server.call(env('PUT', '/calendars/admin/cal/ev.ics', body: ev2, headers: { 'If-Match' => etag }))
432
+ status.should.equal 204
433
+ end
434
+
435
+ it ".well-known/caldav returns useful response" do
436
+ server, = make_server
437
+ status, = server.call(env('PROPFIND', '/.well-known/caldav', headers: { 'Depth' => '0' }))
438
+ [207, 301].should.include status
439
+ end
440
+
441
+ it ".well-known/carddav returns useful response" do
442
+ server, = make_server
443
+ status, = server.call(env('PROPFIND', '/.well-known/carddav', headers: { 'Depth' => '0' }))
444
+ [207, 301].should.include status
445
+ end
446
+
447
+ it "MKCALENDAR on /addressbooks/ path creates a calendar there (no path guard)" do
448
+ server, storage = make_server
449
+ storage.create_collection('/addressbooks/admin/')
450
+ status, = server.call(env('MKCALENDAR', '/addressbooks/admin/misplaced/', body: '<d:displayname>Misplaced</d:displayname>'))
451
+ status.should.equal 201
452
+ end
453
+
454
+ it "MKCOL on /calendars/ path creates a collection there (no path guard)" do
455
+ server, storage = make_server
456
+ storage.create_collection('/calendars/admin/')
457
+ status, = server.call(env('MKCOL', '/calendars/admin/misplaced/', body: '<resourcetype><addressbook/></resourcetype>'))
458
+ status.should.equal 201
459
+ end
460
+
461
+ it "PUT contact then REPORT returns it with cr:address-data" do
462
+ server, storage = make_server
463
+ storage.create_collection('/addressbooks/admin/')
464
+ server.call(env('MKCOL', '/addressbooks/admin/addr/', body: '<resourcetype><addressbook/></resourcetype>'))
465
+
466
+ status, = server.call(env('PUT', '/addressbooks/admin/addr/c.vcf', body: "BEGIN:VCARD\r\nUID:c1\r\nFN:Alice\r\nEND:VCARD"))
467
+ status.should.equal 201
468
+
469
+ status, _, body = server.call(env('REPORT', '/addressbooks/admin/addr/'))
470
+ status.should.equal 207
471
+ body[0].should.include 'Alice'
472
+ body[0].should.include 'cr:address-data'
473
+ end
474
+
475
+ it "PUT VTODO and REPORT returns it" do
476
+ server, storage = make_server
477
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
478
+
479
+ todo = "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:todo-1\r\nSUMMARY:Buy groceries\r\nEND:VTODO\r\nEND:VCALENDAR"
480
+ status, = server.call(env('PUT', '/calendars/admin/cal/todo.ics', body: todo))
481
+ status.should.equal 201
482
+
483
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/'))
484
+ status.should.equal 207
485
+ body[0].should.include 'Buy groceries'
486
+ end
487
+
488
+ it "REPORT filters VTODO from VEVENT" do
489
+ server, storage = make_server
490
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
491
+
492
+ storage.put_item('/calendars/admin/cal/ev.ics',
493
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
494
+ storage.put_item('/calendars/admin/cal/td.ics',
495
+ "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nSUMMARY:Task\r\nEND:VTODO\r\nEND:VCALENDAR", 'text/calendar')
496
+
497
+ filter_xml = <<~XML
498
+ <c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
499
+ <c:comp-filter name="VCALENDAR">
500
+ <c:comp-filter name="VTODO"/>
501
+ </c:comp-filter>
502
+ </c:filter>
503
+ XML
504
+
505
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/', body: filter_xml))
506
+ status.should.equal 207
507
+ body[0].should.include 'td.ics'
508
+ body[0].should.not.include 'ev.ics'
509
+ end
510
+
511
+ it "MOVE between collections" do
512
+ server, storage = make_server
513
+ storage.create_collection('/calendars/admin/cal1/', type: :calendar)
514
+ storage.create_collection('/calendars/admin/cal2/', type: :calendar)
515
+ storage.put_item('/calendars/admin/cal1/ev.ics',
516
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:move-me\r\nSUMMARY:Cross\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
517
+
518
+ status, = server.call(env('MOVE', '/calendars/admin/cal1/ev.ics',
519
+ headers: { 'Destination' => 'http://localhost/calendars/admin/cal2/ev.ics' }))
520
+ status.should.equal 201
521
+
522
+ storage.get_item('/calendars/admin/cal1/ev.ics').should.be.nil
523
+ storage.get_item('/calendars/admin/cal2/ev.ics').should.not.be.nil
524
+ end
525
+
526
+ it "MOVE between collections rejects UID conflict" do
527
+ server, storage = make_server
528
+ storage.create_collection('/calendars/admin/cal1/', type: :calendar)
529
+ storage.create_collection('/calendars/admin/cal2/', type: :calendar)
530
+ storage.put_item('/calendars/admin/cal1/a.ics',
531
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:same-uid\r\nSUMMARY:A\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
532
+ storage.put_item('/calendars/admin/cal2/b.ics',
533
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:same-uid\r\nSUMMARY:B\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
534
+
535
+ status, = server.call(env('MOVE', '/calendars/admin/cal1/a.ics',
536
+ headers: { 'Destination' => 'http://localhost/calendars/admin/cal2/a.ics' }))
537
+ status.should.equal 409
538
+ end
539
+
540
+ it "REPORT with time-range filter on recurring event" do
541
+ server, storage = make_server
542
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
543
+ storage.put_item('/calendars/admin/cal/rec.ics',
544
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=30\r\nSUMMARY:Daily\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
545
+ storage.put_item('/calendars/admin/cal/one.ics',
546
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260301T090000Z\r\nDTEND:20260301T100000Z\r\nSUMMARY:March\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
547
+
548
+ filter_xml = <<~XML
549
+ <c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
550
+ <c:comp-filter name="VCALENDAR">
551
+ <c:comp-filter name="VEVENT">
552
+ <c:time-range start="20260115T000000Z" end="20260116T000000Z"/>
553
+ </c:comp-filter>
554
+ </c:comp-filter>
555
+ </c:filter>
556
+ XML
557
+
558
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/', body: filter_xml))
559
+ status.should.equal 207
560
+ body[0].should.include 'rec.ics'
561
+ body[0].should.not.include 'one.ics'
562
+ end
563
+
564
+ it "REPORT with param-filter matches ATTENDEE;PARTSTAT" do
565
+ server, storage = make_server
566
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
567
+ storage.put_item('/calendars/admin/cal/accepted.ics',
568
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nATTENDEE;PARTSTAT=ACCEPTED:mailto:alice@x.com\r\nSUMMARY:Yes\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
569
+ storage.put_item('/calendars/admin/cal/declined.ics',
570
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nATTENDEE;PARTSTAT=DECLINED:mailto:bob@x.com\r\nSUMMARY:No\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
571
+
572
+ filter_xml = <<~XML
573
+ <c:filter xmlns:c="urn:ietf:params:xml:ns:caldav">
574
+ <c:comp-filter name="VCALENDAR">
575
+ <c:comp-filter name="VEVENT">
576
+ <c:prop-filter name="ATTENDEE">
577
+ <c:param-filter name="PARTSTAT">
578
+ <c:text-match>ACCEPTED</c:text-match>
579
+ </c:param-filter>
580
+ </c:prop-filter>
581
+ </c:comp-filter>
582
+ </c:comp-filter>
583
+ </c:filter>
584
+ XML
585
+
586
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/', body: filter_xml))
587
+ status.should.equal 207
588
+ body[0].should.include 'accepted.ics'
589
+ body[0].should.not.include 'declined.ics'
590
+ end
591
+
592
+ it "PROPFIND propname returns property names only" do
593
+ server, storage = make_server
594
+ storage.create_collection('/calendars/admin/cal/', type: :calendar, displayname: 'Work')
595
+ status, _, body = server.call(env('PROPFIND', '/calendars/admin/cal/',
596
+ body: '<d:propfind xmlns:d="DAV:"><d:propname/></d:propfind>',
597
+ headers: { 'Depth' => '0' }))
598
+ status.should.equal 207
599
+ body[0].should.include '<d:resourcetype/>'
600
+ body[0].should.not.include 'Work'
601
+ end
602
+
603
+ it "MOVE with URL-encoded @ in destination" do
604
+ server, storage = make_server
605
+ storage.create_collection('/calendars/admin/cal%40dom/', type: :calendar)
606
+ storage.put_item('/calendars/admin/cal%40dom/ev.ics',
607
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:at-test\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
608
+
609
+ status, = server.call(env('MOVE', '/calendars/admin/cal%40dom/ev.ics',
610
+ headers: { 'Destination' => 'http://localhost/calendars/admin/cal%40dom/moved.ics' }))
611
+ status.should.equal 201
612
+ storage.get_item('/calendars/admin/cal%40dom/moved.ics').should.not.be.nil
613
+ end
614
+
615
+ it "sync-collection REPORT: initial sync returns all items" do
616
+ server, storage = make_server
617
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
618
+ storage.put_item('/calendars/admin/cal/ev.ics',
619
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:sync1\r\nSUMMARY:Test\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
620
+
621
+ report_body = <<~XML
622
+ <?xml version="1.0" encoding="utf-8" ?>
623
+ <d:sync-collection xmlns:d="DAV:">
624
+ <d:prop><d:getetag/></d:prop>
625
+ <d:sync-token/>
626
+ </d:sync-collection>
627
+ XML
628
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
629
+ status.should.equal 207
630
+ body[0].should.include 'ev.ics'
631
+ body[0].should.include 'd:sync-token'
632
+ end
633
+
634
+ it "sync-collection REPORT: no changes returns empty with same token" do
635
+ server, storage = make_server
636
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
637
+ storage.put_item('/calendars/admin/cal/ev.ics',
638
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:sync2\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
639
+
640
+ report_body = <<~XML
641
+ <?xml version="1.0" encoding="utf-8" ?>
642
+ <d:sync-collection xmlns:d="DAV:">
643
+ <d:prop><d:getetag/></d:prop>
644
+ <d:sync-token/>
645
+ </d:sync-collection>
646
+ XML
647
+ _, _, body1 = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
648
+ token = body1[0].match(/<d:sync-token>([^<]+)<\/d:sync-token>/)[1]
649
+
650
+ report_body2 = <<~XML
651
+ <?xml version="1.0" encoding="utf-8" ?>
652
+ <d:sync-collection xmlns:d="DAV:">
653
+ <d:prop><d:getetag/></d:prop>
654
+ <d:sync-token>#{token}</d:sync-token>
655
+ </d:sync-collection>
656
+ XML
657
+ status, _, body2 = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body2))
658
+ status.should.equal 207
659
+ body2[0].should.not.include 'ev.ics'
660
+ body2[0].should.include 'd:sync-token'
661
+ end
662
+
663
+ it "sync-collection REPORT: added item appears in diff" do
664
+ server, storage = make_server
665
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
666
+
667
+ report_body = <<~XML
668
+ <?xml version="1.0" encoding="utf-8" ?>
669
+ <d:sync-collection xmlns:d="DAV:">
670
+ <d:prop><d:getetag/></d:prop>
671
+ <d:sync-token/>
672
+ </d:sync-collection>
673
+ XML
674
+ _, _, body1 = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
675
+ token = body1[0].match(/<d:sync-token>([^<]+)<\/d:sync-token>/)[1]
676
+
677
+ storage.put_item('/calendars/admin/cal/new.ics',
678
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:new1\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
679
+
680
+ report_body2 = <<~XML
681
+ <?xml version="1.0" encoding="utf-8" ?>
682
+ <d:sync-collection xmlns:d="DAV:">
683
+ <d:prop><d:getetag/></d:prop>
684
+ <d:sync-token>#{token}</d:sync-token>
685
+ </d:sync-collection>
686
+ XML
687
+ status, _, body2 = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body2))
688
+ status.should.equal 207
689
+ body2[0].should.include 'new.ics'
690
+ end
691
+
692
+ it "sync-collection REPORT: deleted item returns 404 status" do
693
+ server, storage = make_server
694
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
695
+ storage.put_item('/calendars/admin/cal/del.ics',
696
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:del1\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
697
+
698
+ report_body = <<~XML
699
+ <?xml version="1.0" encoding="utf-8" ?>
700
+ <d:sync-collection xmlns:d="DAV:">
701
+ <d:prop><d:getetag/></d:prop>
702
+ <d:sync-token/>
703
+ </d:sync-collection>
704
+ XML
705
+ _, _, body1 = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
706
+ token = body1[0].match(/<d:sync-token>([^<]+)<\/d:sync-token>/)[1]
707
+
708
+ storage.delete_item('/calendars/admin/cal/del.ics')
709
+
710
+ report_body2 = <<~XML
711
+ <?xml version="1.0" encoding="utf-8" ?>
712
+ <d:sync-collection xmlns:d="DAV:">
713
+ <d:prop><d:getetag/></d:prop>
714
+ <d:sync-token>#{token}</d:sync-token>
715
+ </d:sync-collection>
716
+ XML
717
+ status, _, body2 = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body2))
718
+ status.should.equal 207
719
+ body2[0].should.include 'del.ics'
720
+ body2[0].should.include '404'
721
+ end
722
+
723
+ it "sync-collection REPORT: modified item appears in diff" do
724
+ server, storage = make_server
725
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
726
+ storage.put_item('/calendars/admin/cal/mod.ics',
727
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:mod1\r\nSUMMARY:V1\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
728
+
729
+ report_body = <<~XML
730
+ <?xml version="1.0" encoding="utf-8" ?>
731
+ <d:sync-collection xmlns:d="DAV:">
732
+ <d:prop><d:getetag/></d:prop>
733
+ <d:sync-token/>
734
+ </d:sync-collection>
735
+ XML
736
+ _, _, body1 = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
737
+ token = body1[0].match(/<d:sync-token>([^<]+)<\/d:sync-token>/)[1]
738
+
739
+ storage.put_item('/calendars/admin/cal/mod.ics',
740
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:mod1\r\nSUMMARY:V2\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
741
+
742
+ report_body2 = <<~XML
743
+ <?xml version="1.0" encoding="utf-8" ?>
744
+ <d:sync-collection xmlns:d="DAV:">
745
+ <d:prop><d:getetag/></d:prop>
746
+ <d:sync-token>#{token}</d:sync-token>
747
+ </d:sync-collection>
748
+ XML
749
+ status, _, body2 = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body2))
750
+ status.should.equal 207
751
+ body2[0].should.include 'mod.ics'
752
+ end
753
+
754
+ it "sync-collection REPORT: invalid token returns 403" do
755
+ server, storage = make_server
756
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
757
+
758
+ report_body = <<~XML
759
+ <?xml version="1.0" encoding="utf-8" ?>
760
+ <d:sync-collection xmlns:d="DAV:">
761
+ <d:prop><d:getetag/></d:prop>
762
+ <d:sync-token>http://caldav.local/sync/INVALID</d:sync-token>
763
+ </d:sync-collection>
764
+ XML
765
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
766
+ status.should.equal 403
767
+ body[0].should.include 'valid-sync-token'
768
+ end
769
+
770
+ it "PROPFIND sync-token changes after item add" do
771
+ server, storage = make_server
772
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
773
+
774
+ _, _, body1 = server.call(env('PROPFIND', '/calendars/admin/cal/', headers: { 'Depth' => '0' }))
775
+ token1 = body1[0].match(/<d:sync-token>([^<]+)<\/d:sync-token>/)[1]
776
+
777
+ storage.put_item('/calendars/admin/cal/ev.ics',
778
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:st1\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
779
+
780
+ _, _, body2 = server.call(env('PROPFIND', '/calendars/admin/cal/', headers: { 'Depth' => '0' }))
781
+ token2 = body2[0].match(/<d:sync-token>([^<]+)<\/d:sync-token>/)[1]
782
+
783
+ token1.should.not.equal token2
784
+ end
785
+
786
+ it "PROPFIND sync-token matches sync-collection initial token" do
787
+ server, storage = make_server
788
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
789
+ storage.put_item('/calendars/admin/cal/ev.ics',
790
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:match1\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
791
+
792
+ _, _, pf_body = server.call(env('PROPFIND', '/calendars/admin/cal/', headers: { 'Depth' => '0' }))
793
+ pf_token = pf_body[0].match(/<d:sync-token>([^<]+)<\/d:sync-token>/)[1]
794
+
795
+ report_body = <<~XML
796
+ <?xml version="1.0" encoding="utf-8" ?>
797
+ <d:sync-collection xmlns:d="DAV:">
798
+ <d:prop><d:getetag/></d:prop>
799
+ <d:sync-token/>
800
+ </d:sync-collection>
801
+ XML
802
+ _, _, sc_body = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
803
+ sc_token = sc_body[0].match(/<d:sync-token>([^<]+)<\/d:sync-token>/)[1]
804
+
805
+ pf_token.should.equal sc_token
806
+ end
807
+
808
+ it "sync-collection REPORT: move shows old path deleted and new path added" do
809
+ server, storage = make_server
810
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
811
+ storage.put_item('/calendars/admin/cal/ev1.ics',
812
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:mvs\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
813
+
814
+ report_body = <<~XML
815
+ <?xml version="1.0" encoding="utf-8" ?>
816
+ <d:sync-collection xmlns:d="DAV:">
817
+ <d:prop><d:getetag/></d:prop>
818
+ <d:sync-token/>
819
+ </d:sync-collection>
820
+ XML
821
+ _, _, body1 = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
822
+ token = body1[0].match(/<d:sync-token>([^<]+)<\/d:sync-token>/)[1]
823
+
824
+ server.call(env('MOVE', '/calendars/admin/cal/ev1.ics',
825
+ headers: { 'Destination' => 'http://localhost/calendars/admin/cal/ev2.ics' }))
826
+
827
+ report_body2 = <<~XML
828
+ <?xml version="1.0" encoding="utf-8" ?>
829
+ <d:sync-collection xmlns:d="DAV:">
830
+ <d:prop><d:getetag/></d:prop>
831
+ <d:sync-token>#{token}</d:sync-token>
832
+ </d:sync-collection>
833
+ XML
834
+ status, _, body2 = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body2))
835
+ status.should.equal 207
836
+ body2[0].should.include 'ev1.ics'
837
+ body2[0].should.include 'ev2.ics'
838
+ body2[0].should.include '404'
839
+ end
840
+
841
+ it "sync-collection REPORT: create and delete item shows 404" do
842
+ server, storage = make_server
843
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
844
+
845
+ report_body = <<~XML
846
+ <?xml version="1.0" encoding="utf-8" ?>
847
+ <d:sync-collection xmlns:d="DAV:">
848
+ <d:prop><d:getetag/></d:prop>
849
+ <d:sync-token/>
850
+ </d:sync-collection>
851
+ XML
852
+ _, _, body1 = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
853
+ token = body1[0].match(/<d:sync-token>([^<]+)<\/d:sync-token>/)[1]
854
+
855
+ storage.put_item('/calendars/admin/cal/tmp.ics',
856
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:tmp1\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
857
+ storage.delete_item('/calendars/admin/cal/tmp.ics')
858
+
859
+ report_body2 = <<~XML
860
+ <?xml version="1.0" encoding="utf-8" ?>
861
+ <d:sync-collection xmlns:d="DAV:">
862
+ <d:prop><d:getetag/></d:prop>
863
+ <d:sync-token>#{token}</d:sync-token>
864
+ </d:sync-collection>
865
+ XML
866
+ status, _, body2 = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body2))
867
+ status.should.equal 207
868
+ # Empty collection, same as initial empty — no items reported
869
+ body2[0].should.not.include 'tmp.ics'
870
+ end
871
+
872
+ it "PUT whole calendar splits into individual items" do
873
+ server, storage = make_server
874
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
875
+
876
+ whole_cal = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:ev1\r\nSUMMARY:Event 1\r\nEND:VEVENT\r\nBEGIN:VTODO\r\nUID:td1\r\nSUMMARY:Todo 1\r\nEND:VTODO\r\nEND:VCALENDAR"
877
+ status, = server.call(env('PUT', '/calendars/admin/cal/', body: whole_cal))
878
+ status.should.equal 201
879
+
880
+ items = storage.list_items('/calendars/admin/cal/')
881
+ items.length.should.equal 2
882
+ end
883
+
884
+ it "PUT whole calendar overwrites existing items" do
885
+ server, storage = make_server
886
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
887
+ storage.put_item('/calendars/admin/cal/old.ics',
888
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:old\r\nSUMMARY:Old\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
889
+
890
+ whole_cal = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:new1\r\nSUMMARY:New1\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:new2\r\nSUMMARY:New2\r\nEND:VEVENT\r\nEND:VCALENDAR"
891
+ status, = server.call(env('PUT', '/calendars/admin/cal/', body: whole_cal))
892
+ status.should.equal 201
893
+
894
+ storage.get_item('/calendars/admin/cal/old.ics').should.be.nil
895
+ items = storage.list_items('/calendars/admin/cal/')
896
+ items.length.should.equal 2
897
+ end
898
+
899
+ it "PUT whole calendar generates UIDs for items without them" do
900
+ server, storage = make_server
901
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
902
+
903
+ whole_cal = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:No UID\r\nEND:VEVENT\r\nEND:VCALENDAR"
904
+ status, = server.call(env('PUT', '/calendars/admin/cal/', body: whole_cal))
905
+ status.should.equal 201
906
+
907
+ items = storage.list_items('/calendars/admin/cal/')
908
+ items.length.should.equal 1
909
+ items[0][1][:body].should.include 'UID:'
910
+ end
911
+
912
+ it "PUT whole addressbook splits vcards into individual items" do
913
+ server, storage = make_server
914
+ storage.create_collection('/addressbooks/admin/')
915
+ server.call(env('MKCOL', '/addressbooks/admin/addr/', body: '<resourcetype><addressbook/></resourcetype>'))
916
+
917
+ whole_ab = "BEGIN:VCARD\r\nUID:c1\r\nFN:Alice\r\nEND:VCARD\r\nBEGIN:VCARD\r\nUID:c2\r\nFN:Bob\r\nEND:VCARD"
918
+ status, = server.call(env('PUT', '/addressbooks/admin/addr/', body: whole_ab))
919
+ status.should.equal 201
920
+
921
+ items = storage.list_items('/addressbooks/admin/addr/')
922
+ items.length.should.equal 2
923
+ end
924
+
925
+ it "PUT whole calendar: multiple events with same UID go to one item" do
926
+ server, storage = make_server
927
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
928
+
929
+ whole_cal = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:recurring\r\nSUMMARY:Main\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:recurring\r\nRECURRENCE-ID:20260102T090000Z\r\nSUMMARY:Override\r\nEND:VEVENT\r\nEND:VCALENDAR"
930
+ status, = server.call(env('PUT', '/calendars/admin/cal/', body: whole_cal))
931
+ status.should.equal 201
932
+
933
+ items = storage.list_items('/calendars/admin/cal/')
934
+ items.length.should.equal 1
935
+ items[0][1][:body].scan('BEGIN:VEVENT').length.should.equal 2
936
+ end
937
+
938
+ it "REPORT with expand-property returns expanded occurrences" do
939
+ server, storage = make_server
940
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
941
+ storage.put_item('/calendars/admin/cal/rec.ics',
942
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=5\r\nUID:expand-int\r\nSUMMARY:Daily\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
943
+
944
+ report_body = <<~XML
945
+ <c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
946
+ <d:prop>
947
+ <c:calendar-data>
948
+ <c:expand start="20260103T000000Z" end="20260105T000000Z"/>
949
+ </c:calendar-data>
950
+ </d:prop>
951
+ <c:filter>
952
+ <c:comp-filter name="VCALENDAR">
953
+ <c:comp-filter name="VEVENT">
954
+ <c:time-range start="20260103T000000Z" end="20260105T000000Z"/>
955
+ </c:comp-filter>
956
+ </c:comp-filter>
957
+ </c:filter>
958
+ </c:calendar-query>
959
+ XML
960
+
961
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
962
+ status.should.equal 207
963
+ body[0].should.include 'RECURRENCE-ID'
964
+ body[0].should.not.include 'RRULE'
965
+ end
966
+
967
+ it "REPORT with expand on non-recurring event returns it unchanged" do
968
+ server, storage = make_server
969
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
970
+ storage.put_item('/calendars/admin/cal/single.ics',
971
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260103T090000Z\r\nDTEND:20260103T100000Z\r\nUID:single-expand\r\nSUMMARY:Once\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
972
+
973
+ report_body = <<~XML
974
+ <c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
975
+ <d:prop>
976
+ <c:calendar-data>
977
+ <c:expand start="20260101T000000Z" end="20260201T000000Z"/>
978
+ </c:calendar-data>
979
+ </d:prop>
980
+ <c:filter>
981
+ <c:comp-filter name="VCALENDAR">
982
+ <c:comp-filter name="VEVENT"/>
983
+ </c:comp-filter>
984
+ </c:filter>
985
+ </c:calendar-query>
986
+ XML
987
+
988
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
989
+ status.should.equal 207
990
+ body[0].should.include 'SUMMARY:Once'
991
+ body[0].should.not.include 'RECURRENCE-ID'
992
+ end
993
+
994
+ it "REPORT with expand on event with override includes override" do
995
+ server, storage = make_server
996
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
997
+ ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nUID:ovr\r\nSUMMARY:Base\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nDTSTART:20260102T140000Z\r\nDTEND:20260102T150000Z\r\nRECURRENCE-ID:20260102T090000Z\r\nUID:ovr\r\nSUMMARY:Override\r\nEND:VEVENT\r\nEND:VCALENDAR"
998
+ storage.put_item('/calendars/admin/cal/ovr.ics', ical, 'text/calendar')
999
+
1000
+ report_body = <<~XML
1001
+ <c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
1002
+ <d:prop>
1003
+ <c:calendar-data>
1004
+ <c:expand start="20260101T000000Z" end="20260104T000000Z"/>
1005
+ </c:calendar-data>
1006
+ </d:prop>
1007
+ <c:filter>
1008
+ <c:comp-filter name="VCALENDAR">
1009
+ <c:comp-filter name="VEVENT"/>
1010
+ </c:comp-filter>
1011
+ </c:filter>
1012
+ </c:calendar-query>
1013
+ XML
1014
+
1015
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
1016
+ status.should.equal 207
1017
+ body[0].should.include 'SUMMARY:Override'
1018
+ body[0].should.include 'SUMMARY:Base'
1019
+ end
1020
+
1021
+ it "REPORT with expand + time-range returns only matching expanded events" do
1022
+ server, storage = make_server
1023
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
1024
+ storage.put_item('/calendars/admin/cal/rec.ics',
1025
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=30\r\nUID:tr-expand\r\nSUMMARY:Daily\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
1026
+ storage.put_item('/calendars/admin/cal/far.ics',
1027
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260601T090000Z\r\nDTEND:20260601T100000Z\r\nUID:far-away\r\nSUMMARY:Far\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
1028
+
1029
+ report_body = <<~XML
1030
+ <c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
1031
+ <d:prop>
1032
+ <c:calendar-data>
1033
+ <c:expand start="20260110T000000Z" end="20260112T000000Z"/>
1034
+ </c:calendar-data>
1035
+ </d:prop>
1036
+ <c:filter>
1037
+ <c:comp-filter name="VCALENDAR">
1038
+ <c:comp-filter name="VEVENT">
1039
+ <c:time-range start="20260110T000000Z" end="20260112T000000Z"/>
1040
+ </c:comp-filter>
1041
+ </c:comp-filter>
1042
+ </c:filter>
1043
+ </c:calendar-query>
1044
+ XML
1045
+
1046
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
1047
+ status.should.equal 207
1048
+ body[0].should.include 'rec.ics'
1049
+ body[0].should.not.include 'far.ics'
1050
+ body[0].should.include 'RECURRENCE-ID'
1051
+ end
1052
+
1053
+ it "REPORT with expand respects EXDATE" do
1054
+ server, storage = make_server
1055
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
1056
+ storage.put_item('/calendars/admin/cal/ex.ics',
1057
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEXDATE:20260102T090000Z\r\nUID:exd\r\nSUMMARY:Skip2\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
1058
+
1059
+ report_body = <<~XML
1060
+ <c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
1061
+ <d:prop>
1062
+ <c:calendar-data>
1063
+ <c:expand start="20260101T000000Z" end="20260106T000000Z"/>
1064
+ </c:calendar-data>
1065
+ </d:prop>
1066
+ <c:filter>
1067
+ <c:comp-filter name="VCALENDAR">
1068
+ <c:comp-filter name="VEVENT"/>
1069
+ </c:comp-filter>
1070
+ </c:filter>
1071
+ </c:calendar-query>
1072
+ XML
1073
+
1074
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
1075
+ status.should.equal 207
1076
+ # 5 occurrences - 1 EXDATE = 4
1077
+ body[0].scan('RECURRENCE-ID').length.should.equal 4
1078
+ end
1079
+
1080
+ it "REPORT with expand returns empty when range misses all occurrences" do
1081
+ server, storage = make_server
1082
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
1083
+ storage.put_item('/calendars/admin/cal/rec.ics',
1084
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nUID:miss\r\nSUMMARY:Miss\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
1085
+
1086
+ report_body = <<~XML
1087
+ <c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
1088
+ <d:prop>
1089
+ <c:calendar-data>
1090
+ <c:expand start="20260601T000000Z" end="20260701T000000Z"/>
1091
+ </c:calendar-data>
1092
+ </d:prop>
1093
+ <c:filter>
1094
+ <c:comp-filter name="VCALENDAR">
1095
+ <c:comp-filter name="VEVENT">
1096
+ <c:time-range start="20260601T000000Z" end="20260701T000000Z"/>
1097
+ </c:comp-filter>
1098
+ </c:comp-filter>
1099
+ </c:filter>
1100
+ </c:calendar-query>
1101
+ XML
1102
+
1103
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
1104
+ status.should.equal 207
1105
+ body[0].should.not.include 'rec.ics'
1106
+ end
1107
+
1108
+ it "REPORT with expand on mixed recurring + non-recurring returns both" do
1109
+ server, storage = make_server
1110
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
1111
+ storage.put_item('/calendars/admin/cal/rec.ics',
1112
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260101T090000Z\r\nDTEND:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=30\r\nUID:mixed-rec\r\nSUMMARY:Recurring\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
1113
+ storage.put_item('/calendars/admin/cal/once.ics',
1114
+ "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260110T120000Z\r\nDTEND:20260110T130000Z\r\nUID:mixed-once\r\nSUMMARY:Single\r\nEND:VEVENT\r\nEND:VCALENDAR", 'text/calendar')
1115
+
1116
+ report_body = <<~XML
1117
+ <c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
1118
+ <d:prop>
1119
+ <c:calendar-data>
1120
+ <c:expand start="20260110T000000Z" end="20260111T000000Z"/>
1121
+ </c:calendar-data>
1122
+ </d:prop>
1123
+ <c:filter>
1124
+ <c:comp-filter name="VCALENDAR">
1125
+ <c:comp-filter name="VEVENT">
1126
+ <c:time-range start="20260110T000000Z" end="20260111T000000Z"/>
1127
+ </c:comp-filter>
1128
+ </c:comp-filter>
1129
+ </c:filter>
1130
+ </c:calendar-query>
1131
+ XML
1132
+
1133
+ status, _, body = server.call(env('REPORT', '/calendars/admin/cal/', body: report_body))
1134
+ status.should.equal 207
1135
+ body[0].should.include 'rec.ics'
1136
+ body[0].should.include 'once.ics'
1137
+ end
1138
+
1139
+ it "ETag round-trip: GET returns etag, conditional GET returns 304" do
1140
+ server, storage = make_server
1141
+ storage.create_collection('/calendars/admin/cal/', type: :calendar)
1142
+ server.call(env('PUT', '/calendars/admin/cal/ev.ics', body: "BEGIN:VCALENDAR\r\nEND:VCALENDAR"))
1143
+
1144
+ _, headers, = server.call(env('GET', '/calendars/admin/cal/ev.ics'))
1145
+ etag = headers['etag']
1146
+ etag.should.not.be.nil
1147
+
1148
+ status, = server.call(env('GET', '/calendars/admin/cal/ev.ics', headers: { 'If-None-Match' => etag }))
1149
+ status.should.equal 304
1150
+ end
1151
+ end
1152
+ end