async-caldav 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/lib/async/caldav/client/addressbook.rb +117 -0
- data/lib/async/caldav/client/calendar.rb +152 -0
- data/lib/async/caldav/client.rb +580 -0
- data/lib/async/caldav/forward_auth.rb +94 -0
- data/lib/async/caldav/handlers/delete.rb +60 -0
- data/lib/async/caldav/handlers/get.rb +87 -0
- data/lib/async/caldav/handlers/head.rb +36 -0
- data/lib/async/caldav/handlers/mkcol.rb +95 -0
- data/lib/async/caldav/handlers/move.rb +126 -0
- data/lib/async/caldav/handlers/options.rb +34 -0
- data/lib/async/caldav/handlers/propfind.rb +201 -0
- data/lib/async/caldav/handlers/proppatch.rb +121 -0
- data/lib/async/caldav/handlers/put.rb +163 -0
- data/lib/async/caldav/handlers/report.rb +257 -0
- data/lib/async/caldav/server.rb +1152 -0
- data/lib/async/caldav/storage/filesystem.rb +375 -0
- data/lib/async/caldav/storage/mock.rb +402 -0
- data/lib/async/caldav/version.rb +7 -0
- data/lib/async/caldav.rb +24 -0
- metadata +90 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
require "async/caldav"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "uri"
|
|
8
|
+
require "rexml/document"
|
|
9
|
+
|
|
10
|
+
require_relative "client/calendar"
|
|
11
|
+
require_relative "client/addressbook"
|
|
12
|
+
|
|
13
|
+
module Async
|
|
14
|
+
module Caldav
|
|
15
|
+
class Client
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
class NotFound < Error; end
|
|
18
|
+
class PreconditionFailed < Error; end
|
|
19
|
+
class Conflict < Error; end
|
|
20
|
+
class Unauthorized < Error; end
|
|
21
|
+
class InvalidSyncToken < Error; end
|
|
22
|
+
|
|
23
|
+
attr_reader :user
|
|
24
|
+
|
|
25
|
+
def initialize(base_url, user:, password: nil, headers: {})
|
|
26
|
+
@uri = URI.parse(base_url)
|
|
27
|
+
@user = user
|
|
28
|
+
@password = password
|
|
29
|
+
@extra_headers = headers
|
|
30
|
+
@http = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.open(base_url, **opts)
|
|
34
|
+
client = new(base_url, **opts)
|
|
35
|
+
return client unless block_given?
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
yield client
|
|
39
|
+
ensure
|
|
40
|
+
client.close
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def close
|
|
45
|
+
@http&.finish if @http&.started?
|
|
46
|
+
@http = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# --- Discovery ---
|
|
50
|
+
|
|
51
|
+
def principal
|
|
52
|
+
_, _, body = request('PROPFIND', '/', headers: { 'Depth' => '0' })
|
|
53
|
+
match = body.match(/<[^>]*current-user-principal[^>]*>\s*<[^>]*href[^>]*>([^<]+)</)
|
|
54
|
+
match ? match[1].strip : "/#{@user}/"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def calendars
|
|
58
|
+
path = "/calendars/#{@user}/"
|
|
59
|
+
status, _, body = request('PROPFIND', path, headers: { 'Depth' => '1' })
|
|
60
|
+
raise Error, "PROPFIND failed: #{status}" unless status == 207
|
|
61
|
+
|
|
62
|
+
parse_collections(body, path, type: :calendar)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def addressbooks
|
|
66
|
+
path = "/addressbooks/#{@user}/"
|
|
67
|
+
status, _, body = request('PROPFIND', path, headers: { 'Depth' => '1' })
|
|
68
|
+
raise Error, "PROPFIND failed: #{status}" unless status == 207
|
|
69
|
+
|
|
70
|
+
parse_collections(body, path, type: :addressbook)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def calendar(name)
|
|
74
|
+
Calendar.new(self, "/calendars/#{@user}/#{name}/")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def addressbook(name)
|
|
78
|
+
Addressbook.new(self, "/addressbooks/#{@user}/#{name}/")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# --- Create ---
|
|
82
|
+
|
|
83
|
+
def create_calendar(name, displayname: nil, description: nil, color: nil)
|
|
84
|
+
path = "/calendars/#{@user}/#{name}/"
|
|
85
|
+
props = []
|
|
86
|
+
props << "<d:displayname>#{Protocol::Caldav::Xml.escape(displayname || name)}</d:displayname>" if displayname || name
|
|
87
|
+
props << "<c:calendar-description>#{Protocol::Caldav::Xml.escape(description)}</c:calendar-description>" if description
|
|
88
|
+
|
|
89
|
+
body = <<~XML
|
|
90
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
91
|
+
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
92
|
+
<d:set><d:prop>#{props.join}</d:prop></d:set>
|
|
93
|
+
</c:mkcalendar>
|
|
94
|
+
XML
|
|
95
|
+
|
|
96
|
+
status, = request('MKCALENDAR', path, body: body, headers: { 'Content-Type' => 'text/xml' })
|
|
97
|
+
raise Error, "MKCALENDAR failed: #{status}" unless status == 201
|
|
98
|
+
|
|
99
|
+
Calendar.new(self, path, displayname: displayname || name, description: description, color: color)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def create_addressbook(name, displayname: nil)
|
|
103
|
+
path = "/addressbooks/#{@user}/#{name}/"
|
|
104
|
+
body = <<~XML
|
|
105
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
106
|
+
<d:mkcol xmlns:d="DAV:" xmlns:cr="urn:ietf:params:xml:ns:carddav">
|
|
107
|
+
<d:set><d:prop>
|
|
108
|
+
<d:resourcetype><d:collection/><cr:addressbook/></d:resourcetype>
|
|
109
|
+
<d:displayname>#{Protocol::Caldav::Xml.escape(displayname || name)}</d:displayname>
|
|
110
|
+
</d:prop></d:set>
|
|
111
|
+
</d:mkcol>
|
|
112
|
+
XML
|
|
113
|
+
|
|
114
|
+
status, = request('MKCOL', path, body: body, headers: { 'Content-Type' => 'text/xml' })
|
|
115
|
+
raise Error, "MKCOL failed: #{status}" unless status == 201
|
|
116
|
+
|
|
117
|
+
Addressbook.new(self, path, displayname: displayname || name)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# --- HTTP transport ---
|
|
121
|
+
|
|
122
|
+
def request(method, path, body: nil, headers: {})
|
|
123
|
+
http = connect
|
|
124
|
+
req = build_request(method, path, body, headers)
|
|
125
|
+
response = http.request(req)
|
|
126
|
+
|
|
127
|
+
status = response.code.to_i
|
|
128
|
+
resp_headers = {}
|
|
129
|
+
response.each_header { |k, v| resp_headers[k] = v }
|
|
130
|
+
|
|
131
|
+
raise Unauthorized, "401 Unauthorized" if status == 401
|
|
132
|
+
|
|
133
|
+
[status, resp_headers, response.body || '']
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# --- Response parsing (used by Calendar/Addressbook) ---
|
|
137
|
+
|
|
138
|
+
def parse_multistatus_items(xml, data_tag:)
|
|
139
|
+
items = []
|
|
140
|
+
xml.scan(/<[^>]*response[^>]*>(.*?)<\/[^>]*response>/m).each do |match|
|
|
141
|
+
resp = match[0]
|
|
142
|
+
href = resp.match(/<[^>]*href[^>]*>([^<]+)</)[1]&.strip rescue nil
|
|
143
|
+
next unless href
|
|
144
|
+
|
|
145
|
+
etag = resp.match(/<[^>]*getetag[^>]*>([^<]+)</)[1]&.strip rescue nil
|
|
146
|
+
data = resp.match(/<[^>]*#{data_tag}[^>]*>(.*?)<\/[^>]*#{data_tag}>/m)
|
|
147
|
+
body = data ? data[1].strip : nil
|
|
148
|
+
|
|
149
|
+
# Unescape XML entities in the body
|
|
150
|
+
if body
|
|
151
|
+
body = body.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"')
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
items << { path: href, body: body, etag: etag }
|
|
155
|
+
end
|
|
156
|
+
items
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def parse_sync_items(xml)
|
|
160
|
+
items = []
|
|
161
|
+
xml.scan(/<[^>]*response[^>]*>(.*?)<\/[^>]*response>/m).each do |match|
|
|
162
|
+
resp = match[0]
|
|
163
|
+
href = resp.match(/<[^>]*href[^>]*>([^<]+)</)[1]&.strip rescue nil
|
|
164
|
+
next unless href
|
|
165
|
+
|
|
166
|
+
if resp.include?('404')
|
|
167
|
+
items << { path: href, status: 404 }
|
|
168
|
+
else
|
|
169
|
+
etag = resp.match(/<[^>]*getetag[^>]*>([^<]+)</)[1]&.strip rescue nil
|
|
170
|
+
items << { path: href, status: 200, etag: etag }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
items
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def parse_collection_props(xml)
|
|
177
|
+
props = {}
|
|
178
|
+
props[:displayname] = Protocol::Caldav::Xml.extract_value(xml, 'displayname')
|
|
179
|
+
props[:description] = Protocol::Caldav::Xml.extract_value(xml, 'calendar-description')
|
|
180
|
+
props[:color] = Protocol::Caldav::Xml.extract_value(xml, 'calendar-color')
|
|
181
|
+
ctag = Protocol::Caldav::Xml.extract_value(xml, 'getctag')
|
|
182
|
+
props[:ctag] = ctag if ctag
|
|
183
|
+
props
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
def connect
|
|
189
|
+
return @http if @http&.started?
|
|
190
|
+
|
|
191
|
+
@http = Net::HTTP.new(@uri.host, @uri.port)
|
|
192
|
+
@http.use_ssl = (@uri.scheme == 'https')
|
|
193
|
+
@http.start
|
|
194
|
+
@http
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def build_request(method, path, body, headers)
|
|
198
|
+
klass = case method
|
|
199
|
+
when 'GET' then Net::HTTP::Get
|
|
200
|
+
when 'PUT' then Net::HTTP::Put
|
|
201
|
+
when 'DELETE' then Net::HTTP::Delete
|
|
202
|
+
when 'HEAD' then Net::HTTP::Head
|
|
203
|
+
when 'OPTIONS' then Net::HTTP::Options
|
|
204
|
+
when 'PROPFIND' then propfind_class
|
|
205
|
+
when 'PROPPATCH' then proppatch_class
|
|
206
|
+
when 'MKCOL' then mkcol_class
|
|
207
|
+
when 'MKCALENDAR' then mkcalendar_class
|
|
208
|
+
when 'REPORT' then report_class
|
|
209
|
+
when 'MOVE' then move_class
|
|
210
|
+
else raise Error, "Unknown method: #{method}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
req = klass.new(path)
|
|
214
|
+
req.body = body if body
|
|
215
|
+
|
|
216
|
+
# Auth
|
|
217
|
+
if @password
|
|
218
|
+
req.basic_auth(@user, @password)
|
|
219
|
+
else
|
|
220
|
+
req['Remote-User'] = @user
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Default + extra + per-request headers
|
|
224
|
+
@extra_headers.each { |k, v| req[k] = v }
|
|
225
|
+
headers.each { |k, v| req[k] = v }
|
|
226
|
+
|
|
227
|
+
req
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def parse_collections(xml, parent_path, type:)
|
|
231
|
+
collections = []
|
|
232
|
+
xml.scan(/<[^>]*response[^>]*>(.*?)<\/[^>]*response>/m).each do |match|
|
|
233
|
+
resp = match[0]
|
|
234
|
+
href = resp.match(/<[^>]*href[^>]*>([^<]+)</)[1]&.strip rescue nil
|
|
235
|
+
next unless href
|
|
236
|
+
next if href == parent_path # skip the parent itself
|
|
237
|
+
|
|
238
|
+
# Check resource type
|
|
239
|
+
is_calendar = resp.include?('calendar/')
|
|
240
|
+
is_addressbook = resp.include?('addressbook/')
|
|
241
|
+
|
|
242
|
+
if type == :calendar && is_calendar
|
|
243
|
+
displayname = Protocol::Caldav::Xml.extract_value(resp, 'displayname')
|
|
244
|
+
description = Protocol::Caldav::Xml.extract_value(resp, 'calendar-description')
|
|
245
|
+
color = Protocol::Caldav::Xml.extract_value(resp, 'calendar-color')
|
|
246
|
+
collections << Calendar.new(self, href, displayname: displayname, description: description, color: color)
|
|
247
|
+
elsif type == :addressbook && is_addressbook
|
|
248
|
+
displayname = Protocol::Caldav::Xml.extract_value(resp, 'displayname')
|
|
249
|
+
collections << Addressbook.new(self, href, displayname: displayname)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
collections
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Custom HTTP method classes for WebDAV
|
|
256
|
+
def propfind_class
|
|
257
|
+
@propfind_class ||= Class.new(Net::HTTPRequest) do
|
|
258
|
+
const_set(:METHOD, 'PROPFIND')
|
|
259
|
+
const_set(:REQUEST_HAS_BODY, true)
|
|
260
|
+
const_set(:RESPONSE_HAS_BODY, true)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def proppatch_class
|
|
265
|
+
@proppatch_class ||= Class.new(Net::HTTPRequest) do
|
|
266
|
+
const_set(:METHOD, 'PROPPATCH')
|
|
267
|
+
const_set(:REQUEST_HAS_BODY, true)
|
|
268
|
+
const_set(:RESPONSE_HAS_BODY, true)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def mkcol_class
|
|
273
|
+
@mkcol_class ||= Class.new(Net::HTTPRequest) do
|
|
274
|
+
const_set(:METHOD, 'MKCOL')
|
|
275
|
+
const_set(:REQUEST_HAS_BODY, true)
|
|
276
|
+
const_set(:RESPONSE_HAS_BODY, true)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def mkcalendar_class
|
|
281
|
+
@mkcalendar_class ||= Class.new(Net::HTTPRequest) do
|
|
282
|
+
const_set(:METHOD, 'MKCALENDAR')
|
|
283
|
+
const_set(:REQUEST_HAS_BODY, true)
|
|
284
|
+
const_set(:RESPONSE_HAS_BODY, true)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def report_class
|
|
289
|
+
@report_class ||= Class.new(Net::HTTPRequest) do
|
|
290
|
+
const_set(:METHOD, 'REPORT')
|
|
291
|
+
const_set(:REQUEST_HAS_BODY, true)
|
|
292
|
+
const_set(:RESPONSE_HAS_BODY, true)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def move_class
|
|
297
|
+
@move_class ||= Class.new(Net::HTTPRequest) do
|
|
298
|
+
const_set(:METHOD, 'MOVE')
|
|
299
|
+
const_set(:REQUEST_HAS_BODY, false)
|
|
300
|
+
const_set(:RESPONSE_HAS_BODY, true)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
test do
|
|
309
|
+
# Mock transport that routes requests through the Server directly
|
|
310
|
+
class MockTransport
|
|
311
|
+
def initialize
|
|
312
|
+
@storage = Async::Caldav::Storage::Mock.new
|
|
313
|
+
@server = Async::Caldav::Server.new(storage: @storage)
|
|
314
|
+
# Pre-create parent collections
|
|
315
|
+
%w[/calendars/ /calendars/admin/ /addressbooks/ /addressbooks/admin/].each do |p|
|
|
316
|
+
@storage.create_collection(p)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
attr_reader :storage
|
|
321
|
+
|
|
322
|
+
def request(method, path, body, headers, user)
|
|
323
|
+
env = {
|
|
324
|
+
'REQUEST_METHOD' => method,
|
|
325
|
+
'PATH_INFO' => path,
|
|
326
|
+
'rack.input' => StringIO.new(body || ''),
|
|
327
|
+
'dav.user' => user
|
|
328
|
+
}
|
|
329
|
+
headers.each do |k, v|
|
|
330
|
+
rack_key = "HTTP_#{k.upcase.tr('-', '_')}"
|
|
331
|
+
env[rack_key] = v
|
|
332
|
+
end
|
|
333
|
+
env['CONTENT_TYPE'] = headers['Content-Type'] if headers['Content-Type']
|
|
334
|
+
@server.call(env)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Client subclass that uses MockTransport instead of Net::HTTP
|
|
339
|
+
class TestClient < Async::Caldav::Client
|
|
340
|
+
def initialize(transport, user:)
|
|
341
|
+
@transport = transport
|
|
342
|
+
@user = user
|
|
343
|
+
@extra_headers = {}
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def request(method, path, body: nil, headers: {})
|
|
347
|
+
status, resp_headers, resp_body = @transport.request(method, path, body, headers, @user)
|
|
348
|
+
body_str = resp_body.is_a?(Array) ? resp_body.join : resp_body
|
|
349
|
+
[status, resp_headers, body_str]
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def close; end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
require 'stringio'
|
|
356
|
+
|
|
357
|
+
describe "Async::Caldav::Client" do
|
|
358
|
+
def make_client
|
|
359
|
+
transport = MockTransport.new
|
|
360
|
+
[TestClient.new(transport, user: 'admin'), transport]
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
it "principal returns user path" do
|
|
364
|
+
client, = make_client
|
|
365
|
+
client.principal.should.include '/admin/'
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
it "create_calendar returns Calendar" do
|
|
369
|
+
client, = make_client
|
|
370
|
+
cal = client.create_calendar('work', displayname: 'Work')
|
|
371
|
+
cal.should.be.instance_of Async::Caldav::Client::Calendar
|
|
372
|
+
cal.path.should.equal '/calendars/admin/work/'
|
|
373
|
+
cal.displayname.should.equal 'Work'
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
it "calendars returns list of Calendar objects" do
|
|
377
|
+
client, = make_client
|
|
378
|
+
client.create_calendar('cal1', displayname: 'Cal 1')
|
|
379
|
+
client.create_calendar('cal2', displayname: 'Cal 2')
|
|
380
|
+
cals = client.calendars
|
|
381
|
+
cals.length.should.equal 2
|
|
382
|
+
cals.all? { |c| c.is_a?(Async::Caldav::Client::Calendar) }.should.equal true
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
it "create_addressbook returns Addressbook" do
|
|
386
|
+
client, = make_client
|
|
387
|
+
ab = client.create_addressbook('contacts', displayname: 'Contacts')
|
|
388
|
+
ab.should.be.instance_of Async::Caldav::Client::Addressbook
|
|
389
|
+
ab.path.should.equal '/addressbooks/admin/contacts/'
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
it "addressbooks returns list of Addressbook objects" do
|
|
393
|
+
client, = make_client
|
|
394
|
+
client.create_addressbook('ab1', displayname: 'AB 1')
|
|
395
|
+
abs = client.addressbooks
|
|
396
|
+
abs.length.should.equal 1
|
|
397
|
+
abs[0].displayname.should.equal 'AB 1'
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
it "calendar returns Calendar for name" do
|
|
401
|
+
client, = make_client
|
|
402
|
+
cal = client.calendar('work')
|
|
403
|
+
cal.path.should.equal '/calendars/admin/work/'
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
it "open with block yields client and closes" do
|
|
407
|
+
transport = MockTransport.new
|
|
408
|
+
yielded = nil
|
|
409
|
+
TestClient.open('http://localhost', user: 'admin') do |c|
|
|
410
|
+
# We can't use TestClient.open directly since it calls new with base_url
|
|
411
|
+
# but this tests the block pattern
|
|
412
|
+
yielded = true
|
|
413
|
+
end
|
|
414
|
+
yielded.should.equal true
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
describe "Async::Caldav::Client::Calendar" do
|
|
419
|
+
def make_client
|
|
420
|
+
transport = MockTransport.new
|
|
421
|
+
[TestClient.new(transport, user: 'admin'), transport]
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
it "put_event creates event and returns etag" do
|
|
425
|
+
client, = make_client
|
|
426
|
+
cal = client.create_calendar('work')
|
|
427
|
+
result = cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR")
|
|
428
|
+
result[:status].should.equal 201
|
|
429
|
+
result[:etag].should.not.be.nil
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
it "get_event retrieves event body and etag" do
|
|
433
|
+
client, = make_client
|
|
434
|
+
cal = client.create_calendar('work')
|
|
435
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nSUMMARY:Test\r\nEND:VCALENDAR")
|
|
436
|
+
result = cal.get_event('ev.ics')
|
|
437
|
+
result[:body].should.include 'SUMMARY:Test'
|
|
438
|
+
result[:etag].should.not.be.nil
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
it "events returns items from REPORT" do
|
|
442
|
+
client, = make_client
|
|
443
|
+
cal = client.create_calendar('work')
|
|
444
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nSUMMARY:Meeting\r\nEND:VCALENDAR")
|
|
445
|
+
items = cal.events
|
|
446
|
+
items.length.should.equal 1
|
|
447
|
+
items[0][:path].should.include 'ev.ics'
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
it "delete_event removes event" do
|
|
451
|
+
client, = make_client
|
|
452
|
+
cal = client.create_calendar('work')
|
|
453
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR")
|
|
454
|
+
cal.delete_event('ev.ics').should.equal true
|
|
455
|
+
lambda { cal.get_event('ev.ics') }.should.raise Async::Caldav::Client::NotFound
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
it "delete removes collection" do
|
|
459
|
+
client, = make_client
|
|
460
|
+
cal = client.create_calendar('work')
|
|
461
|
+
cal.delete.should.equal true
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
it "put_event with if_match sends precondition" do
|
|
465
|
+
client, = make_client
|
|
466
|
+
cal = client.create_calendar('work')
|
|
467
|
+
result = cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR")
|
|
468
|
+
etag = result[:etag]
|
|
469
|
+
|
|
470
|
+
# Update with correct etag works
|
|
471
|
+
result2 = cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nSUMMARY:V2\r\nEND:VCALENDAR", if_match: etag)
|
|
472
|
+
result2[:status].should.equal 204
|
|
473
|
+
|
|
474
|
+
# Update with wrong etag fails
|
|
475
|
+
lambda {
|
|
476
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nSUMMARY:V3\r\nEND:VCALENDAR", if_match: '"wrong"')
|
|
477
|
+
}.should.raise Async::Caldav::Client::PreconditionFailed
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
it "put_event with if_none_match * prevents overwrite" do
|
|
481
|
+
client, = make_client
|
|
482
|
+
cal = client.create_calendar('work')
|
|
483
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR")
|
|
484
|
+
|
|
485
|
+
lambda {
|
|
486
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR", if_none_match: '*')
|
|
487
|
+
}.should.raise Async::Caldav::Client::PreconditionFailed
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
it "put_event with duplicate UID raises Conflict" do
|
|
491
|
+
client, = make_client
|
|
492
|
+
cal = client.create_calendar('work')
|
|
493
|
+
cal.put_event('a.ics', "BEGIN:VCALENDAR\r\nUID:same\r\nEND:VCALENDAR")
|
|
494
|
+
|
|
495
|
+
lambda {
|
|
496
|
+
cal.put_event('b.ics', "BEGIN:VCALENDAR\r\nUID:same\r\nEND:VCALENDAR")
|
|
497
|
+
}.should.raise Async::Caldav::Client::Conflict
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
it "sync returns items and token" do
|
|
501
|
+
client, = make_client
|
|
502
|
+
cal = client.create_calendar('work')
|
|
503
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR")
|
|
504
|
+
|
|
505
|
+
items, token = cal.sync
|
|
506
|
+
items.length.should.equal 1
|
|
507
|
+
token.should.not.be.nil
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
it "sync with token returns incremental changes" do
|
|
511
|
+
client, = make_client
|
|
512
|
+
cal = client.create_calendar('work')
|
|
513
|
+
cal.put_event('ev.ics', "BEGIN:VCALENDAR\r\nUID:1\r\nEND:VCALENDAR")
|
|
514
|
+
|
|
515
|
+
_, token = cal.sync
|
|
516
|
+
|
|
517
|
+
# No changes
|
|
518
|
+
items, token2 = cal.sync(token: token)
|
|
519
|
+
items.length.should.equal 0
|
|
520
|
+
|
|
521
|
+
# Add item
|
|
522
|
+
cal.put_event('ev2.ics', "BEGIN:VCALENDAR\r\nUID:2\r\nEND:VCALENDAR")
|
|
523
|
+
items, = cal.sync(token: token2)
|
|
524
|
+
items.length.should.equal 1
|
|
525
|
+
items[0][:path].should.include 'ev2.ics'
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
it "proppatch updates properties" do
|
|
529
|
+
client, = make_client
|
|
530
|
+
cal = client.create_calendar('work', displayname: 'Old')
|
|
531
|
+
cal.proppatch(displayname: 'New')
|
|
532
|
+
cal.displayname.should.equal 'New'
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
describe "Async::Caldav::Client::Addressbook" do
|
|
537
|
+
def make_client
|
|
538
|
+
transport = MockTransport.new
|
|
539
|
+
[TestClient.new(transport, user: 'admin'), transport]
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
it "put_contact creates contact and returns etag" do
|
|
543
|
+
client, = make_client
|
|
544
|
+
ab = client.create_addressbook('contacts')
|
|
545
|
+
result = ab.put_contact('alice.vcf', "BEGIN:VCARD\r\nUID:1\r\nFN:Alice\r\nEND:VCARD")
|
|
546
|
+
result[:status].should.equal 201
|
|
547
|
+
result[:etag].should.not.be.nil
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
it "get_contact retrieves contact" do
|
|
551
|
+
client, = make_client
|
|
552
|
+
ab = client.create_addressbook('contacts')
|
|
553
|
+
ab.put_contact('alice.vcf', "BEGIN:VCARD\r\nUID:1\r\nFN:Alice\r\nEND:VCARD")
|
|
554
|
+
result = ab.get_contact('alice.vcf')
|
|
555
|
+
result[:body].should.include 'FN:Alice'
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
it "contacts returns items from REPORT" do
|
|
559
|
+
client, = make_client
|
|
560
|
+
ab = client.create_addressbook('contacts')
|
|
561
|
+
ab.put_contact('alice.vcf', "BEGIN:VCARD\r\nUID:1\r\nFN:Alice\r\nEND:VCARD")
|
|
562
|
+
items = ab.contacts
|
|
563
|
+
items.length.should.equal 1
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
it "delete_contact removes contact" do
|
|
567
|
+
client, = make_client
|
|
568
|
+
ab = client.create_addressbook('contacts')
|
|
569
|
+
ab.put_contact('alice.vcf', "BEGIN:VCARD\r\nUID:1\r\nFN:Alice\r\nEND:VCARD")
|
|
570
|
+
ab.delete_contact('alice.vcf').should.equal true
|
|
571
|
+
lambda { ab.get_contact('alice.vcf') }.should.raise Async::Caldav::Client::NotFound
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
it "delete removes collection" do
|
|
575
|
+
client, = make_client
|
|
576
|
+
ab = client.create_addressbook('contacts')
|
|
577
|
+
ab.delete.should.equal true
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
require "async/caldav"
|
|
6
|
+
|
|
7
|
+
module Async
|
|
8
|
+
module Caldav
|
|
9
|
+
module ForwardAuth
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def extract(env)
|
|
13
|
+
{
|
|
14
|
+
user: env['HTTP_REMOTE_USER'],
|
|
15
|
+
email: env['HTTP_REMOTE_EMAIL'],
|
|
16
|
+
name: env['HTTP_REMOTE_NAME'],
|
|
17
|
+
groups: parse_groups(env['HTTP_REMOTE_GROUPS'])
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def parse_groups(header)
|
|
22
|
+
return [] if header.nil? || header.empty?
|
|
23
|
+
header.split(',').map(&:strip).reject(&:empty?)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private_class_method :parse_groups
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module TestStub
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def inject(env, user: 'admin', email: nil, name: nil, groups: nil)
|
|
33
|
+
env['HTTP_REMOTE_USER'] ||= user
|
|
34
|
+
env['HTTP_REMOTE_EMAIL'] ||= email if email
|
|
35
|
+
env['HTTP_REMOTE_NAME'] ||= name if name
|
|
36
|
+
env['HTTP_REMOTE_GROUPS'] ||= groups if groups
|
|
37
|
+
env
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
test do
|
|
45
|
+
describe "Async::Caldav::ForwardAuth" do
|
|
46
|
+
it "sets user from HTTP_REMOTE_USER" do
|
|
47
|
+
result = Async::Caldav::ForwardAuth.extract({ 'HTTP_REMOTE_USER' => 'admin' })
|
|
48
|
+
result[:user].should.equal 'admin'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "leaves user nil when header absent" do
|
|
52
|
+
result = Async::Caldav::ForwardAuth.extract({})
|
|
53
|
+
result[:user].should.be.nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "parses HTTP_REMOTE_GROUPS as comma-separated list" do
|
|
57
|
+
result = Async::Caldav::ForwardAuth.extract({ 'HTTP_REMOTE_GROUPS' => 'a,b,c' })
|
|
58
|
+
result[:groups].should.equal ['a', 'b', 'c']
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "trims whitespace around group names" do
|
|
62
|
+
result = Async::Caldav::ForwardAuth.extract({ 'HTTP_REMOTE_GROUPS' => ' a , b ' })
|
|
63
|
+
result[:groups].should.equal ['a', 'b']
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "empty groups header produces empty list" do
|
|
67
|
+
result = Async::Caldav::ForwardAuth.extract({ 'HTTP_REMOTE_GROUPS' => '' })
|
|
68
|
+
result[:groups].should.equal []
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "email and name flow through" do
|
|
72
|
+
result = Async::Caldav::ForwardAuth.extract({
|
|
73
|
+
'HTTP_REMOTE_EMAIL' => 'a@b.com',
|
|
74
|
+
'HTTP_REMOTE_NAME' => 'Admin'
|
|
75
|
+
})
|
|
76
|
+
result[:email].should.equal 'a@b.com'
|
|
77
|
+
result[:name].should.equal 'Admin'
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
describe "Async::Caldav::TestStub" do
|
|
82
|
+
it "injects defaults when no header present" do
|
|
83
|
+
env = {}
|
|
84
|
+
Async::Caldav::TestStub.inject(env)
|
|
85
|
+
env['HTTP_REMOTE_USER'].should.equal 'admin'
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "respects existing headers when present" do
|
|
89
|
+
env = { 'HTTP_REMOTE_USER' => 'existing' }
|
|
90
|
+
Async::Caldav::TestStub.inject(env)
|
|
91
|
+
env['HTTP_REMOTE_USER'].should.equal 'existing'
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|