diff --git a/lib/jsonapi/error_codes.rb b/lib/jsonapi/error_codes.rb index c24607e9c..1a5eb793b 100644 --- a/lib/jsonapi/error_codes.rb +++ b/lib/jsonapi/error_codes.rb @@ -19,6 +19,7 @@ module JSONAPI INVALID_PAGE_VALUE = 118 INVALID_SORT_FORMAT = 119 INVALID_FIELD_FORMAT = 120 + FORBIDDEN = 403 RECORD_NOT_FOUND = 404 UNSUPPORTED_MEDIA_TYPE = 415 LOCKED = 423 diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index 9523e07a5..93de6fedc 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -58,6 +58,15 @@ def errors end end + class HasManySetReplacementForbidden < Error + def errors + [JSONAPI::Error.new(code: JSONAPI::FORBIDDEN, + status: :forbidden, + title: 'Complete replacement forbidden', + detail: 'Complete replacement forbidden for this association')] + end + end + class FilterNotAllowed < Error attr_accessor :filter def initialize(filter) diff --git a/lib/jsonapi/request.rb b/lib/jsonapi/request.rb index 59d3d8017..a09f35275 100644 --- a/lib/jsonapi/request.rb +++ b/lib/jsonapi/request.rb @@ -387,6 +387,10 @@ def parse_update_association_operation(data, association_type, parent_key) association_type, verified_param_set[:has_one].values[0]) else + unless association.acts_as_set + raise JSONAPI::Exceptions::HasManySetReplacementForbidden.new + end + object_params = {links: {association.name => {linkage: data}}} verified_param_set = parse_params(object_params, @resource_klass.updateable_fields(@context)) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 7a9a1026d..9daa77d0e 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -428,9 +428,7 @@ def _updateable_associations associations = [] @_associations.each do |key, association| - if association.is_a?(JSONAPI::Association::HasOne) || association.acts_as_set - associations.push(key) - end + associations.push(key) end associations end diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 92020f216..09e7ce391 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -130,7 +130,7 @@ def jsonapi_links(*links) action: 'create_association', association: link_type.to_s, via: [:post] end - if methods.include?(:update) && res._association(link_type).acts_as_set + if methods.include?(:update) match "links/#{formatted_association_name}", controller: res._type.to_s, action: 'update_association', association: link_type.to_s, via: [:put, :patch] end diff --git a/test/fixtures/comments.yml b/test/fixtures/comments.yml index c656e5039..efbf59123 100644 --- a/test/fixtures/comments.yml +++ b/test/fixtures/comments.yml @@ -14,4 +14,8 @@ post_2_thanks_man: id: 3 post_id: 2 body: Thanks man. Great post. But what is JR? - author_id: 2 \ No newline at end of file + author_id: 2 + +rogue_comment: + body: Rogue Comment Here + author_id: 3 \ No newline at end of file diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 92ee0155d..4050a7724 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -158,7 +158,7 @@ def test_post_single def test_update_association_without_content_type ruby = Section.find_by(name: 'ruby') - patch '/posts/3/links/section', { 'sections' => {type: 'sections', id: ruby.id.to_s }}.to_json + patch '/posts/3/links/section', { 'data' => {type: 'sections', id: ruby.id.to_s }}.to_json assert_equal 415, status end @@ -177,6 +177,31 @@ def test_put_update_association_has_one assert_equal 204, status end + def test_patch_update_association_has_many_acts_as_set + # Comments are acts_as_set=false so PUT/PATCH should respond with 403 + + rogue = Comment.find_by(body: 'Rogue Comment Here') + patch '/posts/5/links/comments', { 'data' => [{type: 'comments', id: rogue.id.to_s }]}.to_json, "CONTENT_TYPE" => JSONAPI::MEDIA_TYPE + + assert_equal 403, status + end + + def test_post_update_association_has_many + rogue = Comment.find_by(body: 'Rogue Comment Here') + post '/posts/5/links/comments', { 'data' => [{type: 'comments', id: rogue.id.to_s }]}.to_json, "CONTENT_TYPE" => JSONAPI::MEDIA_TYPE + + assert_equal 204, status + end + + def test_put_update_association_has_many_acts_as_set + # Comments are acts_as_set=false so PUT/PATCH should respond with 403. Note: JR currently treats PUT and PATCH as equivalent + + rogue = Comment.find_by(body: 'Rogue Comment Here') + put '/posts/5/links/comments', { 'data' => [{type: 'comments', id: rogue.id.to_s }]}.to_json, "CONTENT_TYPE" => JSONAPI::MEDIA_TYPE + + assert_equal 403, status + end + def test_index_content_type get '/posts' assert_match JSONAPI::MEDIA_TYPE, headers['Content-Type']