require 'spec_helper' require 'fileutils' require 'rack/mock' require 'support/lockable_file_resource' class Rack::MockResponse attr_reader :original_response def initialize_with_original(*args) status, headers, @original_response = *args initialize_without_original(*args) end alias_method :initialize_without_original, :initialize alias_method :initialize, :initialize_with_original def media_type_params return {} if content_type.nil? Hash[*content_type.split(/\s*[;,]\s*/)[1..-1]. collect { |s| s.split('=', 2) }. map { |k,v| [k.downcase, v] }.flatten] end end describe RackDAV::Handler do DOC_ROOT = File.expand_path(File.dirname(__FILE__) + '/htdocs') METHODS = %w(GET PUT POST DELETE PROPFIND PROPPATCH MKCOL COPY MOVE OPTIONS HEAD LOCK UNLOCK) CLASS_2 = METHODS CLASS_1 = CLASS_2 - %w(LOCK UNLOCK) before do FileUtils.mkdir(DOC_ROOT) unless File.exist?(DOC_ROOT) end after do FileUtils.rm_rf(DOC_ROOT) if File.exist?(DOC_ROOT) end attr_reader :response shared_examples :lockable_resource do describe "OPTIONS" do it "is successful" do options(url_root).should be_ok end it "sets the allow header with class 2 methods" do options(url_root) CLASS_2.each do |method| response.headers['allow'].should include(method) end end end describe "LOCK" do before(:each) do put(url_root + 'test', :input => "body").should be_created lock(url_root + 'test', :input => File.read(fixture("requests/lock.xml"))) end describe "creation" do it "succeeds" do response.should be_ok end it "sets a compliant rack response" do body = response.original_response body.should be_a(Array) expect(body.size).to eq(1) end it "prints the lockdiscovery" do lockdiscovery_response response_locktoken end end describe "refreshing" do context "a valid locktoken" do it "prints the lockdiscovery" do token = response_locktoken lock(url_root + 'test', 'HTTP_IF' => "(#{token})").should be_ok lockdiscovery_response token end it "accepts it without parenthesis" do token = response_locktoken lock(url_root + 'test', 'HTTP_IF' => token).should be_ok lockdiscovery_response token end it "accepts it with excess angular braces (office 2003)" do token = response_locktoken lock(url_root + 'test', 'HTTP_IF' => "(<#{token}>)").should be_ok lockdiscovery_response token end end context "an invalid locktoken" do it "bails out" do lock(url_root + 'test', 'HTTP_IF' => '123') response.should be_forbidden response.body.should be_empty end end context "no locktoken" do it "bails out" do lock(url_root + 'test') response.should be_bad_request response.body.should be_empty end end end end describe "UNLOCK" do before(:each) do put(url_root + 'test', :input => "body").should be_created lock(url_root + 'test', :input => File.read(fixture("requests/lock.xml"))).should be_ok end context "given a valid token" do before(:each) do token = response_locktoken unlock(url_root + 'test', 'HTTP_LOCK_TOKEN' => "(#{token})") end it "unlocks the resource" do response.should be_no_content end end context "given an invalid token" do before(:each) do unlock(url_root + 'test', 'HTTP_LOCK_TOKEN' => '(123)') end it "bails out" do response.should be_forbidden end end context "given no token" do before(:each) do unlock(url_root + 'test') end it "bails out" do response.should be_bad_request end end end end context "Given a Lockable resource" do context "when mounted directly" do before do @controller = RackDAV::Handler.new( :root => DOC_ROOT, :resource_class => RackDAV::LockableFileResource ) end let(:url_root){ '/' } include_examples :lockable_resource end context "When mounted via a URLMap" do before do @controller = Rack::URLMap.new( "/dav" => RackDAV::Handler.new( :root => DOC_ROOT, :resource_class => RackDAV::LockableFileResource ) ) end let(:url_root){ '/dav/' } include_examples :lockable_resource end end shared_examples :not_lockable_resource do describe "uri escaping" do it "allows url escaped utf-8" do put(url_root + 'D%C3%B6ner').should be_created get(url_root + 'D%C3%B6ner').should be_ok end it "allows url escaped iso-8859" do put(url_root + 'D%F6ner').should be_created get(url_root + 'D%F6ner').should be_ok end end describe "OPTIONS" do it "is successful" do options(url_root).should be_ok end it "sets the allow header with class 2 methods" do options(url_root) CLASS_1.each do |method| response.headers['allow'].should include(method) end end end describe "CONTENT-MD5 header exists" do context "doesn't match with body's checksum" do before do put(url_root + 'foo', :input => 'bar', 'HTTP_CONTENT_MD5' => 'baz') end it 'should return a Bad Request response' do response.should be_bad_request end it 'should not create the resource' do get(url_root + 'foo').should be_not_found end end context "matches with body's checksum" do before do put(url_root + 'foo', :input => 'bar', 'HTTP_CONTENT_MD5' => 'N7UdGUp1E+RbVvZSTy1R8g==') end it 'should be successful' do response.should be_created end it 'should create the resource' do get(url_root + 'foo').should be_ok response.body.should == 'bar' end end end it 'should return headers' do put(url_root + 'test.html', :input => '').should be_created head(url_root + 'test.html').should be_ok response.headers['etag'].should_not be_nil response.headers['content-type'].should match(/html/) response.headers['last-modified'].should_not be_nil end it 'should not find a nonexistent resource' do get(url_root + 'not_found').should be_not_found end it 'should not allow directory traversal' do get(url_root + '../htdocs').should be_forbidden end it 'should create a resource and allow its retrieval' do put(url_root + 'test', :input => 'body').should be_created get(url_root + 'test').should be_ok response.body.should == 'body' end it 'should create and find a url with escaped characters' do put(url_root + url_escape('/a b'), :input => 'body').should be_created get(url_root + url_escape('/a b')).should be_ok response.body.should == 'body' end it 'should delete a single resource' do put(url_root + 'test', :input => 'body').should be_created delete(url_root + 'test').should be_no_content end it 'should delete recursively' do mkcol(url_root + 'folder').should be_created put(url_root + 'folder/a', :input => 'body').should be_created put(url_root + 'folder/b', :input => 'body').should be_created delete(url_root + 'folder').should be_no_content get(url_root + 'folder').should be_not_found get(url_root + 'folder/a').should be_not_found get(url_root + 'folder/b').should be_not_found end it 'should return not found when deleting a non-existent resource' do delete(url_root + 'not_found').should be_not_found end it 'should not allow copy to another domain' do put(url_root + 'test', :input => 'body').should be_created copy('http://example.org' + url_root, 'HTTP_DESTINATION' => 'http://another/').should be_bad_gateway end it 'should not allow copy to the same resource' do put(url_root + 'test', :input => 'body').should be_created copy(url_root + 'test', 'HTTP_DESTINATION' => url_root + 'test').should be_forbidden end it 'should not allow an invalid destination uri' do put(url_root + 'test', :input => 'body').should be_created copy(url_root + 'test', 'HTTP_DESTINATION' => '%').should be_bad_request end it 'should copy a single resource' do put(url_root + 'test', :input => 'body').should be_created copy(url_root + 'test', 'HTTP_DESTINATION' => url_root + 'copy').should be_created get(url_root + 'copy').body.should == 'body' end it 'should copy a resource with escaped characters' do put(url_root + url_escape('/a b'), :input => 'body').should be_created copy(url_root + url_escape('/a b'), 'HTTP_DESTINATION' => url_root + url_escape('/a c')).should be_created get(url_root + url_escape('/a c')).should be_ok response.body.should == 'body' end it 'should deny a copy without overwrite' do put(url_root + 'test', :input => 'body').should be_created put(url_root + 'copy', :input => 'copy').should be_created copy(url_root + 'test', 'HTTP_DESTINATION' => url_root + 'copy', 'HTTP_OVERWRITE' => 'F').should be_precondition_failed get(url_root + 'copy').body.should == 'copy' end it 'should allow a copy with overwrite' do put(url_root + 'test', :input => 'body').should be_created put(url_root + 'copy', :input => 'copy').should be_created copy(url_root + 'test', 'HTTP_DESTINATION' => url_root + 'copy', 'HTTP_OVERWRITE' => 'T').should be_no_content get(url_root + 'copy').body.should == 'body' end it 'should deny a move to an existing resource without overwrite' do put(url_root + 'test', :input => 'body').should be_created put(url_root + 'copy', :input => 'copy').should be_created move(url_root + 'test', 'HTTP_DESTINATION' => url_root + 'copy', 'HTTP_OVERWRITE' => 'F').should be_precondition_failed end it 'should copy a collection' do mkcol(url_root + 'folder').should be_created copy(url_root + 'folder', 'HTTP_DESTINATION' => url_root + 'copy').should be_created propfind(url_root + 'copy', :input => propfind_xml(:resourcetype)) multistatus_response('/d:propstat/d:prop/d:resourcetype/d:collection').should_not be_empty end it 'should copy a collection resursively' do mkcol(url_root + 'folder').should be_created put(url_root + 'folder/a', :input => 'A').should be_created put(url_root + 'folder/b', :input => 'B').should be_created copy(url_root + 'folder', 'HTTP_DESTINATION' => url_root + 'copy').should be_created propfind(url_root + 'copy', :input => propfind_xml(:resourcetype)) multistatus_response('/d:propstat/d:prop/d:resourcetype/d:collection').should_not be_empty get(url_root + 'copy/a').body.should == 'A' get(url_root + 'copy/b').body.should == 'B' end it 'should move a collection recursively' do mkcol(url_root + 'folder').should be_created put(url_root + 'folder/a', :input => 'A').should be_created put(url_root + 'folder/b', :input => 'B').should be_created move(url_root + 'folder', 'HTTP_DESTINATION' => url_root + 'move').should be_created propfind(url_root + 'move', :input => propfind_xml(:resourcetype)) multistatus_response('/d:propstat/d:prop/d:resourcetype/d:collection').should_not be_empty get(url_root + 'move/a').body.should == 'A' get(url_root + 'move/b').body.should == 'B' get(url_root + 'folder/a').should be_not_found get(url_root + 'folder/b').should be_not_found end it 'should not move a collection onto an existing collection without overwrite' do mkcol(url_root + 'folder').should be_created mkcol(url_root + 'dest').should be_created move(url_root + 'folder', 'HTTP_DESTINATION' => url_root + 'dest', 'HTTP_OVERWRITE' => 'F').should be_precondition_failed end it 'should create a collection' do mkcol(url_root + 'folder').should be_created propfind(url_root + 'folder', :input => propfind_xml(:resourcetype)) multistatus_response('/d:propstat/d:prop/d:resourcetype/d:collection').should_not be_empty end it 'should not create a collection with a body' do mkcol(url_root + 'folder', :input => 'body').should be_unsupported_media_type end it 'should not find properties for nonexistent resources' do propfind(url_root + 'non').should be_not_found end it 'should find all properties' do xml = render do |xml| xml.propfind('xmlns' => "DAV:") do xml.allprop end end propfind('http://example.org' + url_root, :input => xml) multistatus_response('/d:href').first.text.strip.should == 'http://example.org' + url_root props = %w(creationdate displayname getlastmodified getetag resourcetype getcontenttype getcontentlength) props.each do |prop| multistatus_response('/d:propstat/d:prop/d:' + prop).should_not be_empty end end it 'should find named properties' do put(url_root + 'test.html', :input => '').should be_created propfind(url_root + 'test.html', :input => propfind_xml(:getcontenttype, :getcontentlength)) multistatus_response('/d:propstat/d:prop/d:getcontenttype').first.text.should == 'text/html' multistatus_response('/d:propstat/d:prop/d:getcontentlength').first.text.should == '7' end it 'should not set properties for a non-existent resource' do proppatch(url_root + 'not_found', :input => propset_xml([:foo, 'testing'])).should be_not_found end it 'should not return properties for non-existent resource' do propfind(url_root + 'prop', :input => propfind_xml(:foo)).should be_not_found end it 'should return the correct charset (utf-8)' do put(url_root + 'test.html', :input => '').should be_created propfind(url_root + 'test.html', :input => propfind_xml(:getcontenttype, :getcontentlength)) charset = @response.media_type_params['charset'] charset.should eql 'utf-8' end it 'should not support LOCK' do put(url_root + 'test', :input => 'body').should be_created xml = render do |xml| xml.lockinfo('xmlns:d' => "DAV:") do xml.lockscope { xml.exclusive } xml.locktype { xml.write } xml.owner { xml.href "http://test.de/" } end end lock(url_root + 'test', :input => xml).should be_method_not_allowed end it 'should not support UNLOCK' do put(url_root + 'test', :input => 'body').should be_created unlock(url_root + 'test', :input => '').should be_method_not_allowed end end context "Given a not lockable resource" do context "when mounted directly" do before do @controller = RackDAV::Handler.new( :root => DOC_ROOT, :resource_class => RackDAV::FileResource ) end let(:url_root){ '/' } include_examples :not_lockable_resource end context "When mounted via a URLMap" do let(:url_root){ '/dav/' } before do @controller = Rack::URLMap.new( "/dav" => RackDAV::Handler.new( :root => DOC_ROOT, :resource_class => RackDAV::FileResource ) ) end include_examples :not_lockable_resource end end private def request(method, uri, options={}) options = { 'REMOTE_USER' => 'manni' }.merge(options) request = Rack::MockRequest.new(@controller) @response = request.request(method, uri, options) end METHODS.each do |method| define_method(method.downcase) do |*args| request(method, *args) end end def render Nokogiri::XML::Builder.new(:encoding => "UTF-8") do |xml| yield xml end.to_xml end def url_escape(string) string.gsub(/([^ a-zA-Z0-9_.-]+)/n) do '%' + $1.unpack('H2' * $1.size).join('%').upcase end.tr(' ', '+') end def response_xml @response_xml ||= Nokogiri::XML(@response.body) end def response_locktoken response_xml.xpath("/d:prop/d:lockdiscovery/d:activelock/d:locktoken/d:href", 'd' => 'DAV:').first.text end def lockdiscovery_response(token) match = lambda do |pattern| response_xml.xpath("/d:prop/d:lockdiscovery/d:activelock" + pattern, 'd' => 'DAV:') end match[''].should_not be_empty match['/d:locktype'].should_not be_empty match['/d:lockscope'].should_not be_empty match['/d:depth'].should_not be_empty match['/d:owner'].should_not be_empty match['/d:timeout'].should_not be_empty match['/d:locktoken/d:href'].should_not be_empty match['/d:locktoken/d:href'].first.text.should == token end def multistatus_response(pattern, ns=nil) xmlns = { 'd' => 'DAV:' } xmlns.merge!(ns) unless ns.nil? @response.should be_multi_status response_xml.xpath("/d:multistatus/d:response", xmlns).should_not be_empty response_xml.xpath("/d:multistatus/d:response" + pattern, xmlns) end def propfind_xml(*props) render do |xml| xml.propfind('xmlns' => "DAV:") do xml.prop do props.each do |prop, attrs| xml.send(prop.to_sym, attrs) end end end end end def propset_xml(*props) render do |xml| xml.propertyupdate('xmlns' => 'DAV:') do xml.set do xml.prop do props.each do |prop, value, attrs| attrs = {} if attrs.nil? xml.send(prop.to_sym, value, attrs) end end end end end end end