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,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/setup"
4
- require "scampi"
5
- require "async/caldav"
6
3
  require 'uri'
7
4
 
8
5
  module Async
@@ -13,114 +10,126 @@ module Async
13
10
 
14
11
  def call(path:, storage:, headers: {}, **)
15
12
  destination = headers['destination']
16
- return [400, { 'content-type' => 'text/plain' }, ['Missing Destination header']] unless destination
17
-
18
- to_path = URI.parse(destination).path
19
- overwrite = headers['overwrite'] != 'F'
20
-
21
- source = storage.get_item(path.to_s)
22
- return [404, { 'content-type' => 'text/plain' }, ['Not Found']] unless source
23
-
24
- existing = storage.get_item(to_path)
25
-
26
- if existing && !overwrite
27
- return [412, { 'content-type' => 'text/plain' }, ['Precondition Failed']]
28
- end
29
-
30
- # UID conflict check when destination doesn't already exist
31
- if !existing
32
- uid = extract_uid(source[:body])
33
- if uid
34
- dest_col = Protocol::Caldav::Path.new(to_path).parent.to_s
35
- items = storage.list_items(dest_col)
36
- items.each do |item_path, item_data|
37
- next if item_path == path.to_s
38
- if extract_uid(item_data[:body]) == uid
39
- return [409, { 'content-type' => 'text/plain' }, ['UID conflict']]
13
+ if destination
14
+ to_path = URI.parse(destination).path
15
+ overwrite = headers['overwrite'] != 'F'
16
+ source = storage.get_item(path.to_s)
17
+ if source
18
+ existing = storage.get_item(to_path)
19
+ if existing && !overwrite
20
+ [412, { 'content-type' => 'text/plain' }, ['Precondition Failed']]
21
+ elsif !existing && uid_conflict?(
22
+ path,
23
+ to_path,
24
+ source[:body],
25
+ storage,
26
+ )
27
+ [409, { 'content-type' => 'text/plain' }, ['UID conflict']]
28
+ else
29
+ storage.move_item(path.to_s, to_path)
30
+ if existing
31
+ [204, {}, ['']]
32
+ else
33
+ [201, {}, ['']]
40
34
  end
41
35
  end
36
+ else
37
+ [404, { 'content-type' => 'text/plain' }, ['Not Found']]
42
38
  end
39
+ else
40
+ [400, { 'content-type' => 'text/plain' }, ['Missing Destination header']]
43
41
  end
42
+ end
44
43
 
45
- storage.move_item(path.to_s, to_path)
46
-
47
- if existing
48
- [204, {}, ['']]
44
+ # A UID may appear once per collection. The source item itself is
45
+ # skipped, so moving an item back over its own path is not a clash.
46
+ def uid_conflict?(path, to_path, body, storage)
47
+ uid = extract_uid(body)
48
+ if uid
49
+ dest_col = Protocol::Caldav::Path.new(to_path).parent.to_s
50
+ storage.list_items(dest_col).any? do |item_path, item_data|
51
+ item_path != path.to_s && extract_uid(item_data[:body]) == uid
52
+ end
49
53
  else
50
- [201, {}, ['']]
54
+ false
51
55
  end
52
56
  end
53
57
 
54
58
  def extract_uid(body)
55
- return nil unless body
56
- match = body.match(/^UID:(.+)/i)
57
- match ? match[1].strip : nil
59
+ if body
60
+ match = body.match(/^UID:(.+)/i)
61
+ match ? match[1].strip : nil
62
+ else
63
+ nil
64
+ end
58
65
  end
59
66
 
60
- private_class_method :extract_uid
67
+ private_class_method :extract_uid, :uid_conflict?
61
68
  end
62
69
  end
63
70
  end
64
71
  end
65
72
 
66
- test do
67
- describe "Async::Caldav::Handlers::Move" do
68
- def call(**opts)
69
- Async::Caldav::Handlers::Move.call(**opts)
70
- end
73
+ __END__
71
74
 
72
- def path(p, s)
73
- Protocol::Caldav::Path.new(p, storage_class: s)
74
- end
75
+ require_relative "../../caldav"
75
76
 
