rack_dav 0.1.3 → 0.3.1

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.
data/rack_dav.gemspec CHANGED
@@ -1,28 +1,24 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "rack_dav/version"
4
+
1
5
  Gem::Specification.new do |s|
2
- s.name = 'rack_dav'
3
- s.version = '0.1.3'
4
- s.summary = 'WebDAV handler for Rack'
5
- s.author = 'Matthias Georgi'
6
- s.email = 'matti.georgi@gmail.com'
7
- s.homepage = 'http://www.matthias-georgi.de/rack_dav'
8
- s.description = 'WebDAV handler for Rack'
9
- s.require_path = 'lib'
10
- s.executables << 'rack_dav'
11
- s.has_rdoc = true
12
- s.extra_rdoc_files = ['README.md']
13
- s.files = %w{
14
- .gitignore
15
- LICENSE
16
- rack_dav.gemspec
17
- lib/rack_dav.rb
18
- lib/rack_dav/file_resource.rb
19
- lib/rack_dav/handler.rb
20
- lib/rack_dav/controller.rb
21
- lib/rack_dav/builder_namespace.rb
22
- lib/rack_dav/http_status.rb
23
- lib/rack_dav/resource.rb
24
- bin/rack_dav
25
- spec/handler_spec.rb
26
- README.md
27
- }
6
+ s.name = "rack_dav"
7
+ s.version = RackDAV::VERSION
8
+ s.author = "Matthias Georgi"
9
+ s.email = "matti.georgi@gmail.com"
10
+ s.homepage = "http://georgi.github.com/rack_dav"
11
+ s.summary = "WebDAV handler for Rack."
12
+ s.description = "WebDAV handler for Rack."
13
+
14
+ s.files = `git ls-files`.split("\n")
15
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
16
+ s.require_paths = ["lib"]
17
+
18
+ s.extra_rdoc_files = ["README.md"]
19
+
20
+ s.add_dependency("rack", "~> 1.4.0")
21
+ s.add_dependency('nokogiri')
22
+ s.add_development_dependency("rspec", "~> 2.11.0")
23
+ s.add_development_dependency("rake","~> 0.9.0")
28
24
  end
