async-caldav 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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('&amp;', '&').gsub('&lt;', '<').gsub('&gt;', '>').gsub('&quot;', '"')
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