76
- it "moves an item and returns 201" do
77
- s = Async::Caldav::Storage::Mock.new
78
- s.create_collection('/cal/')
79
- s.put_item('/cal/a.ics', 'BEGIN:VCALENDAR', 'text/calendar')
80
- status, = call(
81
- path: path('/cal/a.ics', s), storage: s,
82
- headers: { 'destination' => 'http://localhost/cal/b.ics' }
83
- )
84
- status.should.equal 201
85
- s.get_item('/cal/a.ics').should.be.nil
86
- s.get_item('/cal/b.ics').should.not.be.nil
87
- end
77
+ describe "Async::Caldav::Handlers::Move" do
78
+ def call(**opts)
79
+ Async::Caldav::Handlers::Move.call(**opts)
80
+ end
88
81
 
89
- it "overwrites and returns 204" do
90
- s = Async::Caldav::Storage::Mock.new
91
- s.put_item('/cal/a.ics', 'A', 'text/calendar')
92
- s.put_item('/cal/b.ics', 'B', 'text/calendar')
93
- status, = call(
94
- path: path('/cal/a.ics', s), storage: s,
95
- headers: { 'destination' => 'http://localhost/cal/b.ics' }
96
- )
97
- status.should.equal 204
98
- end
82
+ def path(p, s)
83
+ Protocol::Caldav::Path.new(p, storage_class: s)
84
+ end
99
85
 
100
- it "returns 400 without Destination header" do
101
- s = Async::Caldav::Storage::Mock.new
102
- status, = call(path: path('/cal/a.ics', s), storage: s, headers: {})
103
- status.should.equal 400
104
- end
86
+ it "moves an item and returns 201" do
87
+ s = Async::Caldav::Storage::Mock.new
88
+ s.create_collection('/cal/')
89
+ s.put_item('/cal/a.ics', 'BEGIN:VCALENDAR', 'text/calendar')
90
+ status, = call(
91
+ path: path('/cal/a.ics', s), storage: s,
92
+ headers: { 'destination' => 'http://localhost/cal/b.ics' }
93
+ )
94
+ status.should.equal 201
95
+ s.get_item('/cal/a.ics').should.be.nil
96
+ s.get_item('/cal/b.ics').should.not.be.nil
97
+ end
105
98
 
106
- it "returns 404 when source missing" do
107
- s = Async::Caldav::Storage::Mock.new
108
- status, = call(
109
- path: path('/cal/nope.ics', s), storage: s,
110
- headers: { 'destination' => 'http://localhost/cal/b.ics' }
111
- )
112
- status.should.equal 404
113
- end
99
+ it "overwrites and returns 204" do
100
+ s = Async::Caldav::Storage::Mock.new
101
+ s.put_item('/cal/a.ics', 'A', 'text/calendar')
102
+ s.put_item('/cal/b.ics', 'B', 'text/calendar')
103
+ status, = call(
104
+ path: path('/cal/a.ics', s), storage: s,
105
+ headers: { 'destination' => 'http://localhost/cal/b.ics' }
106
+ )
107
+ status.should.equal 204
108
+ end
114
109
 
115
- it "returns 412 when Overwrite=F and destination exists" do
116
- s = Async::Caldav::Storage::Mock.new
117
- s.put_item('/cal/a.ics', 'A', 'text/calendar')
118
- s.put_item('/cal/b.ics', 'B', 'text/calendar')
119
- status, = call(
120
- path: path('/cal/a.ics', s), storage: s,
121
- headers: { 'destination' => 'http://localhost/cal/b.ics', 'overwrite' => 'F' }
122
- )
123
- status.should.equal 412
124
- end
110
+ it "returns 400 without Destination header" do
111
+ s = Async::Caldav::Storage::Mock.new
112
+ status, = call(path: path('/cal/a.ics', s), storage: s, headers: {})
113
+ status.should.equal 400
125
114
  end
126
- end
115
+
116
+ it "returns 404 when source missing" do
117
+ s = Async::Caldav::Storage::Mock.new
118
+ status, = call(
119
+ path: path('/cal/nope.ics', s), storage: s,
120
+ headers: { 'destination' => 'http://localhost/cal/b.ics' }
121
+ )
122
+ status.should.equal 404
123
+ end
124
+
125
+ it "returns 412 when Overwrite=F and destination exists" do
126
+ s = Async::Caldav::Storage::Mock.new
127
+ s.put_item('/cal/a.ics', 'A', 'text/calendar')
128
+ s.put_item('/cal/b.ics', 'B', 'text/calendar')
129
+ status, = call(
130
+ path: path('/cal/a.ics', s), storage: s,
131
+ headers: { 'destination' => 'http://localhost/cal/b.ics', 'overwrite' => 'F' }
132
+ )
133
+ status.should.equal 412
134
+ end
135
+ end
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/setup"
4
- require "scampi"
5
- require "async/caldav"
6
-
7
3
  module Async
