diff --git a/Gemfile b/Gemfile index f8eef12..2266ada 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,8 @@ gem "sinatra-contrib" gem "activesupport", '~> 3.2' gem "riak-client", :github => "5apps/riak-ruby-client", :branch => "invalid_uri_error" gem "fog" +gem "rest-client" +gem "redis" group :test do gem 'rake' @@ -14,4 +16,5 @@ end group :staging, :production do gem "rainbows" + gem "sentry-raven" end diff --git a/Gemfile.lock b/Gemfile.lock index e9c498b..aa7d813 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,6 +21,8 @@ GEM builder (3.2.2) eventmachine (1.0.3) excon (0.16.10) + faraday (0.9.1) + multipart-post (>= 1.2, < 3) fog (1.7.0) builder excon (~> 0.14) @@ -42,6 +44,7 @@ GEM mime-types (1.23) minitest (2.10.0) multi_json (1.10.0) + multipart-post (2.0.0) net-scp (1.0.4) net-ssh (>= 1.99.1) net-ssh (2.6.7) @@ -59,7 +62,12 @@ GEM unicorn (~> 4.8) raindrops (0.13.0) rake (0.9.2.2) + redis (3.0.7) + rest-client (1.6.7) + mime-types (>= 1.16) ruby-hmac (0.4.0) + sentry-raven (0.13.1) + faraday (>= 0.7.6) sinatra (1.4.5) rack (~> 1.4) rack-protection (~> 1.4) @@ -87,6 +95,9 @@ DEPENDENCIES purdytest rainbows rake + redis + rest-client riak-client! + sentry-raven sinatra (~> 1.4) sinatra-contrib diff --git a/config.yml.example b/config.yml.example index 2ae1ca3..f0ed36d 100644 --- a/config.yml.example +++ b/config.yml.example @@ -13,6 +13,13 @@ development: &defaults cs_binaries: rs.binaries authorizations: rs_authorizations opslog: rs_opslog + # # uncomment this section and comment the riak one + # swift: &swift_defaults + # host: "https://swift.example.com" + # # Redis is needed for the swift backend + # redis: + # host: localhost + # port: 6379 test: <<: *defaults diff --git a/lib/remote_storage/riak.rb b/lib/remote_storage/riak.rb index 0684957..17bd69b 100644 --- a/lib/remote_storage/riak.rb +++ b/lib/remote_storage/riak.rb @@ -15,7 +15,7 @@ module RemoteStorage self.settings = settings self.server = server - credentials = File.read(settings['riak_cs']['credentials_file']) + credentials = File.read(settings.riak['riak_cs']['credentials_file']) self.cs_credentials = JSON.parse(credentials) end @@ -229,8 +229,7 @@ module RemoteStorage # A URI object that can be used with HTTP backend methods def riak_uri(bucket, key) - rc = settings.symbolize_keys - URI.parse "http://#{rc[:host]}:#{rc[:http_port]}/riak/#{bucket}/#{key}" + URI.parse "http://#{settings.riak["host"]}:#{settings.riak["http_port"]}/riak/#{bucket}/#{key}" end def serializer_for(content_type) @@ -471,13 +470,13 @@ module RemoteStorage end def client - @client ||= ::Riak::Client.new(:host => settings['host'], - :http_port => settings['http_port']) + @client ||= ::Riak::Client.new(:host => settings.riak['host'], + :http_port => settings.riak['http_port']) end def data_bucket @data_bucket ||= begin - bucket = client.bucket(settings['buckets']['data']) + bucket = client.bucket(settings.riak['buckets']['data']) bucket.allow_mult = false bucket end @@ -485,7 +484,7 @@ module RemoteStorage def directory_bucket @directory_bucket ||= begin - bucket = client.bucket(settings['buckets']['directories']) + bucket = client.bucket(settings.riak['buckets']['directories']) bucket.allow_mult = false bucket end @@ -493,7 +492,7 @@ module RemoteStorage def auth_bucket @auth_bucket ||= begin - bucket = client.bucket(settings['buckets']['authorizations']) + bucket = client.bucket(settings.riak['buckets']['authorizations']) bucket.allow_mult = false bucket end @@ -501,7 +500,7 @@ module RemoteStorage def binary_bucket @binary_bucket ||= begin - bucket = client.bucket(settings['buckets']['binaries']) + bucket = client.bucket(settings.riak['buckets']['binaries']) bucket.allow_mult = false bucket end @@ -509,7 +508,7 @@ module RemoteStorage def opslog_bucket @opslog_bucket ||= begin - bucket = client.bucket(settings['buckets']['opslog']) + bucket = client.bucket(settings.riak['buckets']['opslog']) bucket.allow_mult = false bucket end @@ -520,12 +519,12 @@ module RemoteStorage :provider => 'AWS', :aws_access_key_id => cs_credentials['key_id'], :aws_secret_access_key => cs_credentials['key_secret'], - :endpoint => settings['riak_cs']['endpoint'] + :endpoint => settings.riak['riak_cs']['endpoint'] }) end def cs_binary_bucket - @cs_binary_bucket ||= cs_client.directories.create(:key => settings['buckets']['cs_binaries']) + @cs_binary_bucket ||= cs_client.directories.create(:key => settings.riak['buckets']['cs_binaries']) end end diff --git a/lib/remote_storage/swift.rb b/lib/remote_storage/swift.rb new file mode 100644 index 0000000..8c4c696 --- /dev/null +++ b/lib/remote_storage/swift.rb @@ -0,0 +1,311 @@ +require "rest_client" +require "json" +require "cgi" +require "active_support/core_ext/time/conversions" +require "active_support/core_ext/numeric/time" +require "redis" + +module RemoteStorage + class Swift + + attr_accessor :settings, :server + + def initialize(settings, server) + self.settings = settings + self.server = server + end + + def authorize_request(user, directory, token, listing=false) + request_method = server.env["REQUEST_METHOD"] + + if directory.split("/").first == "public" + return true if ["GET", "HEAD"].include?(request_method) && !listing + end + + authorizations = redis.smembers("authorizations:#{user}:#{token}") + permission = directory_permission(authorizations, directory) + + server.halt 401 unless permission + if ["PUT", "DELETE"].include? request_method + server.halt 401 unless permission == "rw" + end + end + + def get_head(user, directory, key) + url = url_for(user, directory, key) + + res = do_head_request(url) + + set_response_headers(res) + rescue RestClient::ResourceNotFound + server.halt 404 + end + + def get_data(user, directory, key) + url = url_for(user, directory, key) + + res = do_get_request(url) + + set_response_headers(res) + + none_match = (server.env["HTTP_IF_NONE_MATCH"] || "").split(",").map(&:strip) + server.halt 304 if none_match.include? %Q("#{res.headers[:etag]}") + + return res.body + rescue RestClient::ResourceNotFound + server.halt 404 + end + + def get_head_directory_listing(user, directory) + res = do_head_request("#{container_url_for(user)}/#{directory}/") + + server.headers["Content-Type"] = "application/json" + server.headers["ETag"] = %Q("#{res.headers[:etag]}") + rescue RestClient::ResourceNotFound + server.halt 404 + end + + def get_directory_listing(user, directory) + server.headers["Content-Type"] = "application/json" + + do_head_request("#{container_url_for(user)}/#{directory}/") do |response| + if response.code == 404 + return directory_listing([]).to_json + else + server.headers["ETag"] = %Q("#{response.headers[:etag]}") + none_match = (server.env["HTTP_IF_NONE_MATCH"] || "").split(",").map(&:strip) + server.halt 304 if none_match.include? %Q("#{response.headers[:etag]}") + end + end + + res = do_get_request("#{container_url_for(user)}/?format=json&path=#{directory}/") + + if body = JSON.parse(res.body) + listing = directory_listing(body) + else + puts "listing not JSON" + end + + listing.to_json + end + + def put_data(user, directory, key, data, content_type) + server.halt 409 if has_name_collision?(user, directory, key) + + url = url_for(user, directory, key) + + if required_match = server.env["HTTP_IF_MATCH"] + do_head_request(url) do |response| + server.halt 412 unless required_match == %Q("#{response.headers[:etag]}") + end + end + if server.env["HTTP_IF_NONE_MATCH"] == "*" + do_head_request(url) do |response| + server.halt 412 unless response.code == 404 + end + end + + res = do_put_request(url, data, content_type) + + if update_dir_objects(user, directory) + server.headers["ETag"] = %Q("#{res.headers[:etag]}") + server.halt 200 + else + server.halt 500 + end + end + + def delete_data(user, directory, key) + url = url_for(user, directory, key) + + if required_match = server.env["HTTP_IF_MATCH"] + do_head_request(url) do |response| + server.halt 412 unless required_match == %Q("#{response.headers[:etag]}") + end + end + + do_delete_request(url) + delete_dir_objects(user, directory) + + server.halt 200 + rescue RestClient::ResourceNotFound + server.halt 404 + end + + private + + def set_response_headers(response) + server.headers["ETag"] = %Q("#{response.headers[:etag]}") + server.headers["Content-Type"] = response.headers[:content_type] + server.headers["Content-Length"] = response.headers[:content_length] + server.headers["Last-Modified"] = response.headers[:last_modified] + end + + def extract_category(directory) + if directory.match(/^public\//) + "public/#{directory.split('/')[1]}" + else + directory.split('/').first + end + end + + def directory_permission(authorizations, directory) + authorizations = authorizations.map do |auth| + auth.index(":") ? auth.split(":") : [auth, "rw"] + end + authorizations = Hash[*authorizations.flatten] + + permission = authorizations[""] + + authorizations.each do |key, value| + if directory.match(/^(public\/)?#{key}(\/|$)/) + if permission.nil? || permission == "r" + permission = value + end + return permission if permission == "rw" + end + end + + permission + end + + def directory_listing(res_body) + listing = { + "@context" => "http://remotestorage.io/spec/folder-description", + "items" => {} + } + + res_body.each do |entry| + name = entry["name"].gsub("#{File.dirname(entry["name"])}/", '') + if name[-1] == "/" + listing["items"].merge!({ + name => { + "ETag" => entry["hash"], + } + }) + else + listing["items"].merge!({ + name => { + "ETag" => entry["hash"], + "Content-Type" => entry["content_type"], + "Content-Length" => entry["bytes"] + } + }) + end + end + + listing + end + + def has_name_collision?(user, directory, key) + # check for existing directory with the same name as the document + do_head_request("#{container_url_for(user)}/#{directory}/#{key}/") do |res| + return true if res.code == 200 + end + + # check for existing documents with the same name as one of the parent directories + parent_directories_for(directory).each do |dir| + do_head_request("#{container_url_for(user)}/#{dir}/") do |res_dir| + if res_dir.code == 200 + return false + else + do_head_request("#{container_url_for(user)}/#{dir}") do |res_key| + if res_key.code == 200 + return true + else + next + end + end + end + end + end + + false + end + + def parent_directories_for(directory) + directories = directory.split("/") + parent_directories = [] + + while directories.any? + parent_directories << directories.join("/") + directories.pop + end + + parent_directories + end + + def update_dir_objects(user, directory) + timestamp = (Time.now.to_f * 1000).to_i + + parent_directories_for(directory).each do |dir| + do_put_request("#{container_url_for(user)}/#{dir}/", timestamp.to_s, "text/plain") + end + + true + rescue + parent_directories_for(directory).each do |dir| + do_delete_request("#{container_url_for(user)}/#{dir}/") rescue false + end + + false + end + + def delete_dir_objects(user, directory) + parent_directories_for(directory).each do |dir| + if dir_empty?(user, dir) + do_delete_request("#{container_url_for(user)}/#{dir}/") + else + timestamp = (Time.now.to_f * 1000).to_i + do_put_request("#{container_url_for(user)}/#{dir}/", timestamp.to_s, "text/plain") + end + end + end + + def dir_empty?(user, dir) + do_get_request("#{container_url_for(user)}/?format=plain&limit=1&path=#{dir}/") do |res| + return res.headers[:content_length] == "0" + end + end + + def container_url_for(user) + "#{base_url}/#{container_for(user)}" + end + + def url_for(user, directory, key) + "#{container_url_for(user)}/#{directory}/#{key}" + end + + def base_url + @base_url ||= settings.swift["host"] + end + + def container_for(user) + "rs:#{settings.environment.to_s.chars.first}:#{user}" + end + + def default_headers + @default_headers ||= {"x-auth-token" => settings.swift["token"]} + end + + def do_put_request(url, data, content_type) + RestClient.put(url, data, default_headers.merge({content_type: content_type})) + end + + def do_get_request(url, &block) + RestClient.get(url, default_headers, &block) + end + + def do_head_request(url, &block) + RestClient.head(url, default_headers, &block) + end + + def do_delete_request(url) + RestClient.delete(url, default_headers) + end + + def redis + @redis ||= Redis.new(host: settings.redis["host"], port: settings.redis["port"]) + end + end +end diff --git a/liquor-cabinet.rb b/liquor-cabinet.rb index c815abe..d74d8dd 100644 --- a/liquor-cabinet.rb +++ b/liquor-cabinet.rb @@ -5,6 +5,7 @@ require "sinatra/base" require 'sinatra/config_file' require "sinatra/reloader" require "remote_storage/riak" +require "remote_storage/swift" class LiquorCabinet < Sinatra::Base @@ -23,11 +24,22 @@ class LiquorCabinet < Sinatra::Base configure :development do register Sinatra::Reloader + also_reload "lib/remote_storage/*.rb" enable :logging end configure :production, :staging do require "rack/common_logger" + if ENV['SENTRY_DSN'] + require "raven" + + Raven.configure do |config| + config.dsn = ENV['SENTRY_DSN'] + config.tags = { environment: settings.environment.to_s } + end + + use Raven::Rack + end end # @@ -62,17 +74,19 @@ class LiquorCabinet < Sinatra::Base end ["/:user/*/:key", "/:user/:key"].each do |path| - get path do - storage.get_data(@user, @directory, @key) - end - head path do storage.get_head(@user, @directory, @key) end + get path do + storage.get_data(@user, @directory, @key) + end + put path do data = request.body.read + halt 422 unless env['CONTENT_TYPE'] + if env['CONTENT_TYPE'] == "application/x-www-form-urlencoded" content_type = "text/plain; charset=utf-8" else @@ -88,23 +102,29 @@ class LiquorCabinet < Sinatra::Base end ["/:user/*/", "/:user/"].each do |path| - get path do - storage.get_directory_listing(@user, @directory) - end - head path do storage.get_head_directory_listing(@user, @directory) end + + get path do + storage.get_directory_listing(@user, @directory) + end end private def storage @storage ||= begin - if settings.riak - RemoteStorage::Riak.new(settings.riak, self) - # elsif settings.redis - # include RemoteStorage::Redis + if settings.respond_to? :riak + RemoteStorage::Riak.new(settings, self) + elsif settings.respond_to? :swift + settings.swift["token"] = File.read("tmp/swift_token.txt") + RemoteStorage::Swift.new(settings, self) + else + puts <<-EOF +You need to set one storage backend in your config.yml file. +Riak and Swift are currently supported. See config.yml.example. + EOF end end end diff --git a/spec/riak_spec.rb b/spec/riak_spec.rb index cd896e9..b3116f2 100644 --- a/spec/riak_spec.rb +++ b/spec/riak_spec.rb @@ -17,19 +17,11 @@ describe "App with Riak backend" do head "/jimmy/public/foo" end - it "returns an empty body" do + it "works" do last_response.status.must_equal 200 last_response.body.must_equal "" - end - - it "has an ETag header set" do - last_response.status.must_equal 200 last_response.headers["ETag"].wont_be_nil - end - - it "has a Content-Length header set" do - last_response.status.must_equal 200 - last_response.headers["Content-Length"].must_equal '14' + last_response.headers["Content-Length"].must_equal "14" end end @@ -44,478 +36,180 @@ describe "App with Riak backend" do get "/jimmy/public/foo" end - it "returns the value on all get requests" do + it "works" do last_response.status.must_equal 200 last_response.body.must_equal "some text data" - end - - it "has an ETag header set" do - last_response.status.must_equal 200 last_response.headers["ETag"].wont_be_nil - end - - it "has a Content-Length header set" do - last_response.status.must_equal 200 last_response.headers["Content-Length"].must_equal "14" - end - - it "has caching headers set" do - last_response.status.must_equal 200 last_response.headers["Expires"].must_equal "0" end + + describe "empty file" do + before do + object = data_bucket.new("jimmy:public:empty") + object.content_type = "text/plain" + object.data = "" + object.store + + get "/jimmy/public/empty" + end + + it "returns an empty body" do + last_response.status.must_equal 200 + # Rack::MockRequest turns the body into a string. We can't use + # `last_response.body` to check for nil, because: + # >> [nil].join + # => "" + last_response.body.must_equal '' + last_response.headers["Content-Length"].must_equal '0' + end + end end - describe "empty file" do + describe "GET data with custom content type" do before do - object = data_bucket.new("jimmy:public:empty") + object = data_bucket.new("jimmy:public:magic") + object.content_type = "text/magic" + object.raw_data = "some text data" + object.store + end + + it "returns the value with the correct content type" do + get "/jimmy/public/magic" + + last_response.status.must_equal 200 + last_response.content_type.must_equal "text/magic" + last_response.body.must_equal "some text data" + end + end + + describe "private data" do + before do + object = data_bucket.new("jimmy:documents:foo") object.content_type = "text/plain" - object.data = "" + object.data = "some private text data" object.store - get "/jimmy/public/empty" + @etag = object.etag + + auth = auth_bucket.new("jimmy:123") + auth.data = ["documents", "public"] + auth.store end - it "returns an empty body" do - last_response.status.must_equal 200 - # Rack::MockRequest turns the body into a string. We can't use - # `last_response.body` to check for nil, because: - # >> [nil].join - # => "" - last_response.body.must_equal '' - last_response.headers["Content-Length"].must_equal '0' - end - end - end - - describe "GET data with custom content type" do - before do - object = data_bucket.new("jimmy:public:magic") - object.content_type = "text/magic" - object.raw_data = "some text data" - object.store - end - - it "returns the value with the correct content type" do - get "/jimmy/public/magic" - - last_response.status.must_equal 200 - last_response.content_type.must_equal "text/magic" - last_response.body.must_equal "some text data" - end - end - - describe "private data" do - before do - object = data_bucket.new("jimmy:documents:foo") - object.content_type = "text/plain" - object.data = "some private text data" - object.store - - @etag = object.etag - - auth = auth_bucket.new("jimmy:123") - auth.data = ["documents", "public"] - auth.store - end - - describe "HEAD" do - before do - header "Authorization", "Bearer 123" - head "/jimmy/documents/foo" - end - - it "returns an empty body" do - last_response.status.must_equal 200 - last_response.body.must_equal "" - end - - it "has an ETag header set" do - last_response.status.must_equal 200 - last_response.headers["ETag"].wont_be_nil - end - - it "has a Content-Length header set" do - last_response.status.must_equal 200 - last_response.headers["Content-Length"].must_equal '22' - end - end - - describe "HEAD nonexisting key" do - it "returns a 404" do - header "Authorization", "Bearer 123" - head "/jimmy/documents/somestupidkey" - - last_response.status.must_equal 404 - end - end - - describe "GET" do - before do - header "Authorization", "Bearer 123" - end - - it "returns the value" do - get "/jimmy/documents/foo" - - last_response.status.must_equal 200 - last_response.body.must_equal "some private text data" - end - - describe "when If-None-Match header is set" do - it "responds with 'not modified' when it matches the current ETag" do - header "If-None-Match", @etag - get "/jimmy/documents/foo" - - last_response.status.must_equal 304 - last_response.body.must_be_empty - last_response.headers["ETag"].must_equal @etag - end - - it "responds normally when it does not match the current ETag" do - header "If-None-Match", "FOO" - get "/jimmy/documents/foo" - - last_response.status.must_equal 200 - last_response.body.must_equal "some private text data" - end - end - - describe "when If-None-Match header is set with multiple revisions" do - it "responds with 'not modified' when it contains the current ETag" do - header "If-None-Match", "DEADBEEF,#{@etag},F00BA4" - get "/jimmy/documents/foo" - - last_response.status.must_equal 304 - last_response.body.must_be_empty - last_response.headers["ETag"].must_equal @etag - end - - it "responds normally when it does not contain the current ETag" do - header "If-None-Match", "FOO,BAR" - get "/jimmy/documents/foo" - - last_response.status.must_equal 200 - last_response.body.must_equal "some private text data" - end - end - end - - describe "GET nonexisting key" do - it "returns a 404" do - header "Authorization", "Bearer 123" - get "/jimmy/documents/somestupidkey" - - last_response.status.must_equal 404 - end - end - - describe "PUT" do - before do - header "Authorization", "Bearer 123" - end - - describe "with implicit content type" do + describe "HEAD" do before do - put "/jimmy/documents/bar", "another text" + header "Authorization", "Bearer 123" + head "/jimmy/documents/foo" end - it "saves the value" do - last_response.status.must_equal 201 + it "works" do + last_response.status.must_equal 200 last_response.body.must_equal "" - data_bucket.get("jimmy:documents:bar").data.must_equal "another text" - end - - it "stores the data as plain text with utf-8 encoding" do - data_bucket.get("jimmy:documents:bar").content_type.must_equal "text/plain; charset=utf-8" - end - - it "sets the ETag header" do last_response.headers["ETag"].wont_be_nil - end - - it "indexes the data set" do - indexes = data_bucket.get("jimmy:documents:bar").indexes - indexes["user_id_bin"].must_be_kind_of Set - indexes["user_id_bin"].must_include "jimmy" - - indexes["directory_bin"].must_include "documents" - end - - it "logs the operation" do - objects = [] - opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } - - log_entry = objects.select{|o| o.data["count"] == 1}.first - log_entry.data["size"].must_equal 12 - log_entry.data["category"].must_equal "documents" - log_entry.indexes["user_id_bin"].must_include "jimmy" + last_response.headers["Content-Length"].must_equal "22" end end - describe "with explicit content type" do - before do - header "Content-Type", "application/json" - put "/jimmy/documents/jason", '{"foo": "bar", "unhosted": 1}' - end - - it "saves the value (as JSON)" do - last_response.status.must_equal 201 - data_bucket.get("jimmy:documents:jason").data.must_be_kind_of Hash - data_bucket.get("jimmy:documents:jason").data.must_equal({"foo" => "bar", "unhosted" => 1}) - end - - it "uses the requested content type" do - data_bucket.get("jimmy:documents:jason").content_type.must_equal "application/json" - end - - it "delivers the data correctly" do + describe "HEAD nonexisting key" do + it "returns a 404" do header "Authorization", "Bearer 123" - get "/jimmy/documents/jason" + head "/jimmy/documents/somestupidkey" - last_response.body.must_equal '{"foo":"bar","unhosted":1}' - last_response.content_type.must_equal "application/json" + last_response.status.must_equal 404 end end - describe "with arbitrary content type" do + describe "GET" do before do - header "Content-Type", "text/magic" - put "/jimmy/documents/magic", "pure magic" - end - - it "saves the value" do - last_response.status.must_equal 201 - data_bucket.get("jimmy:documents:magic").raw_data.must_equal "pure magic" - end - - it "uses the requested content type" do - data_bucket.get("jimmy:documents:magic").content_type.must_equal "text/magic" - end - - it "delivers the data correctly" do header "Authorization", "Bearer 123" - get "/jimmy/documents/magic" - - last_response.body.must_equal "pure magic" - last_response.content_type.must_equal "text/magic" - end - end - - describe "with content type containing the encoding" do - before do - header "Content-Type", "application/json; charset=UTF-8" - put "/jimmy/documents/jason", '{"foo": "bar", "unhosted": 1}' end - it "saves the value (as JSON)" do - last_response.status.must_equal 201 - data_bucket.get("jimmy:documents:jason").data.must_be_kind_of Hash - data_bucket.get("jimmy:documents:jason").data.must_equal({"foo" => "bar", "unhosted" => 1}) - end - - it "uses the requested content type" do - data_bucket.get("jimmy:documents:jason").content_type.must_equal "application/json; charset=UTF-8" - end - - it "delivers the data correctly" do - get "/jimmy/documents/jason" - - last_response.body.must_equal '{"foo":"bar","unhosted":1}' - last_response.content_type.must_equal "application/json; charset=UTF-8" - end - end - - describe "naming collissions between documents and directories" do - before do - put "/jimmy/documents/archive/document", "lorem ipsum" - end - - it "responds with 409 when directory with same name already exists" do - put "/jimmy/documents/archive", "some awesome content" - - last_response.status.must_equal 409 - - lambda { - data_bucket.get("jimmy:documents/archive") - }.must_raise Riak::HTTPFailedRequest - end - - it "responds with 409 when there is an existing document with same name as one of the directories" do - put "/jimmy/documents/archive/document/subdir/doc", "some awesome content" - - last_response.status.must_equal 409 - - lambda { - data_bucket.get("jimmy:documents/archive/document/subdir/doc") - }.must_raise Riak::HTTPFailedRequest - end - end - - describe "with existing content" do - before do - put "/jimmy/documents/archive/foo", "lorem ipsum" - end - - it "saves the value" do - put "/jimmy/documents/archive/foo", "some awesome content" + it "returns the value" do + get "/jimmy/documents/foo" last_response.status.must_equal 200 - data_bucket.get("jimmy:documents/archive:foo").data.must_equal "some awesome content" - end - - it "logs the operations" do - put "/jimmy/documents/archive/foo", "some awesome content" - - objects = [] - opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } - - create_entry = objects.select{|o| o.data["count"] == 1}.first - create_entry.data["size"].must_equal 11 - create_entry.data["category"].must_equal "documents" - create_entry.indexes["user_id_bin"].must_include "jimmy" - - update_entry = objects.select{|o| o.data["count"] == 0}.first - update_entry.data["size"].must_equal 9 - update_entry.data["category"].must_equal "documents" - update_entry.indexes["user_id_bin"].must_include "jimmy" - end - - it "changes the ETag header" do - old_etag = last_response.headers["ETag"] - put "/jimmy/documents/archive/foo", "some awesome content" - - last_response.headers["ETag"].wont_be_nil - last_response.headers["ETag"].wont_equal old_etag - end - - describe "when If-Match header is set" do - it "allows the request if the header matches the current ETag" do - old_etag = last_response.headers["ETag"] - header "If-Match", old_etag - - put "/jimmy/documents/archive/foo", "some awesome content" - last_response.status.must_equal 200 - - get "/jimmy/documents/archive/foo" - last_response.body.must_equal "some awesome content" - end - - it "fails the request if the header does not match the current ETag" do - header "If-Match", "WONTMATCH" - - put "/jimmy/documents/archive/foo", "some awesome content" - last_response.status.must_equal 412 - - get "/jimmy/documents/archive/foo" - last_response.body.must_equal "lorem ipsum" - end + last_response.body.must_equal "some private text data" end describe "when If-None-Match header is set" do + it "responds with 'not modified' when it matches the current ETag" do + header "If-None-Match", @etag + get "/jimmy/documents/foo" + + last_response.status.must_equal 304 + last_response.body.must_be_empty + last_response.headers["ETag"].must_equal @etag + end + + it "responds normally when it does not match the current ETag" do + header "If-None-Match", "FOO" + get "/jimmy/documents/foo" + + last_response.status.must_equal 200 + last_response.body.must_equal "some private text data" + end + end + + describe "when If-None-Match header is set with multiple revisions" do + it "responds with 'not modified' when it contains the current ETag" do + header "If-None-Match", "DEADBEEF,#{@etag},F00BA4" + get "/jimmy/documents/foo" + + last_response.status.must_equal 304 + last_response.body.must_be_empty + last_response.headers["ETag"].must_equal @etag + end + + it "responds normally when it does not contain the current ETag" do + header "If-None-Match", "FOO,BAR" + get "/jimmy/documents/foo" + + last_response.status.must_equal 200 + last_response.body.must_equal "some private text data" + end + end + end + + describe "GET nonexisting key" do + it "returns a 404" do + header "Authorization", "Bearer 123" + get "/jimmy/documents/somestupidkey" + + last_response.status.must_equal 404 + end + end + + describe "PUT" do + before do + header "Authorization", "Bearer 123" + end + + describe "with implicit content type" do before do - header "If-None-Match", "*" + put "/jimmy/documents/bar", "another text" end - it "fails when the document already exists" do - put "/jimmy/documents/archive/foo", "some awesome content" - - last_response.status.must_equal 412 - - get "/jimmy/documents/archive/foo" - last_response.body.must_equal "lorem ipsum" - end - - it "succeeds when the document does not exist" do - put "/jimmy/documents/archive/bar", "my little content" - + it "saves the value" do last_response.status.must_equal 201 - end - end - end - - describe "exsting content without serializer registered for the given content-type" do - before do - header "Content-Type", "text/html; charset=UTF-8" - put "/jimmy/documents/html", '' - put "/jimmy/documents/html", '' - end - - it "saves the value" do - last_response.status.must_equal 200 - data_bucket.get("jimmy:documents:html").raw_data.must_equal "" - end - - it "uses the requested content type" do - data_bucket.get("jimmy:documents:html").content_type.must_equal "text/html; charset=UTF-8" - end - end - - describe "public data" do - before do - put "/jimmy/public/documents/notes/foo", "note to self" - end - - it "saves the value" do - last_response.status.must_equal 201 - data_bucket.get("jimmy:public/documents/notes:foo").data.must_equal "note to self" - end - - it "logs the operation" do - objects = [] - opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } - - log_entry = objects.select{|o| o.data["count"] == 1}.first - log_entry.data["size"].must_equal 12 - log_entry.data["category"].must_equal "public/documents" - log_entry.indexes["user_id_bin"].must_include "jimmy" - end - end - - context "with binary data" do - context "binary charset in content-type header" do - before do - header "Content-Type", "image/jpeg; charset=binary" - filename = File.join(File.expand_path(File.dirname(__FILE__)), "fixtures", "rockrule.jpeg") - @image = File.open(filename, "r").read - put "/jimmy/documents/jaypeg", @image + last_response.body.must_equal "" + data_bucket.get("jimmy:documents:bar").data.must_equal "another text" end - it "uses the requested content type" do - get "/jimmy/documents/jaypeg" - - last_response.status.must_equal 200 - last_response.content_type.must_equal "image/jpeg; charset=binary" + it "stores the data as plain text with utf-8 encoding" do + data_bucket.get("jimmy:documents:bar").content_type.must_equal "text/plain; charset=utf-8" end - it "delivers the data correctly" do - get "/jimmy/documents/jaypeg" - - last_response.status.must_equal 200 - last_response.body.must_equal @image - end - - it "responds with an ETag header" do + it "sets the ETag header" do last_response.headers["ETag"].wont_be_nil - etag = last_response.headers["ETag"] - - get "/jimmy/documents/jaypeg" - - last_response.headers["ETag"].wont_be_nil - last_response.headers["ETag"].must_equal etag end - it "responds with a Content-Length header" do - get "/jimmy/documents/jaypeg" + it "indexes the data set" do + indexes = data_bucket.get("jimmy:documents:bar").indexes + indexes["user_id_bin"].must_be_kind_of Set + indexes["user_id_bin"].must_include "jimmy" - last_response.headers["Content-Length"].must_equal "16044" - end - - it "changes the ETag when updating the file" do - old_etag = last_response.headers["ETag"] - put "/jimmy/documents/jaypeg", @image - - last_response.headers["ETag"].wont_be_nil - last_response.headers["ETag"].wont_equal old_etag + indexes["directory_bin"].must_include "documents" end it "logs the operation" do @@ -523,12 +217,233 @@ describe "App with Riak backend" do opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } log_entry = objects.select{|o| o.data["count"] == 1}.first - log_entry.data["size"].must_equal 16044 + log_entry.data["size"].must_equal 12 log_entry.data["category"].must_equal "documents" log_entry.indexes["user_id_bin"].must_include "jimmy" end + end - context "overwriting existing file with same file" do + describe "with explicit content type" do + before do + header "Content-Type", "application/json" + put "/jimmy/documents/jason", '{"foo": "bar", "unhosted": 1}' + end + + it "saves the value (as JSON)" do + last_response.status.must_equal 201 + data_bucket.get("jimmy:documents:jason").data.must_be_kind_of Hash + data_bucket.get("jimmy:documents:jason").data.must_equal({"foo" => "bar", "unhosted" => 1}) + end + + it "uses the requested content type" do + data_bucket.get("jimmy:documents:jason").content_type.must_equal "application/json" + end + + it "delivers the data correctly" do + header "Authorization", "Bearer 123" + get "/jimmy/documents/jason" + + last_response.body.must_equal '{"foo":"bar","unhosted":1}' + last_response.content_type.must_equal "application/json" + end + end + + describe "with arbitrary content type" do + before do + header "Content-Type", "text/magic" + put "/jimmy/documents/magic", "pure magic" + end + + it "saves the value" do + last_response.status.must_equal 201 + data_bucket.get("jimmy:documents:magic").raw_data.must_equal "pure magic" + end + + it "uses the requested content type" do + data_bucket.get("jimmy:documents:magic").content_type.must_equal "text/magic" + end + + it "delivers the data correctly" do + header "Authorization", "Bearer 123" + get "/jimmy/documents/magic" + + last_response.body.must_equal "pure magic" + last_response.content_type.must_equal "text/magic" + end + end + + describe "with content type containing the encoding" do + before do + header "Content-Type", "application/json; charset=UTF-8" + put "/jimmy/documents/jason", '{"foo": "bar", "unhosted": 1}' + end + + it "saves the value (as JSON)" do + last_response.status.must_equal 201 + data_bucket.get("jimmy:documents:jason").data.must_be_kind_of Hash + data_bucket.get("jimmy:documents:jason").data.must_equal({"foo" => "bar", "unhosted" => 1}) + end + + it "uses the requested content type" do + data_bucket.get("jimmy:documents:jason").content_type.must_equal "application/json; charset=UTF-8" + end + + it "delivers the data correctly" do + get "/jimmy/documents/jason" + + last_response.body.must_equal '{"foo":"bar","unhosted":1}' + last_response.content_type.must_equal "application/json; charset=UTF-8" + end + end + + describe "naming collisions between documents and directories" do + before do + put "/jimmy/documents/archive/document", "lorem ipsum" + end + + it "responds with 409 when directory with same name already exists" do + put "/jimmy/documents/archive", "some awesome content" + + last_response.status.must_equal 409 + + lambda { + data_bucket.get("jimmy:documents/archive") + }.must_raise Riak::HTTPFailedRequest + end + + it "responds with 409 when there is an existing document with same name as one of the directories" do + put "/jimmy/documents/archive/document/subdir/doc", "some awesome content" + + last_response.status.must_equal 409 + + lambda { + data_bucket.get("jimmy:documents/archive/document/subdir/doc") + }.must_raise Riak::HTTPFailedRequest + end + end + + describe "with existing content" do + before do + put "/jimmy/documents/archive/foo", "lorem ipsum" + end + + it "saves the value" do + put "/jimmy/documents/archive/foo", "some awesome content" + + last_response.status.must_equal 200 + data_bucket.get("jimmy:documents/archive:foo").data.must_equal "some awesome content" + end + + it "logs the operations" do + put "/jimmy/documents/archive/foo", "some awesome content" + + objects = [] + opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } + + create_entry = objects.select{|o| o.data["count"] == 1}.first + create_entry.data["size"].must_equal 11 + create_entry.data["category"].must_equal "documents" + create_entry.indexes["user_id_bin"].must_include "jimmy" + + update_entry = objects.select{|o| o.data["count"] == 0}.first + update_entry.data["size"].must_equal 9 + update_entry.data["category"].must_equal "documents" + update_entry.indexes["user_id_bin"].must_include "jimmy" + end + + it "changes the ETag header" do + old_etag = last_response.headers["ETag"] + put "/jimmy/documents/archive/foo", "some awesome content" + + last_response.headers["ETag"].wont_be_nil + last_response.headers["ETag"].wont_equal old_etag + end + + describe "when If-Match header is set" do + it "allows the request if the header matches the current ETag" do + old_etag = last_response.headers["ETag"] + header "If-Match", old_etag + + put "/jimmy/documents/archive/foo", "some awesome content" + last_response.status.must_equal 200 + + get "/jimmy/documents/archive/foo" + last_response.body.must_equal "some awesome content" + end + + it "fails the request if the header does not match the current ETag" do + header "If-Match", "WONTMATCH" + + put "/jimmy/documents/archive/foo", "some awesome content" + last_response.status.must_equal 412 + + get "/jimmy/documents/archive/foo" + last_response.body.must_equal "lorem ipsum" + end + end + + describe "when If-None-Match header is set" do + before do + header "If-None-Match", "*" + end + + it "fails when the document already exists" do + put "/jimmy/documents/archive/foo", "some awesome content" + + last_response.status.must_equal 412 + + get "/jimmy/documents/archive/foo" + last_response.body.must_equal "lorem ipsum" + end + + it "succeeds when the document does not exist" do + put "/jimmy/documents/archive/bar", "my little content" + + last_response.status.must_equal 201 + end + end + end + + describe "exsting content without serializer registered for the given content-type" do + before do + header "Content-Type", "text/html; charset=UTF-8" + put "/jimmy/documents/html", '' + put "/jimmy/documents/html", '' + end + + it "saves the value" do + last_response.status.must_equal 200 + data_bucket.get("jimmy:documents:html").raw_data.must_equal "" + end + + it "uses the requested content type" do + data_bucket.get("jimmy:documents:html").content_type.must_equal "text/html; charset=UTF-8" + end + end + + describe "public data" do + before do + put "/jimmy/public/documents/notes/foo", "note to self" + end + + it "saves the value" do + last_response.status.must_equal 201 + data_bucket.get("jimmy:public/documents/notes:foo").data.must_equal "note to self" + end + + it "logs the operation" do + objects = [] + opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } + + log_entry = objects.select{|o| o.data["count"] == 1}.first + log_entry.data["size"].must_equal 12 + log_entry.data["category"].must_equal "public/documents" + log_entry.indexes["user_id_bin"].must_include "jimmy" + end + end + + context "with binary data" do + context "binary charset in content-type header" do before do header "Content-Type", "image/jpeg; charset=binary" filename = File.join(File.expand_path(File.dirname(__FILE__)), "fixtures", "rockrule.jpeg") @@ -536,267 +451,324 @@ describe "App with Riak backend" do put "/jimmy/documents/jaypeg", @image end - it "doesn't log the operation" do + it "uses the requested content type" do + get "/jimmy/documents/jaypeg" + + last_response.status.must_equal 200 + last_response.content_type.must_equal "image/jpeg; charset=binary" + end + + it "delivers the data correctly" do + get "/jimmy/documents/jaypeg" + + last_response.status.must_equal 200 + last_response.body.must_equal @image + end + + it "responds with an ETag header" do + last_response.headers["ETag"].wont_be_nil + etag = last_response.headers["ETag"] + + get "/jimmy/documents/jaypeg" + + last_response.headers["ETag"].wont_be_nil + last_response.headers["ETag"].must_equal etag + end + + it "responds with a Content-Length header" do + get "/jimmy/documents/jaypeg" + + last_response.headers["Content-Length"].must_equal "16044" + end + + it "changes the ETag when updating the file" do + old_etag = last_response.headers["ETag"] + put "/jimmy/documents/jaypeg", @image + + last_response.headers["ETag"].wont_be_nil + last_response.headers["ETag"].wont_equal old_etag + end + + it "logs the operation" do objects = [] opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } - objects.size.must_equal 1 - end - end - - context "overwriting existing file with different file" do - before do - header "Content-Type", "image/jpeg; charset=binary" - filename = File.join(File.expand_path(File.dirname(__FILE__)), "fixtures", "rockrule.jpeg") - @image = File.open(filename, "r").read - put "/jimmy/documents/jaypeg", @image+"foo" - end - - it "logs the operation changing only the size" do - objects = [] - opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } - - objects.size.must_equal 2 - - log_entry = objects.select{|o| o.data["count"] == 0}.first - log_entry.data["size"].must_equal 3 + log_entry = objects.select{|o| o.data["count"] == 1}.first + log_entry.data["size"].must_equal 16044 log_entry.data["category"].must_equal "documents" log_entry.indexes["user_id_bin"].must_include "jimmy" end + + context "overwriting existing file with same file" do + before do + header "Content-Type", "image/jpeg; charset=binary" + filename = File.join(File.expand_path(File.dirname(__FILE__)), "fixtures", "rockrule.jpeg") + @image = File.open(filename, "r").read + put "/jimmy/documents/jaypeg", @image + end + + it "doesn't log the operation" do + objects = [] + opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } + + objects.size.must_equal 1 + end + end + + context "overwriting existing file with different file" do + before do + header "Content-Type", "image/jpeg; charset=binary" + filename = File.join(File.expand_path(File.dirname(__FILE__)), "fixtures", "rockrule.jpeg") + @image = File.open(filename, "r").read + put "/jimmy/documents/jaypeg", @image+"foo" + end + + it "logs the operation changing only the size" do + objects = [] + opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } + + objects.size.must_equal 2 + + log_entry = objects.select{|o| o.data["count"] == 0}.first + log_entry.data["size"].must_equal 3 + log_entry.data["category"].must_equal "documents" + log_entry.indexes["user_id_bin"].must_include "jimmy" + end + end + end + + context "no binary charset in content-type header" do + before do + header "Content-Type", "image/jpeg" + filename = File.join(File.expand_path(File.dirname(__FILE__)), "fixtures", "rockrule.jpeg") + @image = File.open(filename, "r").read + put "/jimmy/documents/jaypeg", @image + end + + it "uses the requested content type" do + get "/jimmy/documents/jaypeg" + + last_response.status.must_equal 200 + last_response.content_type.must_equal "image/jpeg" + end + + it "delivers the data correctly" do + get "/jimmy/documents/jaypeg" + + last_response.status.must_equal 200 + last_response.body.must_equal @image + end end end - context "no binary charset in content-type header" do + context "with escaped key" do before do - header "Content-Type", "image/jpeg" - filename = File.join(File.expand_path(File.dirname(__FILE__)), "fixtures", "rockrule.jpeg") - @image = File.open(filename, "r").read - put "/jimmy/documents/jaypeg", @image - end - - it "uses the requested content type" do - get "/jimmy/documents/jaypeg" - - last_response.status.must_equal 200 - last_response.content_type.must_equal "image/jpeg" + put "/jimmy/documents/bar%3Abaz/http%3A%2F%2F5apps.com", "super website" end it "delivers the data correctly" do - get "/jimmy/documents/jaypeg" + header "Authorization", "Bearer 123" + get "/jimmy/documents/bar%3Abaz/http%3A%2F%2F5apps.com" + last_response.body.must_equal 'super website' + end + end + + context "with unescaped key" do + before do + put "/jimmy/documents/bar:baz/john@doe.com", "John Doe" + end + + it "lists the document in the directory" do + get "/jimmy/documents/bar:baz/" + + content = JSON.parse(last_response.body) + content["items"]["john@doe.com"].wont_be_nil + end + + it "delivers the data correctly" do + get "/jimmy/documents/bar:baz/john@doe.com" + + last_response.body.must_equal "John Doe" + end + end + + context "escaped square brackets in key" do + before do + put "/jimmy/documents/gracehopper%5B1%5D.jpg", "super image" + end + + it "delivers the data correctly" do + header "Authorization", "Bearer 123" + get "/jimmy/documents/gracehopper%5B1%5D.jpg" + + last_response.body.must_equal "super image" + end + end + + context "invalid JSON" do + context "empty body" do + before do + header "Content-Type", "application/json" + put "/jimmy/documents/jason", "" + end + + it "saves an empty JSON object" do + last_response.status.must_equal 201 + data_bucket.get("jimmy:documents:jason").data.must_be_kind_of Hash + data_bucket.get("jimmy:documents:jason").data.must_equal({}) + end + end + + context "unparsable JSON" do + before do + header "Content-Type", "application/json" + put "/jimmy/documents/jason", "foo" + end + + it "returns a 422" do + last_response.status.must_equal 422 + end + end + end + end + + describe "DELETE" do + before do + header "Authorization", "Bearer 123" + end + + describe "basics" do + before do + delete "/jimmy/documents/foo" + end + + it "removes the key" do last_response.status.must_equal 200 - last_response.body.must_equal @image + lambda { + data_bucket.get("jimmy:documents:foo") + }.must_raise Riak::HTTPFailedRequest + end + + it "logs the operation" do + objects = [] + opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } + + log_entry = objects.select{|o| o.data["count"] == -1}.first + log_entry.data["size"].must_equal(-22) + log_entry.data["category"].must_equal "documents" + log_entry.indexes["user_id_bin"].must_include "jimmy" end end - end - context "with escaped key" do - before do - put "/jimmy/documents/bar%3Abaz/http%3A%2F%2F5apps.com", "super website" - end - - it "delivers the data correctly" do - header "Authorization", "Bearer 123" - get "/jimmy/documents/bar%3Abaz/http%3A%2F%2F5apps.com" - - last_response.body.must_equal 'super website' - end - end - - context "with unescaped key" do - before do - put "/jimmy/documents/bar:baz/john@doe.com", "John Doe" - end - - it "lists the document in the directory" do - get "/jimmy/documents/bar:baz/" - - content = JSON.parse(last_response.body) - content["items"]["john@doe.com"].wont_be_nil - end - - it "delivers the data correctly" do - get "/jimmy/documents/bar:baz/john@doe.com" - - last_response.body.must_equal "John Doe" - end - end - - context "escaped square brackets in key" do - before do - put "/jimmy/documents/gracehopper%5B1%5D.jpg", "super image" - end - - it "delivers the data correctly" do - header "Authorization", "Bearer 123" - get "/jimmy/documents/gracehopper%5B1%5D.jpg" - - last_response.body.must_equal "super image" - end - end - - context "invalid JSON" do - context "empty body" do + context "non-existing object" do before do - header "Content-Type", "application/json" - put "/jimmy/documents/jason", "" + delete "/jimmy/documents/foozius" end - it "saves an empty JSON object" do - last_response.status.must_equal 201 - data_bucket.get("jimmy:documents:jason").data.must_be_kind_of Hash - data_bucket.get("jimmy:documents:jason").data.must_equal({}) + it "responds with 404" do + last_response.status.must_equal 404 + end + + it "doesn't log the operation" do + objects = [] + opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } + objects.select{|o| o.data["count"] == -1}.size.must_equal 0 end end - context "unparsable JSON" do - before do - header "Content-Type", "application/json" - put "/jimmy/documents/jason", "foo" + context "when an If-Match header is given" do + it "allows the request if it matches the current ETag" do + get "/jimmy/documents/foo" + old_etag = last_response.headers["ETag"] + header "If-Match", old_etag + + delete "/jimmy/documents/foo" + last_response.status.must_equal 200 + + get "/jimmy/documents/foo" + last_response.status.must_equal 404 end - it "returns a 422" do - last_response.status.must_equal 422 + it "fails the request if it does not match the current ETag" do + header "If-Match", "WONTMATCH" + + delete "/jimmy/documents/foo" + last_response.status.must_equal 412 + + get "/jimmy/documents/foo" + last_response.status.must_equal 200 + last_response.body.must_equal "some private text data" + end + end + + context "binary data" do + before do + header "Content-Type", "image/jpeg; charset=binary" + filename = File.join(File.expand_path(File.dirname(__FILE__)), "fixtures", "rockrule.jpeg") + @image = File.open(filename, "r").read + put "/jimmy/documents/jaypeg", @image + + delete "/jimmy/documents/jaypeg" + end + + it "removes the main object" do + last_response.status.must_equal 200 + lambda { + data_bucket.get("jimmy:documents:jaypeg") + }.must_raise Riak::HTTPFailedRequest + end + + it "removes the binary object" do + last_response.status.must_equal 200 + + binary = cs_binary_bucket.files.get("jimmy:documents:jaypeg") + binary.must_be_nil + end + + it "logs the operation" do + objects = [] + opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } + + log_entry = objects.select{|o| o.data["count"] == -1 && o.data["size"] == -16044}.first + log_entry.data["category"].must_equal "documents" + log_entry.indexes["user_id_bin"].must_include "jimmy" end end end end - describe "DELETE" do + describe "unauthorized access" do before do - header "Authorization", "Bearer 123" + auth = auth_bucket.new("jimmy:123") + auth.data = ["documents", "public"] + auth.store + + header "Authorization", "Bearer 321" end - describe "basics" do - before do - delete "/jimmy/documents/foo" - end - - it "removes the key" do - last_response.status.must_equal 200 - lambda { - data_bucket.get("jimmy:documents:foo") - }.must_raise Riak::HTTPFailedRequest - end - - it "logs the operation" do - objects = [] - opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } - - log_entry = objects.select{|o| o.data["count"] == -1}.first - log_entry.data["size"].must_equal(-22) - log_entry.data["category"].must_equal "documents" - log_entry.indexes["user_id_bin"].must_include "jimmy" - end - end - - context "non-existing object" do - before do - delete "/jimmy/documents/foozius" - end - - it "responds with 404" do - last_response.status.must_equal 404 - end - - it "doesn't log the operation" do - objects = [] - opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } - objects.select{|o| o.data["count"] == -1}.size.must_equal 0 - end - end - - context "when an If-Match header is given" do - it "allows the request if it matches the current ETag" do + describe "GET" do + it "returns a 401" do get "/jimmy/documents/foo" - old_etag = last_response.headers["ETag"] - header "If-Match", old_etag + last_response.status.must_equal 401 + end + end + + describe "PUT" do + it "returns a 401" do + put "/jimmy/documents/foo", "some text" + + last_response.status.must_equal 401 + end + end + + describe "DELETE" do + it "returns a 401" do delete "/jimmy/documents/foo" - last_response.status.must_equal 200 - get "/jimmy/documents/foo" - last_response.status.must_equal 404 + last_response.status.must_equal 401 end - - it "fails the request if it does not match the current ETag" do - header "If-Match", "WONTMATCH" - - delete "/jimmy/documents/foo" - last_response.status.must_equal 412 - - get "/jimmy/documents/foo" - last_response.status.must_equal 200 - last_response.body.must_equal "some private text data" - end - end - - context "binary data" do - before do - header "Content-Type", "image/jpeg; charset=binary" - filename = File.join(File.expand_path(File.dirname(__FILE__)), "fixtures", "rockrule.jpeg") - @image = File.open(filename, "r").read - put "/jimmy/documents/jaypeg", @image - - delete "/jimmy/documents/jaypeg" - end - - it "removes the main object" do - last_response.status.must_equal 200 - lambda { - data_bucket.get("jimmy:documents:jaypeg") - }.must_raise Riak::HTTPFailedRequest - end - - it "removes the binary object" do - last_response.status.must_equal 200 - - binary = cs_binary_bucket.files.get("jimmy:documents:jaypeg") - binary.must_be_nil - end - - it "logs the operation" do - objects = [] - opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } - - log_entry = objects.select{|o| o.data["count"] == -1 && o.data["size"] == -16044}.first - log_entry.data["category"].must_equal "documents" - log_entry.indexes["user_id_bin"].must_include "jimmy" - end - end - end - end - - describe "unauthorized access" do - before do - auth = auth_bucket.new("jimmy:123") - auth.data = ["documents", "public"] - auth.store - - header "Authorization", "Bearer 321" - end - - describe "GET" do - it "returns a 401" do - get "/jimmy/documents/foo" - - last_response.status.must_equal 401 - end - end - - describe "PUT" do - it "returns a 401" do - put "/jimmy/documents/foo", "some text" - - last_response.status.must_equal 401 - end - end - - describe "DELETE" do - it "returns a 401" do - delete "/jimmy/documents/foo" - - last_response.status.must_equal 401 end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2547b47..ee4daac 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -28,8 +28,7 @@ def write_last_response_to_file(filename = "last_response.html") end alias context describe - -if app.settings.riak +if app.settings.respond_to? :riak ::Riak.disable_list_keys_warnings = true def client