diff --git a/lib/remote_storage/riak.rb b/lib/remote_storage/riak.rb index cec809c..768de46 100644 --- a/lib/remote_storage/riak.rb +++ b/lib/remote_storage/riak.rb @@ -40,8 +40,11 @@ module RemoteStorage def get_data(user, directory, key) object = data_bucket.get("#{user}:#{directory}:#{key}") + server.halt 304 if server.env["HTTP_IF_NONE_MATCH"] == object.etag + server.headers["Content-Type"] = object.content_type server.headers["Last-Modified"] = last_modified_date_for(object) + server.headers["ETag"] = object.etag if binary_key = object.meta["binary_key"] object = cs_binary_bucket.files.get(binary_key[0]) @@ -66,25 +69,34 @@ module RemoteStorage def get_directory_listing(user, directory) directory_object = directory_bucket.get("#{user}:#{directory}") + + server.halt 304 if server.env["HTTP_IF_NONE_MATCH"] == directory_object.etag + timestamp = directory_object.data.to_i timestamp /= 1000 if timestamp.to_s.length == 13 server.headers["Content-Type"] = "application/json" server.headers["Last-Modified"] = Time.at(timestamp).to_s(:rfc822) + server.headers["ETag"] = directory_object.etag listing = directory_listing(user, directory) return listing.to_json rescue ::Riak::HTTPFailedRequest - server.headers["Content-Type"] = "application/json" - return "{}" + server.halt 404 end def put_data(user, directory, key, data, content_type=nil) object = build_data_object(user, directory, key, data, content_type) + if required_match = server.env["HTTP_IF_MATCH"] + server.halt 412 unless required_match == object.etag + end + object_exists = !object.raw_data.nil? existing_object_size = object_size(object) + server.halt 412 if object_exists && server.env["HTTP_IF_NONE_MATCH"] == "*" + timestamp = (Time.now.to_f * 1000).to_i object.meta["timestamp"] = timestamp @@ -96,13 +108,14 @@ module RemoteStorage new_object_size = object.raw_data.size end - response = object.store + object.store log_count = object_exists ? 0 : 1 log_operation(user, directory, log_count, new_object_size, existing_object_size) update_all_directory_objects(user, directory, timestamp) + server.headers["ETag"] = object.etag server.halt 200 rescue ::Riak::HTTPFailedRequest server.halt 422 @@ -111,6 +124,11 @@ module RemoteStorage def delete_data(user, directory, key) object = data_bucket.get("#{user}:#{directory}:#{key}") existing_object_size = object_size(object) + etag = object.etag + + if required_match = server.env["HTTP_IF_MATCH"] + server.halt 412 unless required_match == etag + end if binary_key = object.meta["binary_key"] object = cs_binary_bucket.files.get(binary_key[0]) @@ -126,6 +144,7 @@ module RemoteStorage timestamp = (Time.now.to_f * 1000).to_i delete_or_update_directory_objects(user, directory, timestamp) + server.headers["ETag"] = etag server.halt riak_response[:code] rescue ::Riak::HTTPFailedRequest server.halt 404 @@ -222,8 +241,9 @@ module RemoteStorage sub_directories(user, directory).each do |entry| directory_name = entry["name"].split("/").last timestamp = entry["timestamp"].to_i + etag = entry["etag"] - listing.merge!({ "#{directory_name}/" => timestamp }) + listing.merge!({ "#{directory_name}/" => etag }) end directory_entries(user, directory).each do |entry| @@ -233,20 +253,16 @@ module RemoteStorage else DateTime.rfc2822(entry["last_modified"]).to_time.to_i end + etag = entry["etag"] - listing.merge!({ entry_name => timestamp }) + listing.merge!({ entry_name => etag }) end listing end def directory_entries(user, directory) - directory = "/" if directory == "" - - user_keys = data_bucket.get_index("user_id_bin", user) - directory_keys = data_bucket.get_index("directory_bin", directory) - - all_keys = user_keys & directory_keys + all_keys = user_directory_keys(user, directory, data_bucket) return [] if all_keys.empty? map_query = <<-EOH @@ -256,48 +272,53 @@ module RemoteStorage key_name = keys.join(':'); last_modified_date = v.values[0]['metadata']['X-Riak-Last-Modified']; timestamp = v.values[0]['metadata']['X-Riak-Meta']['X-Riak-Meta-Timestamp']; + etag = v.values[0]['metadata']['X-Riak-VTag']; return [{ name: key_name, last_modified: last_modified_date, timestamp: timestamp, + etag: etag }]; } EOH - map_reduce = ::Riak::MapReduce.new(client) - all_keys.each do |key| - map_reduce.add(data_bucket.name, key) - end - - map_reduce. - map(map_query, :keep => true). - run + run_map_reduce(data_bucket, all_keys, map_query) end def sub_directories(user, directory) - directory = "/" if directory == "" - - user_keys = directory_bucket.get_index("user_id_bin", user) - directory_keys = directory_bucket.get_index("directory_bin", directory) - - all_keys = user_keys & directory_keys + all_keys = user_directory_keys(user, directory, directory_bucket) return [] if all_keys.empty? map_query = <<-EOH function(v){ keys = v.key.split(':'); key_name = keys[keys.length-1]; - timestamp = v.values[0]['data'] + timestamp = v.values[0]['data']; + etag = v.values[0]['metadata']['X-Riak-VTag']; return [{ name: key_name, timestamp: timestamp, + etag: etag }]; } EOH + run_map_reduce(directory_bucket, all_keys, map_query) + end + + def user_directory_keys(user, directory, bucket) + directory = "/" if directory == "" + + user_keys = bucket.get_index("user_id_bin", user) + directory_keys = bucket.get_index("directory_bin", directory) + + user_keys & directory_keys + end + + def run_map_reduce(bucket, keys, map_query) map_reduce = ::Riak::MapReduce.new(client) - all_keys.each do |key| - map_reduce.add(directory_bucket.name, key) + keys.each do |key| + map_reduce.add(bucket.name, key) end map_reduce. diff --git a/liquor-cabinet.rb b/liquor-cabinet.rb index 1df2e25..71eecd1 100644 --- a/liquor-cabinet.rb +++ b/liquor-cabinet.rb @@ -42,7 +42,8 @@ class LiquorCabinet < Sinatra::Base before path do headers 'Access-Control-Allow-Origin' => '*', 'Access-Control-Allow-Methods' => 'GET, PUT, DELETE', - 'Access-Control-Allow-Headers' => 'Authorization, Content-Type, Origin' + 'Access-Control-Allow-Headers' => 'Authorization, Content-Type, Origin, If-Match, If-None-Match', + 'Access-Control-Expose-Headers' => 'ETag' headers['Access-Control-Allow-Origin'] = env["HTTP_ORIGIN"] if env["HTTP_ORIGIN"] headers['Cache-Control'] = 'no-cache' diff --git a/spec/directories_spec.rb b/spec/directories_spec.rb index 3292017..1e5f656 100644 --- a/spec/directories_spec.rb +++ b/spec/directories_spec.rb @@ -19,17 +19,18 @@ describe "Directories" do put "/jimmy/tasks/http%3A%2F%2F5apps.com", "prettify design" end - it "lists the objects with a timestamp of the last modification" do + it "lists the objects with their version" do get "/jimmy/tasks/" last_response.status.must_equal 200 last_response.content_type.must_equal "application/json" + foo = data_bucket.get("jimmy:tasks:foo") + content = JSON.parse(last_response.body) content.must_include "http://5apps.com" content.must_include "foo" - content["foo"].must_be_kind_of Integer - content["foo"].to_s.length.must_equal 13 + content["foo"].must_equal foo.etag.gsub(/"/, "") end it "has a Last-Modifier header set" do @@ -44,17 +45,57 @@ describe "Directories" do last_modified.day.must_equal now.day end + it "has an ETag header set" do + get "/jimmy/tasks/" + + last_response.status.must_equal 200 + last_response.headers["ETag"].wont_be_nil + + # check that ETag stays the same + etag = last_response.headers["ETag"] + get "/jimmy/tasks/" + last_response.headers["ETag"].must_equal etag + end + it "has CORS headers set" do get "/jimmy/tasks/" last_response.status.must_equal 200 last_response.headers["Access-Control-Allow-Origin"].must_equal "*" last_response.headers["Access-Control-Allow-Methods"].must_equal "GET, PUT, DELETE" - last_response.headers["Access-Control-Allow-Headers"].must_equal "Authorization, Content-Type, Origin" + last_response.headers["Access-Control-Allow-Headers"].must_equal "Authorization, Content-Type, Origin, If-Match, If-None-Match" + last_response.headers["Access-Control-Expose-Headers"].must_equal "ETag" + end + + context "when If-None-Match header is set" do + before do + get "/jimmy/tasks/" + + @etag = last_response.headers["ETag"] + end + + it "responds with 'not modified' when it matches the current ETag" do + header "If-None-Match", @etag + get "/jimmy/tasks/" + + last_response.status.must_equal 304 + last_response.body.must_be_empty + end + + it "responds normally when it does not match the current ETag" do + header "If-None-Match", "FOO" + get "/jimmy/tasks/" + + last_response.status.must_equal 200 + last_response.body.wont_be_empty + end end context "with sub-directories" do before do + get "/jimmy/tasks/" + @old_etag = last_response.headers["ETag"] + put "/jimmy/tasks/home/laundry", "do the laundry" end @@ -63,12 +104,20 @@ describe "Directories" do last_response.status.must_equal 200 + home = directory_bucket.get("jimmy:tasks/home") + content = JSON.parse(last_response.body) content.must_include "foo" content.must_include "http://5apps.com" content.must_include "home/" - content["home/"].must_be_kind_of Integer - content["home/"].to_s.length.must_equal 13 + content["home/"].must_equal home.etag.gsub(/"/, "") + end + + it "updates the ETag of the parent directory" do + get "/jimmy/tasks/" + + last_response.headers["ETag"].wont_be_nil + last_response.headers["ETag"].wont_equal @old_etag end context "for a different user" do @@ -102,10 +151,11 @@ describe "Directories" do last_response.status.must_equal 200 + projects = directory_bucket.get("jimmy:tasks/private/projects") + content = JSON.parse(last_response.body) content.must_include "projects/" - content["projects/"].must_be_kind_of Integer - content["projects/"].to_s.length.must_equal 13 + content["projects/"].must_equal projects.etag.gsub(/"/, "") end it "updates the timestamps of the existing directory objects" do @@ -137,10 +187,11 @@ describe "Directories" do last_response.status.must_equal 200 + jaypeg = data_bucket.get("jimmy:tasks:jaypeg.jpg") + content = JSON.parse(last_response.body) content.must_include "jaypeg.jpg" - content["jaypeg.jpg"].must_be_kind_of Integer - content["jaypeg.jpg"].to_s.length.must_equal 13 + content["jaypeg.jpg"].must_equal jaypeg.etag.gsub(/"/, "") end end @@ -157,10 +208,11 @@ describe "Directories" do last_response.status.must_equal 200 + jaypeg = data_bucket.get("jimmy:tasks:jaypeg.jpg") + content = JSON.parse(last_response.body) content.must_include "jaypeg.jpg" - content["jaypeg.jpg"].must_be_kind_of Integer - content["jaypeg.jpg"].to_s.length.must_equal 13 + content["jaypeg.jpg"].must_equal jaypeg.etag.gsub(/"/, "") end end end @@ -176,10 +228,11 @@ describe "Directories" do last_response.status.must_equal 200 + laundry = data_bucket.get("jimmy:tasks/home:laundry") + content = JSON.parse(last_response.body) content.must_include "laundry" - content["laundry"].must_be_kind_of Integer - content["laundry"].to_s.length.must_equal 13 + content["laundry"].must_equal laundry.etag.gsub(/"/, "") end end @@ -187,8 +240,7 @@ describe "Directories" do it "returns an empty listing" do get "/jimmy/documents/notfound/" - last_response.status.must_equal 200 - last_response.body.must_equal "{}" + last_response.status.must_equal 404 end end @@ -254,12 +306,20 @@ describe "Directories" do last_response.status.must_equal 200 + tasks = directory_bucket.get("jimmy:tasks") + content = JSON.parse(last_response.body) content.must_include "root-1" content.must_include "root-2" content.must_include "tasks/" - content["tasks/"].must_be_kind_of Integer - content["tasks/"].to_s.length.must_equal 13 + content["tasks/"].must_equal tasks.etag.gsub(/"/, "") + end + + it "has an ETag header set" do + get "/jimmy/" + + last_response.status.must_equal 200 + last_response.headers["ETag"].wont_be_nil end end @@ -281,6 +341,13 @@ describe "Directories" do content = JSON.parse(last_response.body) content.must_include "5apps" end + + it "has an ETag header set" do + get "/jimmy/public/bookmarks/" + + last_response.status.must_equal 200 + last_response.headers["ETag"].wont_be_nil + end end context "when directly authorized for the public directory" do @@ -323,9 +390,11 @@ describe "Directories" do describe "directory object" do describe "PUT file" do context "no existing directory object" do - it "creates a new directory object" do + before do put "/jimmy/tasks/home/trash", "take out the trash" + end + it "creates a new directory object" do object = data_bucket.get("jimmy:tasks/home:trash") directory = directory_bucket.get("jimmy:tasks/home") @@ -334,15 +403,11 @@ describe "Directories" do end it "sets the correct index for the directory object" do - put "/jimmy/tasks/home/trash", "take out the trash" - object = directory_bucket.get("jimmy:tasks/home") object.indexes["directory_bin"].must_include "tasks" end it "creates directory objects for the parent directories" do - put "/jimmy/tasks/home/trash", "take out the trash" - object = directory_bucket.get("jimmy:tasks") object.indexes["directory_bin"].must_include "/" object.data.wont_be_nil @@ -355,10 +420,7 @@ describe "Directories" do context "existing directory object" do before do - directory = directory_bucket.new("jimmy:tasks/home") - directory.content_type = "text/plain" - directory.data = (2.seconds.ago.to_f * 1000).to_i - directory.store + put "/jimmy/tasks/home/trash", "collect some trash" end it "updates the timestamp of the directory" do @@ -383,7 +445,8 @@ describe "Directories" do last_response.headers["Access-Control-Allow-Origin"].must_equal "*" last_response.headers["Access-Control-Allow-Methods"].must_equal "GET, PUT, DELETE" - last_response.headers["Access-Control-Allow-Headers"].must_equal "Authorization, Content-Type, Origin" + last_response.headers["Access-Control-Allow-Headers"].must_equal "Authorization, Content-Type, Origin, If-Match, If-None-Match" + last_response.headers["Access-Control-Expose-Headers"].must_equal "ETag" end context "sub-directories" do @@ -394,7 +457,8 @@ describe "Directories" do last_response.headers["Access-Control-Allow-Origin"].must_equal "*" last_response.headers["Access-Control-Allow-Methods"].must_equal "GET, PUT, DELETE" - last_response.headers["Access-Control-Allow-Headers"].must_equal "Authorization, Content-Type, Origin" + last_response.headers["Access-Control-Allow-Headers"].must_equal "Authorization, Content-Type, Origin, If-Match, If-None-Match" + last_response.headers["Access-Control-Expose-Headers"].must_equal "ETag" end end @@ -406,7 +470,8 @@ describe "Directories" do last_response.headers["Access-Control-Allow-Origin"].must_equal "*" last_response.headers["Access-Control-Allow-Methods"].must_equal "GET, PUT, DELETE" - last_response.headers["Access-Control-Allow-Headers"].must_equal "Authorization, Content-Type, Origin" + last_response.headers["Access-Control-Allow-Headers"].must_equal "Authorization, Content-Type, Origin, If-Match, If-None-Match" + last_response.headers["Access-Control-Expose-Headers"].must_equal "ETag" end end end @@ -450,6 +515,31 @@ describe "Directories" do directory_bucket.get("jimmy:").wont_be_nil end + it "updates the ETag headers of all parent directories" do + get "/jimmy/tasks/home/" + home_etag = last_response.headers["ETag"] + + get "/jimmy/tasks/" + tasks_etag = last_response.headers["ETag"] + + get "/jimmy/" + root_etag = last_response.headers["ETag"] + + delete "/jimmy/tasks/home/trash" + + get "/jimmy/tasks/home/" + last_response.headers["ETag"].wont_be_nil + last_response.headers["ETag"].wont_equal home_etag + + get "/jimmy/tasks/" + last_response.headers["ETag"].wont_be_nil + last_response.headers["ETag"].wont_equal tasks_etag + + get "/jimmy/" + last_response.headers["ETag"].wont_be_nil + last_response.headers["ETag"].wont_equal root_etag + end + describe "timestamps" do before do @old_timestamp = (2.seconds.ago.to_f * 1000).to_i diff --git a/spec/permissions_spec.rb b/spec/permissions_spec.rb index d4f7bbf..8f13a16 100644 --- a/spec/permissions_spec.rb +++ b/spec/permissions_spec.rb @@ -345,7 +345,7 @@ describe "Permissions" do it "allows GET requests" do get "/jimmy/public/tasks/" - last_response.status.must_equal 200 + last_response.status.must_equal 404 end it "allows PUT requests" do @@ -403,19 +403,19 @@ describe "Permissions" do end it "allows GET requests" do - get "/jimmy/tasks/" + get "/jimmy/public/tasks/" - last_response.status.must_equal 200 + last_response.status.must_equal 404 end it "disallows PUT requests" do - put "/jimmy/tasks/foo", "some text" + put "/jimmy/public/tasks/foo", "some text" last_response.status.must_equal 403 end it "disallows DELETE requests" do - delete "/jimmy/tasks/hello" + delete "/jimmy/public/tasks/hello" last_response.status.must_equal 403 end diff --git a/spec/riak_spec.rb b/spec/riak_spec.rb index c34a601..65bbfae 100644 --- a/spec/riak_spec.rb +++ b/spec/riak_spec.rb @@ -32,6 +32,11 @@ describe "App with Riak backend" do last_modified.year.must_equal now.year last_modified.day.must_equal now.day end + + it "has an ETag header set" do + last_response.status.must_equal 200 + last_response.headers["ETag"].wont_be_nil + end end describe "GET data with custom content type" do @@ -58,19 +63,42 @@ describe "App with Riak backend" do 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 "GET" do - it "returns the value" 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 + 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 end describe "GET nonexisting key" do @@ -102,6 +130,10 @@ describe "App with Riak backend" 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 @@ -197,15 +229,18 @@ describe "App with Riak backend" do describe "with existing content" do before do put "/jimmy/documents/archive/foo", "lorem ipsum" - put "/jimmy/documents/archive/foo", "some awesome content" 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 } @@ -220,22 +255,74 @@ describe "App with Riak backend" do update_entry.indexes["user_id_bin"].must_include "jimmy" end - describe "when no serializer is 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 "changes the ETag header" do + old_etag = last_response.headers["ETag"] + put "/jimmy/documents/archive/foo", "some awesome content" - it "saves the value" do + 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 - data_bucket.get("jimmy:documents:html").raw_data.must_equal "" + + get "/jimmy/documents/archive/foo" + last_response.body.must_equal "some awesome content" end - it "uses the requested content type" do - data_bucket.get("jimmy:documents:html").content_type.must_equal "text/html; charset=UTF-8" + 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 200 + 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 @@ -282,13 +369,23 @@ describe "App with Riak backend" do last_response.body.must_equal @image end - # it "indexes the binary set" do - # indexes = binary_bucket.get("jimmy:documents:jaypeg").indexes - # indexes["user_id_bin"].must_be_kind_of Set - # indexes["user_id_bin"].must_include "jimmy" + it "responds with an ETag header" do + last_response.headers["ETag"].wont_be_nil + etag = last_response.headers["ETag"] - # indexes["directory_bin"].must_include "documents" - # end + get "/jimmy/documents/jaypeg" + + last_response.headers["ETag"].wont_be_nil + last_response.headers["ETag"].must_equal etag + 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 = [] @@ -322,14 +419,6 @@ describe "App with Riak backend" do last_response.status.must_equal 200 last_response.body.must_equal @image end - - # it "indexes the binary set" do - # indexes = binary_bucket.get("jimmy:documents:jaypeg").indexes - # indexes["user_id_bin"].must_be_kind_of Set - # indexes["user_id_bin"].must_include "jimmy" - - # indexes["directory_bin"].must_include "documents" - # end end end @@ -376,24 +465,33 @@ describe "App with Riak backend" do describe "DELETE" do before do header "Authorization", "Bearer 123" - delete "/jimmy/documents/foo" end - it "removes the key" do - last_response.status.must_equal 204 - lambda { - data_bucket.get("jimmy:documents:foo") - }.must_raise Riak::HTTPFailedRequest - end + describe "basics" do + before do + delete "/jimmy/documents/foo" + end - it "logs the operation" do - objects = [] - opslog_bucket.keys.each { |k| objects << opslog_bucket.get(k) rescue nil } + it "removes the key" do + last_response.status.must_equal 204 + lambda { + data_bucket.get("jimmy:documents:foo") + }.must_raise Riak::HTTPFailedRequest + end - 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" + 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 + + it "sets the ETag header" do + last_response.headers["ETag"].wont_be_nil + end end context "non-existing object" do @@ -401,10 +499,39 @@ describe "App with Riak backend" 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 1 + 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 + get "/jimmy/documents/foo" + old_etag = last_response.headers["ETag"] + header "If-Match", old_etag + + delete "/jimmy/documents/foo" + last_response.status.must_equal 204 + + get "/jimmy/documents/foo" + last_response.status.must_equal 404 + 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