8
4
  module Caldav
9
5
  module Handlers
@@ -18,17 +14,19 @@ module Async
18
14
  end
19
15
  end
20
16
 
21
- test do
22
- describe "Async::Caldav::Handlers::Options" do
23
- it "returns 200 with DAV headers" do
24
- status, headers, = Async::Caldav::Handlers::Options.call(
25
- path: Protocol::Caldav::Path.new("/calendars/admin/cal/"),
26
- storage: Async::Caldav::Storage::Mock.new
27
- )
28
- status.should.equal 200
29
- headers['dav'].should.include 'calendar-access'
30
- headers['allow'].should.include 'PROPFIND'
31
- headers['content-length'].should.equal '0'
32
- end
17
+ __END__
18
+
19
+ require_relative "../../caldav"
20
+
21
+ describe "Async::Caldav::Handlers::Options" do
22
+ it "returns 200 with DAV headers" do
23
+ status, headers, = Async::Caldav::Handlers::Options.call(
24
+ path: Protocol::Caldav::Path.new("/calendars/admin/cal/"),
25
+ storage: Async::Caldav::Storage::Mock.new
26
+ )
27
+ status.should.equal 200
28
+ headers['dav'].should.include 'calendar-access'
29
+ headers['allow'].should.include 'PROPFIND'
30
+ headers['content-length'].should.equal '0'
33
31
  end
34
- end
32
+ end
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/setup"
4
- require "scampi"
5
- require "async/caldav"
6
-
7
3
  module Async
8
4
  module Caldav
9
5
  module Handlers
@@ -13,7 +9,11 @@ module Async
13
9
  def call(path:, storage:, user:, headers: {}, body: nil, **)
14
10
  depth = headers['depth'] || '1'
15
11
  propname = body&.include?('propname')
16
- build = propname ? :build_propname : :build_propfind
12
+ if propname
13
+ build = :build_propname
14
+ else
15
+ build = :build_propfind
16
+ end
17
17
 
18
18
  col_path = path.ensure_trailing_slash
19
19
  collection = storage.get_collection(col_path.to_s)
@@ -21,75 +21,74 @@ module Async
21
21
 
22
22
  # Non-existent deep path
23
23
  if !collection && !item_data && path.depth > 2
24
- return [404, { 'content-type' => 'text/plain' }, ['Not Found']]
25
- end
26
-
27
- xml = Protocol::Caldav::Multistatus.new.to_xml do |x|
28
- if collection
29
- col = Protocol::Caldav::Collection.new(
30
- path: col_path,
31
- type: collection[:type],
32
- displayname: collection[:displayname],
33
- description: collection[:description],
34
- color: collection[:color],
35
- props: collection[:props]
36
- )
37
- col.send(build, x)
38
-
39
- if depth != '0'
40
- storage.list_collections(col_path.to_s).each do |child_path, child_data|
41
- child_p = Protocol::Caldav::Path.new(child_path, storage_class: storage)
42
- child_col = Protocol::Caldav::Collection.new(
43
- path: child_p,
44
- type: child_data[:type],
45
- displayname: child_data[:displayname],
46
- description: child_data[:description],
47
- color: child_data[:color],
48
- props: child_data[:props]
49
- )
50
- child_col.send(build, x)
24
+ [404, { 'content-type' => 'text/plain' }, ['Not Found']]
25
+ else
26
+ xml = Protocol::Caldav::Multistatus.new.to_xml do |x|
27
+ if collection
28
+ col = Protocol::Caldav::Collection.new(
29
+ path: col_path,
30
+ type: collection[:type],
31
+ displayname: collection[:displayname],
32
+ description: collection[:description],
33
+ color: collection[:color],
34
+ props: collection[:props],
35
+ )
36
+ col.send(build, x)
37
+
38
+ if depth != '0'
39
+ storage.list_collections(col_path.to_s).each do |child_path, child_data|
40
+ child_p = Protocol::Caldav::Path.new(child_path, storage_class: storage)
41
+ child_col = Protocol::Caldav::Collection.new(
42
+ path: child_p,
43
+ type: child_data[:type],
44
+ displayname: child_data[:displayname],
45
+ description: child_data[:description],
46
+ color: child_data[:color],
47
+ props: child_data[:props],
48
+ )
49
+ child_col.send(build, x)
50
+ end
51
+
52
+ storage.list_items(col_path.to_s).each do |item_path, data|
53
+ item_p = Protocol::Caldav::Path.new(item_path, storage_class: storage)
54
+ item = Protocol::Caldav::Item.new(
55
+ path: item_p,
56
+ body: data[:body],
57
+ content_type: data[:content_type],
58
+ etag: data[:etag],
59
+ )
60
+ item.send(build, x)
61
+ end
51
62
  end
