diff --git a/config/locales/en.yml b/config/locales/en.yml index 71acc4bd1f0..10840e2c689 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -6270,6 +6270,7 @@ en: ancestor: Undisclosed - The ancestor is invisible because of lacking permissions. definingProject: Undisclosed - The project is invisible because of lacking permissions. definingWorkspace: Undisclosed - The workspace is invisible because of lacking permissions. + workPackage: Undisclosed - The work package is invisible because of lacking permissions. doorkeeper: pre_authorization: diff --git a/lib/api/decorators/linked_resource.rb b/lib/api/decorators/linked_resource.rb index b288e3e1d5c..592bf43a36b 100644 --- a/lib/api/decorators/linked_resource.rb +++ b/lib/api/decorators/linked_resource.rb @@ -193,6 +193,47 @@ module API skip_render:) end + # Like associated_resource, but skips rendering and shows an undisclosed + # link when the associated record exists but is not visible to the current user. + # Requires the associated model to implement +visible?(user)+. + def associated_visible_resource(name, + as: nil, + representer: nil, + v3_path: name, + link_title_attribute: :name, + undisclosed_title: :"api_v3.undisclosed.#{name.to_s.camelize(:lower)}") + associated_resource( + name, + as:, + representer:, + v3_path:, + skip_render: ->(*) { + represented.public_send(:"#{name}_id").nil? || + !represented.public_send(name)&.visible?(current_user) + }, + link: associated_visible_resource_link_lambda(name, + v3_path:, + link_title_attribute:, + undisclosed_title:) + ) + end + + def associated_visible_resource_link_lambda(name, v3_path:, link_title_attribute:, undisclosed_title:) + ->(*) do + id = represented.public_send(:"#{name}_id") + next if id.nil? + + resource = represented.public_send(name) + if resource&.visible?(current_user) + { href: api_v3_paths.public_send(v3_path, id), + title: resource.public_send(link_title_attribute) } + else + { href: ::API::V3::URN_UNDISCLOSED, + title: I18n.t(undisclosed_title) } + end + end + end + def link_attr(name, uncacheable, link_cache_if) links_attr = { rel: name.to_s.camelize(:lower) } links_attr[:uncacheable] = true if uncacheable diff --git a/modules/meeting/lib/api/v3/meeting_agenda_items/meeting_agenda_item_representer.rb b/modules/meeting/lib/api/v3/meeting_agenda_items/meeting_agenda_item_representer.rb index 81ec086d1b5..9cc86852ece 100644 --- a/modules/meeting/lib/api/v3/meeting_agenda_items/meeting_agenda_item_representer.rb +++ b/modules/meeting/lib/api/v3/meeting_agenda_items/meeting_agenda_item_representer.rb @@ -77,8 +77,7 @@ module API representer: ::API::V3::Users::UserRepresenter, skip_render: ->(*) { represented.presenter_id.nil? } - associated_resource :work_package, - skip_render: ->(*) { represented.work_package_id.nil? } + associated_visible_resource :work_package associated_resource :meeting_section, as: :section, diff --git a/modules/meeting/spec/requests/api/v3/meeting_agenda_items/agenda_items_by_meeting_resource_spec.rb b/modules/meeting/spec/requests/api/v3/meeting_agenda_items/agenda_items_by_meeting_resource_spec.rb index 4fe0c9f2a0f..d91a0db1f5b 100644 --- a/modules/meeting/spec/requests/api/v3/meeting_agenda_items/agenda_items_by_meeting_resource_spec.rb +++ b/modules/meeting/spec/requests/api/v3/meeting_agenda_items/agenda_items_by_meeting_resource_spec.rb @@ -138,6 +138,30 @@ RSpec.describe "API v3 Meeting Agenda Items sub-resource", content_type: :json d expect(last_response).to have_http_status(:not_found) end end + + context "when the agenda item is linked to a work package in an inaccessible project" do + let(:private_project) { create(:project, public: false) } + let(:private_work_package) { create(:work_package, project: private_project) } + let!(:wp_agenda_item) do + create(:wp_meeting_agenda_item, meeting:, meeting_section: section, work_package: private_work_package, + author: current_user) + end + let(:path) { api_v3_paths.meeting_agenda_item(meeting.id, wp_agenda_item.id) } + + it "returns 200" do + expect(last_response).to have_http_status(:ok) + end + + it "does not embed the inaccessible work package" do + expect(last_response.body).not_to have_json_path("_embedded/workPackage") + end + + it "renders the work package link as undisclosed" do + expect(last_response.body) + .to be_json_eql(::API::V3::URN_UNDISCLOSED.to_json) + .at_path("_links/workPackage/href") + end + end end describe "PATCH /api/v3/meetings/:meeting_id/agenda_items/:id" do diff --git a/spec/lib/api/decorators/linked_resource_spec.rb b/spec/lib/api/decorators/linked_resource_spec.rb index afc2364389c..7c9d19daea2 100644 --- a/spec/lib/api/decorators/linked_resource_spec.rb +++ b/spec/lib/api/decorators/linked_resource_spec.rb @@ -43,6 +43,76 @@ RSpec.describe API::Decorators::LinkedResource do let(:represented) { {} } let(:current_user) { create(:user) } + describe ".associated_visible_resource" do + include API::V3::Utilities::PathHelper + + let(:thing_representer_class) do + Class.new(API::Decorators::Single) do + property :id + def _type = "Thing" + end + end + + let(:representer_class) do + klass = thing_representer_class + Class.new(API::Decorators::Single) do + include API::Decorators::LinkedResource + associated_visible_resource :thing, v3_path: :thing, representer: klass, + undisclosed_title: :"api_v3.undisclosed.parent" + end + end + + let(:thing_id) { 42 } + let(:thing) { double("thing", id: thing_id, name: "Thing Name") } + let(:model) { Struct.new(:thing_id, :thing).new(thing_id, thing) } + + before do + without_partial_double_verification do + allow(api_v3_paths).to receive(:thing).with(thing_id).and_return("/api/v3/things/#{thing_id}") + end + end + + subject(:json) { representer_class.new(model, current_user:, embed_links: true).to_json } + + context "when the resource is visible" do + before { allow(thing).to receive(:visible?).with(current_user).and_return(true) } + + it "renders the link href and title" do + expect(json).to be_json_eql("/api/v3/things/42".to_json).at_path("_links/thing/href") + expect(json).to be_json_eql("Thing Name".to_json).at_path("_links/thing/title") + end + + it "embeds the resource" do + expect(json).to have_json_path("_embedded/thing") + end + end + + context "when the resource is not visible" do + before { allow(thing).to receive(:visible?).with(current_user).and_return(false) } + + it "renders the link href as undisclosed" do + expect(json).to be_json_eql(::API::V3::URN_UNDISCLOSED.to_json).at_path("_links/thing/href") + end + + it "does not embed the resource" do + expect(json).not_to have_json_path("_embedded/thing") + end + end + + context "when the resource id is nil" do + let(:thing_id) { nil } + let(:thing) { nil } + + it "renders no link" do + expect(json).not_to have_json_path("_links/thing") + end + + it "does not embed the resource" do + expect(json).not_to have_json_path("_embedded/thing") + end + end + end + describe "#from_hash" do subject { representer.new(represented, current_user:).from_hash(input_hash) }