@@ -0,0 +1,498 @@
1
+ require 'spec_helper'
2
+ require 'fileutils'
3
+
4
+ require 'rack/mock'
5
+
6
+ require 'support/lockable_file_resource'
7
+
8
+ class Rack::MockResponse
9
+
10
+ attr_reader :original_response
11
+
12
+ def initialize_with_original(*args)
13
+ status, headers, @original_response = *args
14
+ initialize_without_original(*args)
15
+ end
16
+
17
+ alias_method :initialize_without_original, :initialize
18
+ alias_method :initialize, :initialize_with_original
19
+
20
+ def media_type_params
21
+ return {} if content_type.nil?
22
+ Hash[*content_type.split(/\s*[;,]\s*/)[1..-1].
23
+ collect { |s| s.split('=', 2) }.
24
+ map { |k,v| [k.downcase, v] }.flatten]
25
+ end
26
+
27
+ end
28
+
29
+ describe RackDAV::Handler do
30
+
31
+ DOC_ROOT = File.expand_path(File.dirname(__FILE__) + '/htdocs')
32
+ METHODS = %w(GET PUT POST DELETE PROPFIND PROPPATCH MKCOL COPY MOVE OPTIONS HEAD LOCK UNLOCK)
33
+ CLASS_2 = METHODS
34
+ CLASS_1 = CLASS_2 - %w(LOCK UNLOCK)
35
+
36
+ before do
37
+ FileUtils.mkdir(DOC_ROOT) unless File.exists?(DOC_ROOT)
38
+ end
39
+
40
+ after do
41
+ FileUtils.rm_rf(DOC_ROOT) if File.exists?(DOC_ROOT)
42
+ end
43
+
44
+ attr_reader :response
45
+
46
+ context "Given a Lockable resource" do
47
+ before do
48
+ @controller = RackDAV::Handler.new(
49
+ :root => DOC_ROOT,
50
+ :resource_class => RackDAV::LockableFileResource
51
+ )
52
+ end
53
+
54
+ describe "OPTIONS" do
55
+ it "is successful" do
56
+ options('/').should be_ok
57
+ end
58
+
59
+ it "sets the allow header with class 2 methods" do
60
+ options('/')
61
+ CLASS_2.each do |method|
62
+ response.headers['allow'].should include(method)
63
+ end
64
+ end
65
+ end
66
+
67
+ describe "LOCK" do
68
+ before(:each) do
69
+ put("/test", :input => "body").should be_ok
70
+ lock("/test", :input => File.read(fixture("requests/lock.xml")))
71
+ end
72
+
73
+ describe "creation" do
74
+ it "succeeds" do
75
+ response.should be_ok
76
+ end
77
+
78
+ it "sets a compliant rack response" do
79
+ body = response.original_response.body
80
+ body.should be_a(Array)
81
+ body.should have(1).part
82
+ end
83
+
84
+ it "prints the lockdiscovery" do
85
+ lockdiscovery_response response_locktoken
86
+ end
87
+ end
88
+
89
+ describe "refreshing" do
90
+ context "a valid locktoken" do
91
+ it "prints the lockdiscovery" do
92
+ token = response_locktoken
93
+ lock("/test", 'HTTP_IF' => "(#{token})").should be_ok
94
+ lockdiscovery_response token
95
+ end
96
+
97
+ it "accepts it without parenthesis" do
98
+ token = response_locktoken
99
+ lock("/test", 'HTTP_IF' => token).should be_ok
100
+ lockdiscovery_response token
101
+ end
102
+
103
+ it "accepts it with excess angular braces (office 2003)" do
104
+ token = response_locktoken
105
+ lock("/test", 'HTTP_IF' => "(<#{token}>)").should be_ok
106
+ lockdiscovery_response token
107
+ end
108
+ end
109
+
110
+ context "an invalid locktoken" do
111
+ it "bails out" do
112
+ lock("/test", 'HTTP_IF' => '123')
113
+ response.should be_forbidden
114
+ response.body.should be_empty
115
+ end
116
+ end
117
+
118
+ context "no locktoken" do
119
+ it "bails out" do
120
+ lock("/test")
121
+ response.should be_bad_request
122
+ response.body.should be_empty
123
+ end
124
+ end
125
+
126
+ end
127
+ end
128
+
129
+ describe "UNLOCK" do
130
+ before(:each) do
131
+ put("/test", :input => "body").should be_ok
132
+ lock("/test", :input => File.read(fixture("requests/lock.xml"))).should be_ok
133
+ end
134
+
135
+ context "given a valid token" do
136
+ before(:each) do
137
+ token = response_locktoken
138
+ unlock("/test", 'HTTP_LOCK_TOKEN' => "(#{token})")
139
+ end
140
+
141
+ it "unlocks the resource" do
142
+ response.should be_no_content
143
+ end
144
+ end
145
+
146
+ context "given an invalid token" do
147
+ before(:each) do
148
+ unlock("/test", 'HTTP_LOCK_TOKEN' => '(123)')
149
+ end
150
+
151
+ it "bails out" do
152
+ response.should be_forbidden
153
+ end
154
+ end
155
+
156
+ context "given no token" do
157
+ before(:each) do
158
+ unlock("/test")
159
+ end
160
+
161
+ it "bails out" do
162
+ response.should be_bad_request
163
+ end
164
+ end
165
+
166
+ end
167
+ end
168
+
169
+ context "Given a not lockable resource" do
170
+ before do
171
+ @controller = RackDAV::Handler.new(
172
+ :root => DOC_ROOT,
173
+ :resource_class => RackDAV::FileResource
174
+ )
175
+ end
176
+
177
+ describe "uri escaping" do
178
+ it "allows url escaped utf-8" do
179
+ put('/D%C3%B6ner').should be_ok
180
+ get('/D%C3%B6ner').should be_ok
181
+ end
182
+
183
+ it "allows url escaped iso-8859" do
184
+ put('/D%F6ner').should be_ok
185
+ get('/D%F6ner').should be_ok
186
+ end
187
+ end
188
+
189
+ describe "OPTIONS" do
190
+ it "is successful" do
191
+ options('/').should be_ok
192
+ end
193
+
194
+ it "sets the allow header with class 2 methods" do
195
+ options('/')
196
+ CLASS_1.each do |method|
197
+ response.headers['allow'].should include(method)
198
+ end
199
+ end
200
+ end
201
+
202
+ describe "CONTENT-MD5 header exists" do
203
+ context "doesn't match with body's checksum" do
204
+ before do
205
+ put('/foo', :input => 'bar',
206
+ 'HTTP_CONTENT_MD5' => 'baz')
207
+ end
208
+
209
+ it 'should return a Bad Request response' do
210
+ response.should be_bad_request
211
+ end
212
+
213
+ it 'should not create the resource' do
214
+ get('/foo').should be_not_found
215
+ end
216
+ end
217
+
218
+ context "matches with body's checksum" do
219
+ before do
220
+ put('/foo', :input => 'bar',
221
+ 'HTTP_CONTENT_MD5' => 'N7UdGUp1E+RbVvZSTy1R8g==')
222
+ end
223
+
224
+ it 'should be successful' do
225
+ response.should be_ok
226
+ end
227
+
228
+ it 'should create the resource' do
229
+ get('/foo').should be_ok
230
+ response.body.should == 'bar'
231
+ end
232
+ end
233
+ end
234
+
235
+ it 'should return headers' do
236
+ put('/test.html', :input => '<html/>').should be_ok
237
+ head('/test.html').should be_ok
238
+
239
+ response.headers['etag'].should_not be_nil
240
+ response.headers['content-type'].should match(/html/)
241
+ response.headers['last-modified'].should_not be_nil
242
+ end
243
+
244
+
245
+ it 'should not find a nonexistent resource' do
246
+ get('/not_found').should be_not_found
247
+ end
248
+
249
+ it 'should not allow directory traversal' do
250
+ get('/../htdocs').should be_forbidden
251
+ end
252
+
253
+ it 'should create a resource and allow its retrieval' do
254
+ put('/test', :input => 'body').should be_ok
255
+ get('/test').should be_ok
256
+ response.body.should == 'body'
257
+ end
258
+ it 'should create and find a url with escaped characters' do
259
+ put(url_escape('/a b'), :input => 'body').should be_ok
260
+ get(url_escape('/a b')).should be_ok
261
+ response.body.should == 'body'
262
+ end
263
+
264
+ it 'should delete a single resource' do
265
+ put('/test', :input => 'body').should be_ok
266
+ delete('/test').should be_no_content
267
+ end
268
+
269
+ it 'should delete recursively' do
270
+ mkcol('/folder').should be_created
271
+ put('/folder/a', :input => 'body').should be_ok
272
+ put('/folder/b', :input => 'body').should be_ok
273
+
274
+ delete('/folder').should be_no_content
275
+ get('/folder').should be_not_found
276
+ get('/folder/a').should be_not_found
277
+ get('/folder/b').should be_not_found
278
+ end
279
+
280
+ it 'should not allow copy to another domain' do
281
+ put('/test', :input => 'body').should be_ok
282
+ copy('http://localhost/', 'HTTP_DESTINATION' => 'http://another/').should be_bad_gateway
283
+ end
284
+
285
+ it 'should not allow copy to the same resource' do
286
+ put('/test', :input => 'body').should be_ok
287
+ copy('/test', 'HTTP_DESTINATION' => '/test').should be_forbidden
288
+ end
289
+
290
+ it 'should not allow an invalid destination uri' do
291
+ put('/test', :input => 'body').should be_ok
292
+ copy('/test', 'HTTP_DESTINATION' => '%').should be_bad_request
293
+ end
294
+
295
+ it 'should copy a single resource' do
296
+ put('/test', :input => 'body').should be_ok
297
+ copy('/test', 'HTTP_DESTINATION' => '/copy').should be_created
298
+ get('/copy').body.should == 'body'
299
+ end
300
+
301
+ it 'should copy a resource with escaped characters' do
302
+ put(url_escape('/a b'), :input => 'body').should be_ok
303
+ copy(url_escape('/a b'), 'HTTP_DESTINATION' => url_escape('/a c')).should be_created
304
+ get(url_escape('/a c')).should be_ok
305
+ response.body.should == 'body'
306
+ end
307
+
308
+ it 'should deny a copy without overwrite' do
309
+ put('/test', :input => 'body').should be_ok
310
+ put('/copy', :input => 'copy').should be_ok
311
+ copy('/test', 'HTTP_DESTINATION' => '/copy', 'HTTP_OVERWRITE' => 'F')
312
+
313
+ multistatus_response('/d:href').first.text.should == 'http://localhost/test'
314
+ multistatus_response('/d:status').first.text.should match(/412 Precondition Failed/)
315
+
316
+ get('/copy').body.should == 'copy'
317
+ end
318
+
319
+ it 'should allow a copy with overwrite' do
320
+ put('/test', :input => 'body').should be_ok
321
+ put('/copy', :input => 'copy').should be_ok
322
+ copy('/test', 'HTTP_DESTINATION' => '/copy', 'HTTP_OVERWRITE' => 'T').should be_no_content
323
+ get('/copy').body.should == 'body'
324
+ end
325
+
326
+ it 'should copy a collection' do
327
+ mkcol('/folder').should be_created
328
+ copy('/folder', 'HTTP_DESTINATION' => '/copy').should be_created
329
+ propfind('/copy', :input => propfind_xml(:resourcetype))
330
+ multistatus_response('/d:propstat/d:prop/d:resourcetype/d:collection').should_not be_empty
331
+ end
332
+
333
+ it 'should copy a collection resursively' do
334
+ mkcol('/folder').should be_created
335
+ put('/folder/a', :input => 'A').should be_ok
336
+ put('/folder/b', :input => 'B').should be_ok
337
+
338
+ copy('/folder', 'HTTP_DESTINATION' => '/copy').should be_created
339
+ propfind('/copy', :input => propfind_xml(:resourcetype))
340
+ multistatus_response('/d:propstat/d:prop/d:resourcetype/d:collection').should_not be_empty
341
+
342
+ get('/copy/a').body.should == 'A'
343
+ get('/copy/b').body.should == 'B'
344
+ end
345
+
346
+ it 'should move a collection recursively' do
347
+ mkcol('/folder').should be_created
348
+ put('/folder/a', :input => 'A').should be_ok
349
+ put('/folder/b', :input => 'B').should be_ok
350
+
351
+ move('/folder', 'HTTP_DESTINATION' => '/move').should be_created
352
+ propfind('/move', :input => propfind_xml(:resourcetype))
353
+ multistatus_response('/d:propstat/d:prop/d:resourcetype/d:collection').should_not be_empty
354
+
355
+ get('/move/a').body.should == 'A'
356
+ get('/move/b').body.should == 'B'
357
+ get('/folder/a').should be_not_found
358
+ get('/folder/b').should be_not_found
359
+ end
360
+
361
+ it 'should create a collection' do
362
+ mkcol('/folder').should be_created
363
+ propfind('/folder', :input => propfind_xml(:resourcetype))
364
+ multistatus_response('/d:propstat/d:prop/d:resourcetype/d:collection').should_not be_empty
365
+ end
366
+
367
+ it 'should not find properties for nonexistent resources' do
368
+ propfind('/non').should be_not_found
369
+ end
370
+
371
+ it 'should find all properties' do
372
+ xml = render do |xml|
373
+ xml.propfind('xmlns:d' => "DAV:") do
374
+ xml.allprop
375
+ end
376
+ end
377
+
378
+ propfind('http://localhost/', :input => xml)
379
+
380
+ multistatus_response('/d:href').first.text.strip.should == 'http://localhost/'
381
+
382
+ props = %w(creationdate displayname getlastmodified getetag resourcetype getcontenttype getcontentlength)
383
+ props.each do |prop|
384
+ multistatus_response('/d:propstat/d:prop/d:' + prop).should_not be_empty
385
+ end
386
+ end
387
+
388
+ it 'should find named properties' do
389
+ put('/test.html', :input => '<html/>').should be_ok
390
+ propfind('/test.html', :input => propfind_xml(:getcontenttype, :getcontentlength))
391
+
392
+ multistatus_response('/d:propstat/d:prop/d:getcontenttype').first.text.should == 'text/html'
393
+ multistatus_response('/d:propstat/d:prop/d:getcontentlength').first.text.should == '7'
394
+ end
395
+
396
+ it 'should return the correct charset (utf-8)' do
397
+ put('/test.html', :input => '<html/>').should be_ok
398
+ propfind('/test.html', :input => propfind_xml(:getcontenttype, :getcontentlength))
399
+
400
+ charset = @response.media_type_params['charset']
401
+ charset.should eql 'utf-8'
402
+ end
403
+
404
+ it 'should not support LOCK' do
405
+ put('/test', :input => 'body').should be_ok
406
+
407
+ xml = render do |xml|
408
+ xml.lockinfo('xmlns:d' => "DAV:") do
409
+ xml.lockscope { xml.exclusive }
410
+ xml.locktype { xml.write }
411
+ xml.owner { xml.href "http://test.de/" }
412
+ end
413
+ end
414
+
415
+ lock('/test', :input => xml).should be_method_not_allowed
416
+ end
417
+
418
+ it 'should not support UNLOCK' do
419
+ put('/test', :input => 'body').should be_ok
420
+ unlock('/test', :input => '').should be_method_not_allowed
421
+ end
422
+
423
+ end
424
+
425
+
426
+ private
427
+
428
+ def request(method, uri, options={})
429
+ options = {
430
+ 'HTTP_HOST' => 'localhost',
431
+ 'REMOTE_USER' => 'manni'
432
+ }.merge(options)
433
+ request = Rack::MockRequest.new(@controller)
434
+ @response = request.request(method, uri, options)
435
+ end
436
+
437
+ METHODS.each do |method|
438
+ define_method(method.downcase) do |*args|
439
+ request(method, *args)
440
+ end
441
+ end
442
+
443
+
444
+ def render
445
+ Nokogiri::XML::Builder.new(:encoding => "UTF-8") do |xml|
446
+ yield xml
447
+ end.to_xml
448
+ end
449
+
450
+ def url_escape(string)
451
+ string.gsub(/([^ a-zA-Z0-9_.-]+)/n) do
452
+ '%' + $1.unpack('H2' * $1.size).join('%').upcase
453
+ end.tr(' ', '+')
454
+ end
455
+
456
+ def response_xml
457
+ @response_xml ||= Nokogiri::XML(@response.body)
458
+ end
459
+
460
+ def response_locktoken
461
+ response_xml.xpath("/d:prop/d:lockdiscovery/d:activelock/d:locktoken/d:href", 'd' => 'DAV:').first.text
462
+ end
463
+
464
+ def lockdiscovery_response(token)
465
+ match = lambda do |pattern|
466
+ response_xml.xpath("/d:prop/d:lockdiscovery/d:activelock" + pattern, 'd' => 'DAV:')
467
+ end
468
+
469
+ match[''].should_not be_empty
470
+
471
+ match['/d:locktype'].should_not be_empty
472
+ match['/d:lockscope'].should_not be_empty
473
+ match['/d:depth'].should_not be_empty
474
+ match['/d:owner'].should_not be_empty
475
+ match['/d:timeout'].should_not be_empty
476
+ match['/d:locktoken/d:href'].should_not be_empty
477
+ match['/d:locktoken/d:href'].first.text.should == token
478
+ end
479
+
480
+ def multistatus_response(pattern)
481
+ @response.should be_multi_status
482
+ response_xml.xpath("/d:multistatus/d:response", 'd' => 'DAV:').should_not be_empty
483
+ response_xml.xpath("/d:multistatus/d:response" + pattern, 'd' => 'DAV:')
484
+ end
485
+
486
+ def propfind_xml(*props)
487
+ render do |xml|
488
+ xml.propfind('xmlns:d' => "DAV:") do
489
+ xml.prop do
490
+ props.each do |prop|
491
+ xml.send prop.to_sym
492
+ end
493
+ end
494
+ end
495
+ end
496
+ end
497
+
498
+ end