52
-
53
- storage.list_items(col_path.to_s).each do |item_path, data|
54
- item_p = Protocol::Caldav::Path.new(item_path, storage_class: storage)
55
- item = Protocol::Caldav::Item.new(
56
- path: item_p,
57
- body: data[:body],
58
- content_type: data[:content_type],
59
- etag: data[:etag]
60
- )
61
- item.send(build, x)
62
- end
63
- end
64
- elsif item_data
65
- item = Protocol::Caldav::Item.new(
66
- path: path,
67
- body: item_data[:body],
68
- content_type: item_data[:content_type],
69
- etag: item_data[:etag]
70
- )
71
- item.send(build, x)
72
- else
73
- build_discovery(x, path, user)
74
-
75
- if depth != '0'
76
- storage.list_collections(col_path.to_s).each do |child_path, child_data|
77
- child_p = Protocol::Caldav::Path.new(child_path, storage_class: storage)
78
- child_col = Protocol::Caldav::Collection.new(
79
- path: child_p,
80
- type: child_data[:type],
81
- displayname: child_data[:displayname],
82
- description: child_data[:description],
83
- color: child_data[:color],
84
- props: child_data[:props]
85
- )
86
- child_col.send(build, x)
63
+ elsif item_data
64
+ item = Protocol::Caldav::Item.new(
65
+ path: path,
66
+ body: item_data[:body],
67
+ content_type: item_data[:content_type],
68
+ etag: item_data[:etag],
69
+ )
70
+ item.send(build, x)
71
+ else
72
+ build_discovery(x, path, user)
73
+
74
+ if depth != '0'
75
+ storage.list_collections(col_path.to_s).each do |child_path, child_data|
76
+ child_p = Protocol::Caldav::Path.new(child_path, storage_class: storage)
77
+ child_col = Protocol::Caldav::Collection.new(
78
+ path: child_p,
79
+ type: child_data[:type],
80
+ displayname: child_data[:displayname],
81
+ description: child_data[:description],
82
+ color: child_data[:color],
83
+ props: child_data[:props],
84
+ )
85
+ child_col.send(build, x)
86
+ end
87
87
  end
88
88
  end
89
89
  end
