diff --git a/app/models/concerns/exportable.rb b/app/models/concerns/exportable.rb new file mode 100644 index 0000000000000000000000000000000000000000..066a44912bef675b7448e000cada70abf15ae066 --- /dev/null +++ b/app/models/concerns/exportable.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Exportable + extend ActiveSupport::Concern + + def readable_records(association, current_user: nil) + association_records = try(association) + return unless association_records.present? + + if has_many_association?(association) + DeclarativePolicy.user_scope do + association_records.select { |record| readable_record?(record, current_user) } + end + else + readable_record?(association_records, current_user) ? association_records : nil + end + end + + def exportable_association?(association, current_user: nil) + return false unless respond_to?(association) + return true if has_many_association?(association) + + readable = try(association) + return true if readable.nil? + + readable_record?(readable, current_user) + end + + def restricted_associations(keys) + exportable_restricted_associations & keys + end + + def has_many_association?(association_name) + self.class.reflect_on_association(association_name)&.macro == :has_many + end + + private + + def exportable_restricted_associations + [] + end + + def readable_record?(record, user) + if record.respond_to?(:exportable_record?) + record.exportable_record?(user) + else + record.readable_by?(user) + end + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb index 6744ee230b08afd26c0a5a372f4a3a7295bdefb9..ff5fb61146ec6422763e080513a7b0418f6853de 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -25,6 +25,7 @@ class Issue < ApplicationRecord include FromUnion include EachBatch include PgFullTextSearchable + include Exportable extend ::Gitlab::Utils::Override diff --git a/app/models/note.rb b/app/models/note.rb index c0299867b0ee9fd2a98f8ac2fd37efc08b92f71a..57a1ae1ac45e6960787c0c83dd35b26fce25aeaf 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -710,6 +710,12 @@ def issuable_ability_name confidential? ? :read_internal_note : :read_note end + def exportable_record?(user) + return true unless system? + + readable_by?(user) + end + private def system_note_viewable_by?(user) diff --git a/ee/app/models/ee/epic.rb b/ee/app/models/ee/epic.rb index a29846dd4261cdf5dbbdb61ae16d22b4deb095e0..d5067d893694758a4c1c41c16c9c7a921c267af9 100644 --- a/ee/app/models/ee/epic.rb +++ b/ee/app/models/ee/epic.rb @@ -21,6 +21,7 @@ module Epic include Todoable include SortableTitle include EachBatch + include ::Exportable DEFAULT_COLOR = ::Gitlab::Color.of('#1068bf') MAX_HIERARCHY_DEPTH = 7 @@ -700,10 +701,8 @@ def supports_confidentiality? true end - def exportable_association?(key, current_user: nil) - return false unless key == :parent - - parent.present? && current_user&.can?(:read_epic, parent) + def exportable_restricted_associations + super + [:notes] end private diff --git a/ee/app/models/ee/issue.rb b/ee/app/models/ee/issue.rb index 18ebcd7f90019eaf8254e32fe1c571262320a3ca..935c22587a4d7ee471d4b98caa049a5b841230be 100644 --- a/ee/app/models/ee/issue.rb +++ b/ee/app/models/ee/issue.rb @@ -331,12 +331,6 @@ def has_epic? epic_issue.present? end - def exportable_association?(key, current_user: nil) - return false unless key == :epic_issue - - epic.present? && current_user&.can?(:read_epic, epic) - end - private def blocking_issues_ids diff --git a/ee/app/models/epic_issue.rb b/ee/app/models/epic_issue.rb index 47e0027da9d9633381bc4c547b983d6a9f3df280..cc6a2f0a68caf81aeaff940256d7c1c67f51c8c8 100644 --- a/ee/app/models/epic_issue.rb +++ b/ee/app/models/epic_issue.rb @@ -31,6 +31,10 @@ def self.epic_tree_node_query(node) select(selection).in_epic(node.parent_ids) end + def exportable_record?(user) + Ability.allowed?(user, :read_epic, epic) + end + private def validate_confidential_epic diff --git a/ee/spec/lib/ee/gitlab/import_export/group/tree_saver_spec.rb b/ee/spec/lib/ee/gitlab/import_export/group/tree_saver_spec.rb index 3d97258ba494a6bbe626dc4bd1f13ff6755b8177..3bb303d8a87be529267f4978a4e5b95d2b8b297d 100644 --- a/ee/spec/lib/ee/gitlab/import_export/group/tree_saver_spec.rb +++ b/ee/spec/lib/ee/gitlab/import_export/group/tree_saver_spec.rb @@ -8,7 +8,7 @@ let_it_be(:group) { create(:group) } let_it_be(:label) { create(:group_label) } let_it_be(:parent_epic) { create(:epic, group: group) } - let_it_be(:epic) { create(:epic, group: group, parent: parent_epic) } + let_it_be(:epic, reload: true) { create(:epic, group: group, parent: parent_epic) } let_it_be(:epic_event) { create(:event, :created, target: epic, group: group, author: user) } let_it_be(:epic_label_link) { create(:label_link, label: label, target: epic) } let_it_be(:epic_push_event) { create(:event, :pushed, target: epic, group: group, author: user) } @@ -122,15 +122,25 @@ expect(event['state']).to eq('closed') end - context 'when epic parent is not readable' do - before do - epic.update!(parent: create(:epic, group: create(:group, :private))) - end + context 'with inaccessible resources' do + let_it_be(:external_parent) { create(:epic, group: create(:group, :private)) } + + it 'filters out inaccessible epic parent' do + epic.update!(parent: external_parent) - it 'filters out inaccessible epic object' do expect_successful_save(group_tree_saver) expect(epic_json['parent']).to be_nil end + + it 'filters out inaccessible epic notes' do + note_text = "added epic #{external_parent.to_reference(full: true)} as parent epic" + note2 = create(:system_note, noteable: epic, note: note_text) + create(:system_note_metadata, note: note2, action: 'relate_epic') + + expect_successful_save(group_tree_saver) + expect(epic_json['notes'].count).to eq(1) + expect(epic_json['notes'].first['note']).to eq(note.note) + end end end diff --git a/ee/spec/models/epic_issue_spec.rb b/ee/spec/models/epic_issue_spec.rb index 2564b2878f08ab1a6f0ccef696f30c3e8a948d48..a17538cabb5f5b990cc83a2541379706a631225c 100644 --- a/ee/spec/models/epic_issue_spec.rb +++ b/ee/spec/models/epic_issue_spec.rb @@ -138,4 +138,39 @@ def as_item(item) end end end + + describe '#exportable_record?' do + let_it_be(:user) { create(:user) } + let_it_be(:private_group) { create(:group, :private) } + let_it_be(:private_epic) { create(:epic, group: private_group) } + let_it_be(:epic_issue) { create(:epic_issue, epic: private_epic, issue: issue) } + + subject { epic_issue.exportable_record?(current_user) } + + before do + stub_licensed_features(epics: true) + end + + context 'when user is nil' do + let(:current_user) { nil } + + it { is_expected.to be_falsey } + end + + context 'when user cannot read epic' do + let(:current_user) { user } + + it { is_expected.to be_falsey } + end + + context 'when user can read epic' do + let(:current_user) { user } + + before do + private_group.add_reporter(user) + end + + it { is_expected.to be_truthy } + end + end end diff --git a/ee/spec/models/epic_spec.rb b/ee/spec/models/epic_spec.rb index 8f9875434164c67cdddc9f7523e41bcd1442e08d..dfaff52f248780a4d66c6155d63593dd0b0c667e 100644 --- a/ee/spec/models/epic_spec.rb +++ b/ee/spec/models/epic_spec.rb @@ -1387,44 +1387,26 @@ def as_item(item) end end - describe '#exportable_association?' do + it_behaves_like 'resource with exportable associations' do let_it_be(:other_group) { create(:group, :private) } let_it_be(:cross_group_parent) { create(:epic, group: other_group) } - let_it_be_with_reload(:epic) { create(:epic, group: group, parent: cross_group_parent) } + let_it_be_with_reload(:resource) { create(:epic, group: group, parent: cross_group_parent) } + let_it_be(:child_epic) { create(:epic, group: group, parent: resource) } - let(:key) { :parent } - - subject { epic.exportable_association?(key, current_user: user) } - - it { is_expected.to be_falsey } - - context 'when user can read epic' do - before do - group.add_developer(user) - stub_licensed_features(epics: true) - end - - it { is_expected.to be_falsey } - - context "when user can read epic's parent" do - before do - other_group.add_developer(user) - end - - it { is_expected.to be_truthy } - - context 'for an unknown key' do - let(:key) { :labels } - - it { is_expected.to be_falsey } - end - - context 'for an unauthenticated user' do - let(:user) { nil } + let_it_be(:readable_note) do + note = create(:system_note, noteable: resource, note: "added epic #{child_epic.to_reference} as child epic") + create(:system_note_metadata, note: note, action: 'relate_epic') + note + end - it { is_expected.to be_falsey } - end - end + let_it_be(:restricted_note) do + text = "added epic #{cross_group_parent.to_reference(full: true)} as parent epic" + note = create(:system_note, noteable: resource, note: text) + create(:system_note_metadata, note: note, action: 'relate_epic') + note end + + let(:single_association) { :parent } + let(:stubbed_features) { { epics: true } } end end diff --git a/ee/spec/models/issue_spec.rb b/ee/spec/models/issue_spec.rb index 615e6048c3352b96b74b93218e965ca25d8a3f2d..b2db90abd12a2465773087d07dcb293dbbca73e4 100644 --- a/ee/spec/models/issue_spec.rb +++ b/ee/spec/models/issue_spec.rb @@ -1361,46 +1361,31 @@ end end - describe '#exportable_association?' do + it_behaves_like 'resource with exportable associations' do let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group, :private) } + let_it_be(:other_group) { create(:group, :private) } let_it_be(:project) { create(:project, group: group) } - let_it_be_with_reload(:issue) { create(:issue, project: project) } + let_it_be_with_reload(:resource) { create(:issue, project: project) } let_it_be(:epic) { create(:epic, group: group) } - let_it_be(:epic_issue) { create(:epic_issue, issue: issue, epic: epic) } - - let(:key) { :epic_issue } - - subject { issue.exportable_association?(key, current_user: user) } - - it { is_expected.to be_falsey } + let_it_be(:cross_group_epic) { create(:epic, group: other_group) } + let_it_be(:epic_issue) { create(:epic_issue, issue: resource, epic: cross_group_epic) } - context 'when epics are available' do - before do - stub_licensed_features(epics: true) - end - - it { is_expected.to be_falsey } - - context 'when user can read epic' do - before do - group.add_developer(user) - end - - it { is_expected.to be_truthy } - - context 'for an unknown key' do - let(:key) { :labels } - - it { is_expected.to be_falsey } - end - - context 'for an unauthenticated user' do - let(:user) { nil } + let_it_be(:readable_note) do + text = "added epic #{epic.to_reference}" + note = create(:system_note, noteable: resource, project: project, note: text) + create(:system_note_metadata, note: note, action: 'relate') + note + end - it { is_expected.to be_falsey } - end - end + let_it_be(:restricted_note) do + text = "added epic #{cross_group_epic.to_reference(full: true)}" + note = create(:system_note, noteable: resource, project: project, note: text) + create(:system_note_metadata, note: note, action: 'relate') + note end + + let(:single_association) { :epic_issue } + let(:stubbed_features) { { epics: true } } end end diff --git a/lib/gitlab/import_export/json/streaming_serializer.rb b/lib/gitlab/import_export/json/streaming_serializer.rb index cf62f181366c88c2d7a33036561d1d4f1e15f31f..389ab8b4c978685b87b2e0539c9a1f4421f1a9ac 100644 --- a/lib/gitlab/import_export/json/streaming_serializer.rb +++ b/lib/gitlab/import_export/json/streaming_serializer.rb @@ -85,17 +85,30 @@ def serialize_many_relations(key, records, options) end def exportable_json_record(record, options, key) - associations = relations_schema[:include_if_exportable]&.dig(key) - return Raw.new(record.to_json(options)) unless associations && options[:include] + return Raw.new(record.to_json(options)) unless options[:include].any? + conditional_associations = relations_schema[:include_if_exportable]&.dig(key) + + filtered_options = + if conditional_associations.present? + filter_conditional_include(record, options, conditional_associations) + else + options + end + + Raw.new(authorized_record_json(record, filtered_options)) + end + + def filter_conditional_include(record, options, conditional_associations) filtered_options = options.deep_dup - associations.each do |association| + + conditional_associations.each do |association| filtered_options[:include].delete_if do |option| !exportable_json_association?(option, record, association.to_sym) end end - Raw.new(record.to_json(filtered_options)) + filtered_options end def exportable_json_association?(option, record, association) @@ -105,6 +118,34 @@ def exportable_json_association?(option, record, association) record.exportable_association?(association, current_user: current_user) end + def authorized_record_json(record, options) + include_keys = options[:include].flat_map(&:keys) + keys_to_authorize = record.try(:restricted_associations, include_keys) + return record.to_json(options) if keys_to_authorize.blank? + + record_hash = record.as_json(options).with_indifferent_access + filtered_record_hash(record, keys_to_authorize, record_hash).to_json(options) + end + + def filtered_record_hash(record, keys_to_authorize, record_hash) + keys_to_authorize.each do |key| + next unless record_hash[key].present? + + readable = record.try(:readable_records, key, current_user: current_user) + if record.has_many_association?(key) + readable_ids = readable.pluck(:id) + + record_hash[key].keep_if do |association_record| + readable_ids.include?(association_record[:id]) + end + else + record_hash[key] = nil unless readable.present? + end + end + + record_hash + end + def batch(relation, key) opts = { of: BATCH_SIZE } order_by = reorders(relation, key) diff --git a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb index 02ac8065c9fefc446b659e398c8ac76f8e8b349c..d8441a7aa30abe8db68037dd5508f44eaf18c24f 100644 --- a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb +++ b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::ImportExport::Json::StreamingSerializer do +RSpec.describe Gitlab::ImportExport::Json::StreamingSerializer, feature_category: :importers do let_it_be(:user) { create(:user) } let_it_be(:release) { create(:release) } let_it_be(:group) { create(:group) } @@ -213,59 +213,143 @@ end end - describe 'conditional export of included associations' do + describe 'with inaccessible associations' do + let_it_be(:milestone) { create(:milestone, project: exportable) } + let_it_be(:issue) { create(:issue, assignees: [user], project: exportable, milestone: milestone) } + let_it_be(:label1) { create(:label, project: exportable) } + let_it_be(:label2) { create(:label, project: exportable) } + let_it_be(:link1) { create(:label_link, label: label1, target: issue) } + let_it_be(:link2) { create(:label_link, label: label2, target: issue) } + + let(:options) { { include: [{ label_links: { include: [:label] } }, { milestone: { include: [] } }] } } + let(:include) do - [{ issues: { include: [{ label_links: { include: [:label] } }] } }] + [{ issues: options }] end - let(:include_if_exportable) do - { issues: [:label_links] } + shared_examples 'record with exportable associations' do + it 'includes exportable association' do + expect(json_writer).to receive(:write_relation_array).with(exportable_path, :issues, array_including(expected_issue)) + + subject.execute + end end - let_it_be(:label) { create(:label, project: exportable) } - let_it_be(:link) { create(:label_link, label: label, target: issue) } + context 'conditional export of included associations' do + let(:include_if_exportable) do + { issues: [:label_links, :milestone] } + end - context 'when association is exportable' do - before do - allow_next_found_instance_of(Issue) do |issue| - allow(issue).to receive(:exportable_association?).with(:label_links, current_user: user).and_return(true) + context 'when association is exportable' do + before do + allow_next_found_instance_of(Issue) do |issue| + allow(issue).to receive(:exportable_association?).with(:label_links, current_user: user).and_return(true) + allow(issue).to receive(:exportable_association?).with(:milestone, current_user: user).and_return(true) + end + end + + it_behaves_like 'record with exportable associations' do + let(:expected_issue) { issue.to_json(options) } end end - it 'includes exportable association' do - expected_issue = issue.to_json(include: [{ label_links: { include: [:label] } }]) + context 'when an association is not exportable' do + before do + allow_next_found_instance_of(Issue) do |issue| + allow(issue).to receive(:exportable_association?).with(:label_links, current_user: user).and_return(true) + allow(issue).to receive(:exportable_association?).with(:milestone, current_user: user).and_return(false) + end + end - expect(json_writer).to receive(:write_relation_array).with(exportable_path, :issues, array_including(expected_issue)) + it_behaves_like 'record with exportable associations' do + let(:expected_issue) { issue.to_json(include: [{ label_links: { include: [:label] } }]) } + end + end - subject.execute + context 'when association does not respond to exportable_association?' do + before do + allow_next_found_instance_of(Issue) do |issue| + allow(issue).to receive(:respond_to?).and_call_original + allow(issue).to receive(:respond_to?).with(:exportable_association?).and_return(false) + end + end + + it_behaves_like 'record with exportable associations' do + let(:expected_issue) { issue.to_json } + end end end - context 'when association is not exportable' do - before do - allow_next_found_instance_of(Issue) do |issue| - allow(issue).to receive(:exportable_association?).with(:label_links, current_user: user).and_return(false) + context 'export of included restricted associations' do + let(:many_relation) { :label_links } + let(:single_relation) { :milestone } + let(:issue_hash) { issue.as_json(options).with_indifferent_access } + let(:expected_issue) { issue.to_json(options) } + + context 'when the association is restricted' do + context 'when some association records are exportable' do + before do + allow_next_found_instance_of(Issue) do |issue| + allow(issue).to receive(:restricted_associations).with([many_relation, single_relation]).and_return([many_relation]) + allow(issue).to receive(:readable_records).with(many_relation, current_user: user).and_return([link1]) + end + end + + it_behaves_like 'record with exportable associations' do + let(:expected_issue) do + issue_hash[many_relation].delete_at(1) + issue_hash.to_json(options) + end + end end - end - it 'filters out not exportable association' do - expect(json_writer).to receive(:write_relation_array).with(exportable_path, :issues, array_including(issue.to_json)) + context 'when all association records are exportable' do + before do + allow_next_found_instance_of(Issue) do |issue| + allow(issue).to receive(:restricted_associations).with([many_relation, single_relation]).and_return([many_relation]) + allow(issue).to receive(:readable_records).with(many_relation, current_user: user).and_return([link1, link2]) + end + end - subject.execute - end - end + it_behaves_like 'record with exportable associations' + end - context 'when association does not respond to exportable_association?' do - before do - allow_next_found_instance_of(Issue) do |issue| - allow(issue).to receive(:respond_to?).with(:exportable_association?).and_return(false) + context 'when the single association record is exportable' do + before do + allow_next_found_instance_of(Issue) do |issue| + allow(issue).to receive(:restricted_associations).with([many_relation, single_relation]).and_return([single_relation]) + allow(issue).to receive(:readable_records).with(single_relation, current_user: user).and_return(milestone) + end + end + + it_behaves_like 'record with exportable associations' + end + + context 'when the single association record is not exportable' do + before do + allow_next_found_instance_of(Issue) do |issue| + allow(issue).to receive(:restricted_associations).with([many_relation, single_relation]).and_return([single_relation]) + allow(issue).to receive(:readable_records).with(single_relation, current_user: user).and_return(nil) + end + end + + it_behaves_like 'record with exportable associations' do + let(:expected_issue) do + issue_hash[single_relation] = nil + issue_hash.to_json(options) + end + end end end - it 'filters out not exportable association' do - expect(json_writer).to receive(:write_relation_array).with(exportable_path, :issues, array_including(issue.to_json)) + context 'when the associations are not restricted' do + before do + allow_next_found_instance_of(Issue) do |issue| + allow(issue).to receive(:restricted_associations).with([many_relation, single_relation]).and_return([]) + end + end - subject.execute + it_behaves_like 'record with exportable associations' end end end diff --git a/spec/models/concerns/exportable_spec.rb b/spec/models/concerns/exportable_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..74709b06403cfbb932cf4b9a17edbefefe1e785b --- /dev/null +++ b/spec/models/concerns/exportable_spec.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Exportable, feature_category: :importers do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:milestone) { create(:milestone, project: project) } + let_it_be(:issue) { create(:issue, project: project, milestone: milestone) } + let_it_be(:note1) { create(:system_note, project: project, noteable: issue) } + let_it_be(:note2) { create(:system_note, project: project, noteable: issue) } + + let_it_be(:model_klass) do + Class.new(ApplicationRecord) do + include Exportable + + belongs_to :project + has_one :milestone + has_many :notes + + self.table_name = 'issues' + + def self.name + 'Issue' + end + end + end + + subject { model_klass.new } + + describe '.readable_records' do + let_it_be(:model_record) { model_klass.new } + + context 'when model does not respond to association name' do + it 'returns nil' do + expect(subject.readable_records(:foo, current_user: user)).to be_nil + end + end + + context 'when model does respond to association name' do + context 'when there are no records' do + it 'returns nil' do + expect(model_record.readable_records(:notes, current_user: user)).to be_nil + end + end + + context 'when association has #exportable_record? defined' do + before do + allow(model_record).to receive(:try).with(:notes).and_return(issue.notes) + end + + context 'when user can read all records' do + before do + allow_next_found_instance_of(Note) do |note| + allow(note).to receive(:respond_to?).with(:exportable_record?).and_return(true) + allow(note).to receive(:exportable_record?).with(user).and_return(true) + end + end + + it 'returns collection of readable records' do + expect(model_record.readable_records(:notes, current_user: user)).to contain_exactly(note1, note2) + end + end + + context 'when user can not read records' do + before do + allow_next_instance_of(Note) do |note| + allow(note).to receive(:respond_to?).with(:exportable_record?).and_return(true) + allow(note).to receive(:exportable_record?).with(user).and_return(false) + end + end + + it 'returns collection of readable records' do + expect(model_record.readable_records(:notes, current_user: user)).to eq([]) + end + end + end + + context 'when association does not have #exportable_record? defined' do + before do + allow(model_record).to receive(:try).with(:notes).and_return([note1]) + + allow(note1).to receive(:respond_to?).and_call_original + allow(note1).to receive(:respond_to?).with(:exportable_record?).and_return(false) + end + + it 'calls #readable_by?' do + expect(note1).to receive(:readable_by?).with(user) + + model_record.readable_records(:notes, current_user: user) + end + end + + context 'with single relation' do + before do + allow(model_record).to receive(:try).with(:milestone).and_return(issue.milestone) + end + + context 'when user can read the record' do + before do + allow(milestone).to receive(:readable_by?).with(user).and_return(true) + end + + it 'returns collection of readable records' do + expect(model_record.readable_records(:milestone, current_user: user)).to eq(milestone) + end + end + + context 'when user can not read the record' do + before do + allow(milestone).to receive(:readable_by?).with(user).and_return(false) + end + + it 'returns collection of readable records' do + expect(model_record.readable_records(:milestone, current_user: user)).to be_nil + end + end + end + end + end + + describe '.exportable_association?' do + context 'when model does not respond to association name' do + it 'returns false' do + expect(subject.exportable_association?(:tests)).to eq(false) + + allow(issue).to receive(:respond_to?).with(:tests).and_return(false) + end + end + + context 'when model responds to association name' do + let_it_be(:model_record) { model_klass.new } + + context 'when association contains records' do + before do + allow(model_record).to receive(:try).with(:milestone).and_return(milestone) + end + + context 'when current_user is not present' do + it 'returns false' do + expect(model_record.exportable_association?(:milestone)).to eq(false) + end + end + + context 'when current_user can read association' do + before do + allow(milestone).to receive(:readable_by?).with(user).and_return(true) + end + + it 'returns true' do + expect(model_record.exportable_association?(:milestone, current_user: user)).to eq(true) + end + end + + context 'when current_user can not read association' do + before do + allow(milestone).to receive(:readable_by?).with(user).and_return(false) + end + + it 'returns false' do + expect(model_record.exportable_association?(:milestone, current_user: user)).to eq(false) + end + end + end + + context 'when association is empty' do + before do + allow(model_record).to receive(:try).with(:milestone).and_return(nil) + allow(milestone).to receive(:readable_by?).with(user).and_return(true) + end + + it 'returns true' do + expect(model_record.exportable_association?(:milestone, current_user: user)).to eq(true) + end + end + + context 'when association type is has_many' do + it 'returns true' do + expect(subject.exportable_association?(:notes)).to eq(true) + end + end + end + end + + describe '.restricted_associations' do + let(:model_associations) { [:notes, :labels] } + + context 'when `exportable_restricted_associations` is not defined in inheriting class' do + it 'returns empty array' do + expect(subject.restricted_associations(model_associations)).to eq([]) + end + end + + context 'when `exportable_restricted_associations` is defined in inheriting class' do + before do + stub_const('DummyModel', model_klass) + + DummyModel.class_eval do + def exportable_restricted_associations + super + [:notes] + end + end + end + + it 'returns empty array if provided key are not restricted' do + expect(subject.restricted_associations([:labels])).to eq([]) + end + + it 'returns array with restricted keys' do + expect(subject.restricted_associations(model_associations)).to contain_exactly(:notes) + end + end + end + + describe '.has_many_association?' do + let(:model_associations) { [:notes, :labels] } + + context 'when association type is `has_many`' do + it 'returns true' do + expect(subject.has_many_association?(:notes)).to eq(true) + end + end + + context 'when association type is `has_one`' do + it 'returns true' do + expect(subject.has_many_association?(:milestone)).to eq(false) + end + end + + context 'when association type is `belongs_to`' do + it 'returns true' do + expect(subject.has_many_association?(:project)).to eq(false) + end + end + end +end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 27a4132c27ef21c51a8ea881189bb4dc4a8c276a..aa284f34c2fc6a50120f0aec9e16c634578fa68a 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -1878,4 +1878,34 @@ def expect_expiration(noteable) it { is_expected.to eq :read_internal_note } end end + + describe '#exportable_record?' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :private) } + let_it_be(:noteable) { create(:issue, project: project) } + + subject { note.exportable_record?(user) } + + context 'when not a system note' do + let(:note) { build(:note, noteable: noteable) } + + it { is_expected.to be_truthy } + end + + context 'with system note' do + let(:note) { build(:system_note, project: project, noteable: noteable) } + + it 'returns `false` when the user cannot read the note' do + is_expected.to be_falsey + end + + context 'when user can read the note' do + before do + project.add_developer(user) + end + + it { is_expected.to be_truthy } + end + end + end end diff --git a/spec/support/shared_examples/models/exportable_shared_examples.rb b/spec/support/shared_examples/models/exportable_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..37c3e68fd5f466e92f242edd7d3e86357bfb4703 --- /dev/null +++ b/spec/support/shared_examples/models/exportable_shared_examples.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'resource with exportable associations' do + before do + stub_licensed_features(stubbed_features) if stubbed_features.any? + end + + describe '#exportable_association?' do + let(:association) { single_association } + + subject { resource.exportable_association?(association, current_user: user) } + + it { is_expected.to be_falsey } + + context 'when user can read resource' do + before do + group.add_developer(user) + end + + it { is_expected.to be_falsey } + + context "when user can read resource's association" do + before do + other_group.add_developer(user) + end + + it { is_expected.to be_truthy } + + context 'for an unknown association' do + let(:association) { :foo } + + it { is_expected.to be_falsey } + end + + context 'for an unauthenticated user' do + let(:user) { nil } + + it { is_expected.to be_falsey } + end + end + end + end + + describe '#readable_records' do + subject { resource.readable_records(association, current_user: user) } + + before do + group.add_developer(user) + end + + context 'when association not supported' do + let(:association) { :foo } + + it { is_expected.to be_nil } + end + + context 'when association is `:notes`' do + let(:association) { :notes } + + it { is_expected.to match_array([readable_note]) } + + context 'when user have access' do + before do + other_group.add_developer(user) + end + + it 'returns all records' do + is_expected.to match_array([readable_note, restricted_note]) + end + end + end + end +end