rack_dav 0.1.3 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
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