diff --git a/Gemfile b/Gemfile index 5e7995f2..6251a855 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,8 @@ gem 'pg' gem 'sequel' gem 'fog-aws' gem 'etna' +gem 'dav4rack', git: 'https://github.com/planio-gmbh/dav4rack.git', branch: 'master' +gem 'bundler' group :test do gem 'rspec' @@ -17,5 +19,7 @@ group :test do gem 'timecop' gem 'database_cleaner' gem 'pry' + gem 'ruby-debug-ide' + gem 'debase' gem 'pry-byebug' end diff --git a/Gemfile.lock b/Gemfile.lock index 9281f085..b5f6ea27 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,15 @@ +GIT + remote: https://github.com/planio-gmbh/dav4rack.git + revision: 44ea2306904b9777c2eb09945973581947552a6c + branch: master + specs: + dav4rack (1.1.0) + addressable (>= 2.5.0) + nokogiri (>= 1.6.0) + ox (>= 2.1.0) + rack (>= 1.6) + uuidtools (~> 2.1.1) + GEM remote: https://rubygems.org/ specs: @@ -17,6 +29,9 @@ GEM crack (0.4.3) safe_yaml (~> 1.0.0) database_cleaner (1.8.5) + debase (0.2.4.1) + debase-ruby_core_source (>= 0.10.2) + debase-ruby_core_source (0.10.9) diff-lcs (1.3) docile (1.3.2) etna (0.1.12) @@ -61,6 +76,7 @@ GEM connection_pool (~> 2.2) nokogiri (1.10.9) mini_portile2 (~> 2.4.0) + ox (2.13.2) pg (1.2.3) pry (0.13.1) coderay (~> 1.1) @@ -72,6 +88,7 @@ GEM rack (2.2.2) rack-test (1.1.0) rack (>= 1.0, < 3) + rake (13.0.1) rspec (3.9.0) rspec-core (~> 3.9.0) rspec-expectations (~> 3.9.0) @@ -85,6 +102,8 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.9.0) rspec-support (3.9.3) + ruby-debug-ide (0.7.2) + rake (>= 0.8.1) safe_yaml (1.0.5) sequel (5.32.0) simplecov (0.18.5) @@ -95,6 +114,7 @@ GEM timecop (0.9.1) tzinfo (1.2.7) thread_safe (~> 0.1) + uuidtools (2.1.5) webmock (3.8.3) addressable (>= 2.3.6) crack (>= 0.3.2) @@ -105,7 +125,10 @@ PLATFORMS ruby DEPENDENCIES + bundler database_cleaner + dav4rack! + debase etna factory_bot fog-aws @@ -115,6 +138,7 @@ DEPENDENCIES rack rack-test rspec + ruby-debug-ide sequel simplecov timecop diff --git a/config.yml.template b/config.yml.template index 024cef3c..50240c77 100644 --- a/config.yml.template +++ b/config.yml.template @@ -26,7 +26,7 @@ :development: :log_file: log/error.log - :auth_redirect: https://janus.etna-development.local + :auth_redirect: https://janus.development.local :token_name: JANUS_DEV_TOKEN :token_algo: RS256 :metis_uid_name: METIS_DEV_UID diff --git a/docker-compose.yml b/docker-compose.yml index e5df96b3..45c6e8fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,7 @@ x-metis_base: &metis_base COVERAGE: 'true' DATABASE_HOST: 'db' METIS_ENV: 'development' + entrypoint: docker/app/docker-entrypoint.sh services: metis_app: diff --git a/docker/app/Dockerfile.development b/docker/app/Dockerfile.development index 8484e178..a36accbb 100644 --- a/docker/app/Dockerfile.development +++ b/docker/app/Dockerfile.development @@ -40,8 +40,7 @@ RUN gem install bundler --default -v "=$BUNDLER_VERSION" RUN gem update --system # We also the GEM_PATH to be this BUNDLE_PATH so that ides (Rubymine) will find gems easier. -ENV GEM_HOME="$BUNDLE_PATH" -ENV GEM_PATH="/root/.gem/ruby/2.5.0:/usr/local/lib/ruby/gems/2.5.0:/usr/local/bundle:$BUNDLE_PATH" +ENV GEM_PATH="/root/.gem/ruby/2.5.0:/usr/local/lib/ruby/gems/2.5.0:/usr/local/bundle:$BUNDLE_PATH/ruby/2.5.0" # bash improvements for developer environment RUN git clone --depth=1 https://github.com/Bash-it/bash-it.git ~/.bash_it && \ diff --git a/lib/metis.rb b/lib/metis.rb index e313850c..df71e1bb 100644 --- a/lib/metis.rb +++ b/lib/metis.rb @@ -3,6 +3,7 @@ require 'fileutils' require_relative 'archiver' require_relative 'assimilation' +require_relative 'web_dav_resource' # This class handles the http request and routing class Metis diff --git a/lib/web_dav_resource.rb b/lib/web_dav_resource.rb new file mode 100644 index 00000000..0e043617 --- /dev/null +++ b/lib/web_dav_resource.rb @@ -0,0 +1,498 @@ +require 'webrick/httputils' +require 'dav4rack' +require 'forwardable' +require 'dav4rack/resource' + +class Metis + class WebDavResource < DAV4Rack::Resource + extend Forwardable + include WEBrick::HTTPUtils + include DAV4Rack::Utils + + attr_reader :inhabitant + def_delegators :inhabitant, :creation_date, :last_modified, :etag, :content_length, :directory? + + def inhabitant + if @user.instance_of?(Etna::User) + @inhabitant ||= DataResourceNode.descend(path, @user) + else + nil + end + end + + def application + @application ||= Etna::Application.instance + end + + def authenticate(uname, password) + begin + # payload, _ = application.sign.jwt_decode(password) + payload, _ = JSON.parse(Base64.strict_decode64(password)) + rescue + return false + end + + @user = request.env['etna.user'] = Etna::User.new(payload.map { |k, v| [k.to_sym, v] }.to_h, password) + true + end + + def root + @options[:root_uri_path] + end + + def make_collection + raise NotFound if inhabitant.nil? + raise Forbidden unless is_writable?(:directory) + raise Conflict unless inhabitant.mkdir! + + Created + end + + def get(request, response) + raise NotFound unless exist? + + unless directory? + # Files are actually sent via apache, which swaps the body out of a response based on this header. + response['X-Sendfile'] = inhabitant.file.data_block.location + end + + OK + end + + def put(request, response) + raise NotFound if inhabitant.nil? + raise Forbidden unless is_writable?(:file) + + io = request.body + tempfile = Tempfile.new + open(tempfile, "wb") do |file| + while part = io.read(8192) + file << part + end + end + + raise Forbidden unless inhabitant.upload!(tempfile) + + OK + end + + def delete + raise NotFound unless exist? + raise Forbidden unless inhabitant.delete! + + NoContent + end + + def copy(dest_path, overwrite = false, depth = nil) + do_copy(dest_path, overwrite, depth, false) + end + + def move(dest_path, overwrite=false) + do_copy(dest_path, overwrite, nil, true) + end + + def children + return [] unless exist? + + inhabitant.children.map do |node| + child node.path_segment + end + end + + def is_writable?(type) + !inhabitant.nil? && inhabitant.is_writable?(type) + end + + def exist? + !inhabitant.nil? && inhabitant.exist? + end + + def collection? + directory? + end + + def content_type + if directory? + "text/html" + else + mime_type(path, DefaultMimeTypes) + end + end + + protected + + # For now, depth is ignored. + def do_copy(dest_path, overwrite, depth, is_move) + raise NotFound unless exist? + raise Forbidden unless inhabitant.copyable? + + dest = DataResourceNode.descend(dest_path, user) + raise PreconditionFailed if dest.exist? && !overwrite + + Metis.instance.db.transaction do + if dest.exist? + raise Conflict unless dest.delete! + end + + raise NotFound unless inhabitant.copy!(dest) + inhabitant.delete! if is_move + end + + NoContent + end + + def bucket_allowed?(bucket) + true + # bucket.allowed?(@user, @request.env['etna.hmac']) + end + + def project + @project ||= Project.first(project_name: project_name) + end + end + + class DataResourceNode + attr_reader :parent, :path_segment, :user + + def initialize(user, parent, segment) + @user = user + @parent = parent + @path_segment = segment + end + + def self.descend(path, user) + (path[1..-1].split('/')).inject(RootDirectoryResourceNode.new(user)) do |parent, next_child| + parent&.find_child(next_child) + end + end + + def parent_folder + parent.respond_to?(:folder) ? parent.folder : nil + end + + def find_child(segment) + children.find do |node| + node.path_segment == segment + end || writable_edge_node(segment) + end + + def writable_edge_node(segment) + nil + end + + def copyable? + false + end + + def copy!(dest) + false + end + + def delete! + false + end + + def is_writable?(type) + false + end + + def mkdir! + false + end + + def children + [] + end + + def etag + nil + end + + def directory? + true + end + + def content_length + 0 + end + + def creation_date + Time.now + end + + def last_modified + Time.now + end + + def exist? + true + end + + def bucket + nil + end + end + + class RootDirectoryResourceNode < DataResourceNode + def initialize(user) + super(user, nil, nil) + end + + def children + project_names = Metis::Bucket.distinct.select(:project_name).map(&:project_name) + listable_projects = project_names.select do |project_name| + user.is_admin?(project_name) || user.permissions[project_name] + end + + listable_projects.map { |project_name| ProjectResourceNode.new(user, self, project_name) } + end + end + + class ProjectResourceNode < DataResourceNode + def creation_date + first_created_bucket&.created_at || Time.now + end + + def last_modified + last_updated_bucket&.updated_at || Time.now + end + + def children + buckets = Metis::Bucket.where(project_name: path_segment).all + listable_buckets = buckets.select do |bucket| + bucket.allowed?(user, nil) + end + + listable_buckets.map do |bucket| + BucketResourceNode.new(user, self, bucket.name) + end + end + + def last_updated_bucket + @last_updated_bucket ||= Metis::Bucket.where(project_name: path_segment).order_by(:updated_at).last + end + + def first_created_bucket + @first_created_bucket ||= Metis::Bucket.where(project_name: path_segment).order_by(:created_at).first + end + end + + class BucketResourceNode < DataResourceNode + def creation_date + bucket&.created_at || Time.now + end + + # Maybe should be last modified file? But there is no index, not great query. + def last_modified + bucket&.updated_at || Time.now + end + + def children + return [] if bucket.nil? + + folder_names = Metis::Folder.where(bucket: bucket, folder_id: nil).select(:folder_name).map(&:folder_name) + file_names = Metis::File.where(bucket: bucket, folder_id: nil).select(:file_name).map(&:file_name) + + folder_names.map { |name| FolderResourceNode.new(user, self, name, bucket) } + \ + file_names.map { |name| FileResourceNode.new(user, self, name, bucket) } + end + + def writable_edge_node(segment) + WritableEdgeNode.new(user, self, segment, bucket) + end + + def bucket + @bucket ||= Metis::Bucket.where(name: path_segment, project_name: parent.path_segment).first + end + + def path + "" + end + end + + class FolderResourceNode < DataResourceNode + attr_reader :bucket, :path + + def initialize(user, parent, segment, bucket) + super(user, parent, segment) + @bucket = bucket + @path = parent.path + segment + "/" + end + + def creation_date + folder&.created_at || Time.now + end + + def last_modified + folder&.updated_at || Time.now + end + + def copyable? + true + end + + def copy!(dest) + return false unless dest.bucket + Metis::Folder.find_or_create(folder_id: dest.parent_folder&.id, folder_name: dest.path_segment, bucket_id: dest.bucket.id, project_name: dest.bucket.project_name) do |f| + f.author = folder.author + end + true + end + + def delete! + Metis.instance.db.transaction do + Metis::File.where(folder: folder).delete + Metis::Folder.where(folder: folder).delete + folder.remove! + end + end + + def children + return [] if folder.nil? + + folder.folders.map { |f| FolderResourceNode.new(user, self, f.folder_name, bucket) } + \ + folder.files.map { |f| FileResourceNode.new(user, self, f.file_name, bucket) } + end + + def folder + @folder ||= Metis::Folder.from_path(bucket, path).last + end + + def writable_edge_node(segment) + WritableEdgeNode.new(user, self, segment, bucket) + end + end + + class FileResourceNode < DataResourceNode + attr_reader :bucket, :path + + def initialize(user, parent, segment, bucket) + super(user, parent, segment) + @bucket = bucket + @path = parent.path + segment + end + + def file + @file ||= Metis::File.from_path(bucket, path) + end + + def creation_date + return Time.now if file.nil? + file.data_block.created_at + end + + def last_modified + return Time.now if file.nil? + file.data_block.updated_at + end + + def etag + return "" if file.nil? + "#{file.id}-#{file.data_block.md5_hash}" + end + + def content_length + return 0 if file.nil? + stat.size + end + + def copyable? + true + end + + def copy!(dest) + return false unless dest.bucket + Metis::File.find_or_create(folder_id: dest.parent_folder&.id, file_name: dest.path_segment, bucket_id: dest.bucket.id, project_name: dest.bucket.project_name) do |f| + f.author = file.author + f.data_block = file.data_block + end + true + end + + def delete! + file.remove! + true + end + + def stat + return nil if file.nil? + @stat ||= ::File.stat(file.data_block.location) + end + + def directory? + false + end + + def is_writable?(type) + type == :file + end + + def upload!(uploaded_file) + # Most of this is copied from a combination of upload_controller and etna_controller. + # Ideally this would be captured in a service class and shareable. + blob = Metis::Blob.new(tempfile: uploaded_file) + + upload = Metis::Upload.find_or_create( + file_name: path, + bucket: bucket, + metis_uid: metis_uid, + project_name: bucket.project_name + ) do |f| + f.author = Metis::File.author(user) + f.file_size = 0 + f.current_byte_position = 0 + f.next_blob_size = ::File.size(blob.path) + f.next_blob_hash = Metis::File.md5(blob.path) + end + + upload.append_blob(blob, 0, '') + + folder_path, file_name = Metis::File.path_parts(upload.file_name) + folder = Metis::Folder.from_path(bucket, folder_path).last + + file = Metis::File.from_folder(bucket, folder, file_name) + + if file && file.read_only? + return false + end + + if Metis::Folder.exists?(file_name, upload.bucket, folder) + return false + end + + upload.finish! + true + end + + def metis_uid + Metis.instance.sign.uid + end + end + + class WritableEdgeNode < FileResourceNode + def file + nil + end + + def exist? + false + end + + def is_writable?(type) + true + end + + def mkdir! + Metis::Folder.create( + folder: parent_folder, + folder_name: path_segment, + bucket: bucket, + project_name: bucket.project_name, + read_only: false, + author: Metis::File.author(user), + ) + + true + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3598c615..1e93ff54 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -8,6 +8,9 @@ require 'bundler' Bundler.require(:default, :test) +require 'dav4rack' +require 'dav4rack/interceptor' + ENV['METIS_ENV'] = 'test' require_relative '../lib/metis' @@ -37,8 +40,18 @@ def build_rack_mock_session use Rack::Static, urls: ['/css', '/js', '/fonts', '/img'], root: 'lib/client' use Etna::ParseBody use Etna::SymbolizeParams + + map '/webdav/projects/' do + run DAV4Rack::Handler.new(:resource_class => Metis::WebDavResource, :root_uri_path => '/webdav/projects/') + end + + use DAV4Rack::Interceptor, :mappings => { + '/webdav/projects/' => {:resource_class => Metis::WebDavResource}, + } + use Etna::TestAuth use Metis::SetUid + run Metis::Server.new end @@ -342,11 +355,11 @@ def hmac_header(params={}) end end -def default_bucket(project_name) +def default_bucket(project_name, bucket_name: 'files', access: 'viewer') @default_bucket ||= {} @default_bucket[project_name] ||= begin - stubs.create_bucket(project_name, 'files') - create( :bucket, project_name: project_name, name: 'files', owner: 'metis', access: 'viewer') + stubs.create_bucket(project_name, bucket_name) + create( :bucket, project_name: project_name, name: bucket_name, owner: 'metis', access: access) end end diff --git a/spec/web_dav_resource_spec.rb b/spec/web_dav_resource_spec.rb new file mode 100644 index 00000000..ca133b5f --- /dev/null +++ b/spec/web_dav_resource_spec.rb @@ -0,0 +1,408 @@ +describe Metis::WebDavResource do + include Rack::Test::Methods + + def app + OUTER_APP + end + + after(:each) do + stubs.clear + end + + # Request configurations + let(:params) { {} } + let(:env) { {} } + + # User / role configurations + let(:project_role) { :admin } + let(:other_project_role) { :viewer } + let(:permissions) { [[project_name, project_role], [other_project_name, other_project_role]] } + + let(:encoded_permissions) do + permissions.inject({}) do |projects_by_role, (proj_name, role)| + (projects_by_role[role] ||= []).push(proj_name) + projects_by_role + end.map { |role, projs| "#{role.to_s[0, 1]}:#{projs.join(',')}" }.join(';') + end + + let(:user) { {email: 'zeus@olympus.org', first: 'Zeus', perm: encoded_permissions} } + + # File configurations + let(:project_name) { 'labors' } + let(:other_project_name) { 'sports' } + let(:bucket_name) { 'files' } + let(:other_bucket_name) { 'files' } + let(:bucket_access) { 'viewer' } + let(:other_bucket_access) { 'viewer' } + let!(:bucket) { default_bucket(project_name, bucket_name: bucket_name, access: bucket_access) } + let!(:other_bucket) { default_bucket(other_project_name, bucket_name: other_bucket_name, access: other_bucket_access) } + let(:directories) { [] } + let(:file_name) { 'abc.txt' } + let(:folder) { directories_to_folder(directories, project_name, bucket) } + let(:contents) { 'abcdefg' } + let!(:location) { stubs.create_file(project_name, bucket_name, file_name, contents) } + let(:read_only) { false } + let!(:file) { create_file(project_name, file_name, contents, bucket: bucket, folder: folder, read_only: read_only) } + + let(:other_directories) { [] } + let(:other_file_name) { 'def.txt' } + let(:other_folder) { directories_to_folder(other_directories, other_project_name, other_bucket) } + let(:other_contents) { 'hijklmno' } + let!(:other_location) { stubs.create_file(other_project_name, other_bucket_name, other_file_name, other_contents) } + let!(:other_file) { create_file(project_name, other_file_name, other_contents, bucket: other_bucket, folder: other_folder) } + + # Utility values + let(:propfind_xml) do + <<-PROPFIND + + + + + PROPFIND + end + + def application + @application ||= Etna::Application.instance + end + + let(:subject_request) do + # token = application.sign.jwt_token(user) + token = Base64.strict_encode64(user.to_json) + auth = Base64.strict_encode64("user:#{token}") + header('Authorization', "Basic #{auth}") + + custom_request(method, path, params, env) + last_response + end + + let(:statuses) do + response_xml.xpath('//d:multistatus/d:response').map do |response| + propstat = response.xpath('//d:propstat').first + propstat.xpath('//d:status').first.text + end + end + + let(:hrefs) do + response = response_xml.xpath('//d:multistatus/d:response').last + response.xpath('//d:href').map(&:text).map { |href| URI.parse(href).path } + end + + def response_xml + @response_xml ||= Nokogiri.XML(last_response.body) { |config| config.strict } + end + + def directories_to_folder(directories, project_name, bucket) + directories.inject(nil) do |parent, segment| + create(:folder, folder: parent, folder_name: segment, project_name: project_name, bucket: bucket, author: 'someguy@example.org') + end + end + + ['copy', 'move'].each do |verb| + describe verb do + let(:method) { verb.upcase } + let(:source_path) { file_name } + let(:destination_path) { "new_thing" } + let(:destination_bucket_name) { destination_bucket.name } + let(:destination_project_name) { project_name } + let(:destination_bucket) { bucket } + let(:overwrite) { true } + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{source_path}" } + let(:env) do + { + 'HTTP_OVERWRITE' => overwrite ? 'T' : 'F', + 'HTTP_DESTINATION' => "#{METIS_URL}/webdav/projects/#{destination_project_name}/#{destination_bucket_name}/#{destination_path}", + } + end + + subject do + subject_request + expect(last_response.status).to eq(204) + + { + sf_remains: !Metis::File.from_path(bucket, source_path).nil?, + sd_remains: !Metis::Folder.from_path(bucket, source_path).last.nil?, + dd_exists: !Metis::Folder.from_path(destination_bucket, destination_path).last.nil?, + df_exists: !Metis::File.from_path(destination_bucket, destination_path).nil?, + } + end + + it { is_expected.to eq({dd_exists: false, df_exists: true, sd_remains: false, sf_remains: verb == 'copy', }) } + + describe 'from a folder' do + let(:directories) { ['adirectory'] } + let(:source_path) { directories.first } + + it { is_expected.to eq({dd_exists: true, df_exists: false, sf_remains: false, sd_remains: verb == 'copy', }) } + end + + describe 'for non existent source' do + let(:source_path) { "abc" } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(404) + end + end + + describe 'for a project dir' do + let(:path) { "/webdav/projects/#{project_name}/" } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(403) + end + end + end + end + + + describe 'mkcol' do + let(:method) { 'MKCOL' } + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{mkcol_dir}" } + let(:mkcol_dir) { 'my_new_dir' } + + subject do + subject_request + expect(last_response.status).to eq(201) + Metis::Folder.from_path(bucket, mkcol_dir + "/").last&.folder_path + end + + it { is_expected.to eq [mkcol_dir] } + + context 'for a folder in the root path' do + let(:path) { "/webdav/#{mkcol_dir}/" } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(403) + end + end + + context 'for a folder in a project path' do + let(:path) { "/webdav/#{project_name}/#{mkcol_dir}/" } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(403) + end + end + + context 'for an existing directory' do + let(:directories) { ["abc"] } + let(:mkcol_dir) { directories.first } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(405) + end + + context 'as the child of it' do + let(:mkcol_dir) { "#{directories.first}/def" } + + it { is_expected.to eq [directories.first, "def"] } + end + end + + context 'for an existing file' do + let(:file_name) { mkcol_dir } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(405) + end + end + end + + describe 'put' do + let(:method) { 'PUT' } + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{put_file_name}" } + let(:put_file_name) { 'my_new_file' } + let(:put_file_contents) { 'somebody once told me the world is kinda baloney' } + let(:env) { {input: put_file_contents} } + + subject do + subject_request + expect(last_response.status).to eq(200) + ::File.read(Metis::File.from_path(bucket, "#{put_file_name}").data_block.location) + end + + it { is_expected.to eq(put_file_contents) } + + context 'for a file in the root path' do + let(:path) { "/webdav/#{put_file_name}" } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(401) + end + end + + context 'for a file in a project path' do + let(:path) { "/webdav/#{project_name}/#{put_file_name}" } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(401) + end + end + + context 'for a directory' do + let(:directories) { [put_file_name] } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(403) + end + end + + context 'for a read only file' do + let(:file_name) { put_file_name } + let(:read_only) { true } + + it 'is not allowed' do + subject_request + expect(last_response.status).to eq(403) + end + end + + context 'for an existing file' do + let(:file_name) { put_file_name } + + it { is_expected.to eq(put_file_contents) } + end + end + + describe 'get' do + let(:method) { 'GET' } + + + subject do + subject_request + expect(last_response.status).to eq(200) + last_response.headers['X-Sendfile'] + end + + describe 'for top level' do + let(:path) { '/webdav/projects/' } + + it { is_expected.to be_nil } + end + + describe 'for a project' do + let(:path) { "/webdav/projects/#{project_name}/" } + + it { is_expected.to be_nil } + end + + describe 'for a bucket' do + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/" } + + it { is_expected.to be_nil } + end + + describe 'for a file' do + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{file_name}" } + + it { is_expected.to eq(location) } + end + end + + describe 'propfind' do + let(:method) { 'PROPFIND' } + let(:env) { {'HTTP_DEPTH' => '1', input: propfind_xml} } + + subject do + subject_request + expect(last_response.status).to eq(207) + statuses.each { |s| expect(s).to match(/200 OK/) } + # Consistent ordering so that tests are less fragile. + hrefs.sort + end + + describe 'listing projects' do + let(:path) { '/webdav/projects/' } + + it { is_expected.to eq(%W[/webdav/projects/ /webdav/projects/#{project_name}/ /webdav/projects/#{other_project_name}/].sort) } + + context 'when missing access to a project' do + let(:permissions) { [[project_name, project_role]] } + + it { is_expected.to eq(%W[/webdav/projects/ /webdav/projects/#{project_name}/].sort) } + + context 'but the user is a super admin' do + let(:permissions) { [[:administration, :admin]] } + + it { is_expected.to eq(%W[/webdav/projects/ /webdav/projects/#{project_name}/ /webdav/projects/#{other_project_name}/].sort) } + end + end + end + + describe 'listing buckets' do + let(:path) { "/webdav/projects/#{other_project_name}/" } + + it { is_expected.to eq(%W[/webdav/projects/#{other_project_name}/ /webdav/projects/#{other_project_name}/#{other_bucket_name}/].sort) } + + context 'when role is less than bucket access level' do + let(:other_bucket_access) { 'editor' } + + it { is_expected.to eq(%W[/webdav/projects/#{other_project_name}/].sort) } + end + + context 'when the parent project is inaccessible' do + let(:permissions) { [] } + + it 'should fail to find the resource' do + subject_request + expect(last_response.status).to eq(404) + end + end + end + + describe 'listing folders and files' do + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/" } + + it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/ /webdav/projects/#{project_name}/#{bucket_name}/#{file_name}].sort) } + + context 'when multiple items exist together' do + let(:other_project_name) { project_name } + let(:other_bucket_name) { bucket_name } + let(:other_bucket) { bucket } + + it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/ /webdav/projects/#{project_name}/#{bucket_name}/#{file_name} /webdav/projects/#{project_name}/#{bucket_name}/#{other_file_name}].sort) } + + context 'when some items are folders' do + let(:other_directories) { ['folder_1'] } + + it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/ /webdav/projects/#{project_name}/#{bucket_name}/#{file_name} /webdav/projects/#{project_name}/#{bucket_name}/folder_1/].sort) } + end + end + + describe 'listing directories' do + let(:directories) { ['a', 'b'] } + + it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/ /webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/].sort) } + + context 'inside of other directories' do + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/" } + + it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/ /webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/#{directories[1]}/].sort) } + + context 'containing files' do + let(:path) { "/webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/#{directories[1]}/" } + + it { is_expected.to eq(%W[/webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/#{directories[1]}/ /webdav/projects/#{project_name}/#{bucket_name}/#{directories.first}/#{directories[1]}/#{file_name}].sort) } + + context 'without permissions' do + let(:permissions) { [] } + + it 'should fail to find the resource' do + subject_request + expect(last_response.status).to eq(404) + end + end + end + end + end + end + end +end