90
+ [207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
90
91
  end
91
-
92
- [207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
93
92
  end
94
93
 
95
94
  def build_discovery(xml, path, user)
@@ -109,83 +108,85 @@ module Async
109
108
  end
110
109
  end
111
110
 
112
- test do
113
- def normalize(xml)
114
- xml.gsub(/>\s+</, '><').strip
115
- end
111
+ __END__
116
112
 
117
- describe "Async::Caldav::Handlers::Propfind" do
118
- def call(**opts)
119
- Async::Caldav::Handlers::Propfind.call(**opts)
120
- end
113
+ require_relative "../../caldav"
121
114
 
122
- def path(p, s)
123
- Protocol::Caldav::Path.new(p, storage_class: s)
124
- end
115
+ def normalize(xml)
116
+ xml.gsub(/>\s+</, '><').strip
117
+ end
125
118
 
126
- it "returns 207 with collection properties" do
127
- s = Async::Caldav::Storage::Mock.new
128
- s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
129
- status, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin', headers: { 'depth' => '0' })
130
- status.should.equal 207
131
- body[0].should.include 'Work'
132
- body[0].should.include 'calendar'
133
- end
119
+ describe "Async::Caldav::Handlers::Propfind" do
120
+ def call(**opts)
121
+ Async::Caldav::Handlers::Propfind.call(**opts)
122
+ end
134
123
 
135
- it "depth=1 includes child items" do
136
- s = Async::Caldav::Storage::Mock.new
137
- s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
138
- s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
139
- _, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin', headers: { 'depth' => '1' })
140
- body[0].should.include 'ev.ics'
141
- end
124
+ def path(p, s)
125
+ Protocol::Caldav::Path.new(p, storage_class: s)
126
+ end
142
127
 
143
- it "returns 404 for deep non-existent path" do
144
- s = Async::Caldav::Storage::Mock.new
145
- status, = call(path: path('/calendars/admin/nope/deep/', s), storage: s, user: 'admin')
146
- status.should.equal 404
147
- end
128
+ it "returns 207 with collection properties" do
129
+ s = Async::Caldav::Storage::Mock.new
130
+ s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
131
+ status, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin', headers: { 'depth' => '0' })
132
+ status.should.equal 207
133
+ body[0].should.include 'Work'
134
+ body[0].should.include 'calendar'
135
+ end
148
136
 
149
- it "returns discovery info for shallow path" do
150
- s = Async::Caldav::Storage::Mock.new
151
- status, _, body = call(path: path('/', s), storage: s, user: 'admin')
152
- status.should.equal 207
153
- body[0].should.include 'current-user-principal'
154
- body[0].should.include '/admin/'
155
- body[0].should.include 'calendar-home-set'
156
- end
137
+ it "depth=1 includes child items" do
138
+ s = Async::Caldav::Storage::Mock.new
139
+ s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
140
+ s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
141
+ _, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin', headers: { 'depth' => '1' })
142
+ body[0].should.include 'ev.ics'
143
+ end
157
144
 
158
- it "propname request returns property names without values" do
159
- s = Async::Caldav::Storage::Mock.new
160
- s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
161
- s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
162
- propname_body = '<d:propfind xmlns:d="DAV:"><d:propname/></d:propfind>'
163
- status, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin',
164
- headers: { 'depth' => '1' }, body: propname_body)
165
- status.should.equal 207
166
- body[0].should.include '<d:resourcetype/>'
167
- body[0].should.include '<d:getetag/>'
168
- end
145
+ it "returns 404 for deep non-existent path" do
146
+ s = Async::Caldav::Storage::Mock.new
147
+ status, = call(path: path('/calendars/admin/nope/deep/', s), storage: s, user: 'admin')
148
+ status.should.equal 404
149
+ end
169
150
 
170
- it "allprop request returns all properties with values" do
171
- s = Async::Caldav::Storage::Mock.new
172
- s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
173
- s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
174
- allprop_body = '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>'
175
- status, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin',
176
- headers: { 'depth' => '1' }, body: allprop_body)
177
- status.should.equal 207
178
- body[0].should.include 'displayname'
179
- body[0].should.include 'Work'
180
- body[0].should.include 'getetag'
181
- end
151
+ it "returns discovery info for shallow path" do
152
+ s = Async::Caldav::Storage::Mock.new
153
+ status, _, body = call(path: path('/', s), storage: s, user: 'admin')
154
+ status.should.equal 207
155
+ body[0].should.include 'current-user-principal'
156
+ body[0].should.include '/admin/'
157
+ body[0].should.include 'calendar-home-set'
158
+ end
182
159
 
183
- it "returns item propfind for a single item" do
184
- s = Async::Caldav::Storage::Mock.new
185
- s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
186
- status, _, body = call(path: path('/calendars/admin/work/ev.ics', s), storage: s, user: 'admin')
187
- status.should.equal 207
188
- body[0].should.include 'getetag'
189
- end
160
+ it "propname request returns property names without values" do
161
+ s = Async::Caldav::Storage::Mock.new
162
+ s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
163
+ s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
164
+ propname_body = '<d:propfind xmlns:d="DAV:"><d:propname/></d:propfind>'
165
+ status, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin',
166
+ headers: { 'depth' => '1' }, body: propname_body)
167
+ status.should.equal 207
168
+ body[0].should.include '<d:resourcetype/>'
169
+ body[0].should.include '<d:getetag/>'
190
170
  end
191
- end
171
+
172
+ it "allprop request returns all properties with values" do
173
+ s = Async::Caldav::Storage::Mock.new
174
+ s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
175
+ s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
176
+ allprop_body = '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>'
177
+ status, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin',
178
+ headers: { 'depth' => '1' }, body: allprop_body)
179
+ status.should.equal 207
180
+ body[0].should.include 'displayname'
181
+ body[0].should.include 'Work'
182
+ body[0].should.include 'getetag'
183
+ end
184
+
185
+ it "returns item propfind for a single item" do
186
+ s = Async::Caldav::Storage::Mock.new
187
+ s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
188
+ status, _, body = call(path: path('/calendars/admin/work/ev.ics', s), storage: s, user: 'admin')
189
+ status.should.equal 207
190
+ body[0].should.include 'getetag'
191
+ end
192
+ end