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