rack_dav_sp 0.2.dev

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