diff --git a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql index 8a5613c75d20d6d5d5d45d0866fb3c97fd0864fd..e0de6d12b13e5d564cbded353fa41c5141b13c77 100644 --- a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql +++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql @@ -1,5 +1,6 @@ fragment Release on Release { __typename + id name tagName tagPath diff --git a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql index 1823a32735058273cd6ef07499d89769da3cdc06..236d266a40a8b6aaa158069b5ce83b6ee3c6fe15 100644 --- a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql +++ b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql @@ -1,4 +1,5 @@ fragment ReleaseForEditing on Release { + id name tagName description diff --git a/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql b/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql index 56bfe7c23d6eff2b5606b0a3c72e8a28687a63e8..7344772adb98dbbe8a86c2b1b4419a53a8a24bbb 100644 --- a/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql +++ b/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql @@ -1,6 +1,7 @@ mutation createRelease($input: ReleaseCreateInput!) { releaseCreate(input: $input) { release { + id links { selfUrl } diff --git a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql index bda7ac52a478f958d243f2757f9c12aee1d1996b..61a06f268bd4ca12773408e8d5eb7cdd6751f93c 100644 --- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql @@ -13,6 +13,7 @@ query allReleases( __typename nodes { __typename + id name tagName tagPath diff --git a/app/graphql/resolvers/milestones_resolver.rb b/app/graphql/resolvers/milestones_resolver.rb index dc6d781f584be3f93cc0cfcc4e3d5bec47875928..25ff783b408bab1243d16737851ddc4bd6df7552 100644 --- a/app/graphql/resolvers/milestones_resolver.rb +++ b/app/graphql/resolvers/milestones_resolver.rb @@ -4,6 +4,11 @@ module Resolvers class MilestonesResolver < BaseResolver include Gitlab::Graphql::Authorize::AuthorizeResource include TimeFrameArguments + include LooksAhead + + # authorize before resolution + authorize :read_milestone + authorizes_object! argument :ids, [GraphQL::Types::ID], required: false, @@ -34,12 +39,10 @@ class MilestonesResolver < BaseResolver NON_STABLE_CURSOR_SORTS = %i[expired_last_due_date_asc expired_last_due_date_desc].freeze - def resolve(**args) + def resolve_with_lookahead(**args) validate_timeframe_params!(args) - authorize! - - milestones = MilestonesFinder.new(milestones_finder_params(args)).execute + milestones = apply_lookahead(MilestonesFinder.new(milestones_finder_params(args)).execute) if non_stable_cursor_sort?(args[:sort]) offset_pagination(milestones) @@ -50,6 +53,12 @@ def resolve(**args) private + def preloads + { + releases: :releases + } + end + def milestones_finder_params(args) { ids: parse_gids(args[:ids]), @@ -69,12 +78,6 @@ def parent_id_parameters(args) raise NotImplementedError end - # MilestonesFinder does not check for current_user permissions, - # so for now we need to keep it here. - def authorize! - Ability.allowed?(context[:current_user], :read_milestone, parent) || raise_resource_not_available_error! - end - def parse_gids(gids) gids&.map { |gid| GitlabSchema.parse_gid(gid, expected_type: Milestone).model_id } end diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb index 18e4a5d33e3942e8e0636646486e2bb1a3d9f651..7741fd723f0e93a6f8b6a67ccf473fcf7130f4c0 100644 --- a/app/graphql/types/milestone_type.rb +++ b/app/graphql/types/milestone_type.rb @@ -59,6 +59,10 @@ class MilestoneType < BaseObject field :stats, Types::MilestoneStatsType, null: true, description: 'Milestone statistics.' + field :releases, ::Types::ReleaseType.connection_type, + null: true, + description: 'Releases associated with this milestone.' + def stats milestone end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 01b1a71896a7514fbdeb387212c830c0984078d4..bc3774bcc1d5fb9c112f87e763d683a573deea1e 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -52,6 +52,7 @@ class QueryType < ::Types::BaseObject field :milestone, ::Types::MilestoneType, null: true, + extras: [:lookahead], description: 'Find a milestone.' do argument :id, ::Types::GlobalIDType[Milestone], required: true, description: 'Find a milestone by its ID.' end @@ -156,8 +157,9 @@ def merge_request(id:) GitlabSchema.find_by_gid(id) end - def milestone(id:) - GitlabSchema.find_by_gid(id) + def milestone(id:, lookahead:) + preloads = [:releases] if lookahead.selects?(:releases) + Gitlab::Graphql::Loaders::BatchModelLoader.new(id.model_class, id.model_id, preloads).find end def container_repository(id:) diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb index 95b6b43bb46cdef6d08958d265a68837037a659a..43dc0c4ce85c2ea6acaefe9dac9afd9f335be6ea 100644 --- a/app/graphql/types/release_type.rb +++ b/app/graphql/types/release_type.rb @@ -13,6 +13,9 @@ class ReleaseType < BaseObject present_using ReleasePresenter + field :id, ::Types::GlobalIDType[Release], + null: false, + description: 'Global ID of the release.' field :assets, Types::ReleaseAssetsType, null: true, method: :itself, description: 'Assets of the release.' field :created_at, Types::TimeType, null: true, diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 742383e411a18b80ac0a3493fcc9678d58f4f80a..9860abcb8e6d3b9c889814d0271032100b6d28a1 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -13854,6 +13854,7 @@ Represents a milestone. | <a id="milestoneid"></a>`id` | [`ID!`](#id) | ID of the milestone. | | <a id="milestoneiid"></a>`iid` | [`ID!`](#id) | Internal ID of the milestone. | | <a id="milestoneprojectmilestone"></a>`projectMilestone` | [`Boolean!`](#boolean) | Indicates if milestone is at project level. | +| <a id="milestonereleases"></a>`releases` | [`ReleaseConnection`](#releaseconnection) | Releases associated with this milestone. (see [Connections](#connections)) | | <a id="milestonestartdate"></a>`startDate` | [`Time`](#time) | Timestamp of the milestone start date. | | <a id="milestonestate"></a>`state` | [`MilestoneStateEnum!`](#milestonestateenum) | State of the milestone. | | <a id="milestonestats"></a>`stats` | [`MilestoneStats`](#milestonestats) | Milestone statistics. | @@ -15851,6 +15852,7 @@ Represents a release. | <a id="releasedescription"></a>`description` | [`String`](#string) | Description (also known as "release notes") of the release. | | <a id="releasedescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | The GitLab Flavored Markdown rendering of `description`. | | <a id="releaseevidences"></a>`evidences` | [`ReleaseEvidenceConnection`](#releaseevidenceconnection) | Evidence for the release. (see [Connections](#connections)) | +| <a id="releaseid"></a>`id` | [`ReleaseID!`](#releaseid) | Global ID of the release. | | <a id="releaselinks"></a>`links` | [`ReleaseLinks`](#releaselinks) | Links of the release. | | <a id="releasemilestones"></a>`milestones` | [`MilestoneConnection`](#milestoneconnection) | Milestones associated to the release. (see [Connections](#connections)) | | <a id="releasename"></a>`name` | [`String`](#string) | Name of the release. | @@ -20133,6 +20135,12 @@ A `ProjectID` is a global ID. It is encoded as a string. An example `ProjectID` is: `"gid://gitlab/Project/1"`. +### `ReleaseID` + +A `ReleaseID` is a global ID. It is encoded as a string. + +An example `ReleaseID` is: `"gid://gitlab/Release/1"`. + ### `ReleasesLinkID` A `ReleasesLinkID` is a global ID. It is encoded as a string. diff --git a/ee/app/graphql/resolvers/timebox_report_resolver.rb b/ee/app/graphql/resolvers/timebox_report_resolver.rb index c610f29bebf81741ff9a4b7c6a2937a0ae545fce..ce4d0afc6cfb331118f0ceb96314ed9404a26afc 100644 --- a/ee/app/graphql/resolvers/timebox_report_resolver.rb +++ b/ee/app/graphql/resolvers/timebox_report_resolver.rb @@ -18,9 +18,7 @@ def resolve(**args) response = TimeboxReportService.new(timebox, project_scopes).execute - raise GraphQL::ExecutionError, response.message if response.error? - - response.payload + response.payload if response.success? end private diff --git a/ee/spec/graphql/resolvers/timebox_report_resolver_spec.rb b/ee/spec/graphql/resolvers/timebox_report_resolver_spec.rb index 2589e7ab3e7f63aafdb773222e11fcc488d268da..1cbf859e0ef66143cfc046630ec5e68276db9a9a 100644 --- a/ee/spec/graphql/resolvers/timebox_report_resolver_spec.rb +++ b/ee/spec/graphql/resolvers/timebox_report_resolver_spec.rb @@ -70,11 +70,7 @@ stub_const('TimeboxReportService::EVENT_COUNT_LIMIT', 1) end - it 'generates a GraphQL error' do - expect_graphql_error_to_be_created(GraphQL::ExecutionError, 'Burnup chart could not be generated due to too many events') do - subject - end - end + it { is_expected.to be_nil } end end diff --git a/ee/spec/requests/api/graphql/milestone_spec.rb b/ee/spec/requests/api/graphql/milestone_spec.rb index 34f69331e122842f2df8d93c255023ba7b50ea0f..0e1ffb693ae39abbf2a4646d9994db3cf7007489 100644 --- a/ee/spec/requests/api/graphql/milestone_spec.rb +++ b/ee/spec/requests/api/graphql/milestone_spec.rb @@ -46,10 +46,10 @@ stub_licensed_features(milestone_charts: false) end - it 'returns an error' do + it 'returns nil' do post_graphql(query, current_user: current_user) - expect(graphql_errors).to include(a_hash_including('message' => 'Milestone does not support burnup charts')) + expect(graphql_data_at(:milestone, :report)).to be_nil end end diff --git a/lib/gitlab/graphql/authorize/authorize_resource.rb b/lib/gitlab/graphql/authorize/authorize_resource.rb index dc49c806398a8a9b9fcd69bdda268b25d81411a5..884fc85c4ecb341e63b9e7157fb579c9ebf68f3a 100644 --- a/lib/gitlab/graphql/authorize/authorize_resource.rb +++ b/lib/gitlab/graphql/authorize/authorize_resource.rb @@ -15,11 +15,7 @@ def required_permissions # If the `#authorize` call is used on multiple classes, we add the # permissions specified on a subclass, to the ones that were specified # on its superclass. - @required_permissions ||= if respond_to?(:superclass) && superclass.respond_to?(:required_permissions) - superclass.required_permissions.dup - else - [] - end + @required_permissions ||= call_superclass_method(:required_permissions, []).dup end def authorize(*permissions) @@ -27,6 +23,8 @@ def authorize(*permissions) end def authorizes_object? + return true if call_superclass_method(:authorizes_object?, false) + defined?(@authorizes_object) ? @authorizes_object : false end @@ -37,6 +35,14 @@ def authorizes_object! def raise_resource_not_available_error!(msg = RESOURCE_ACCESS_ERROR) raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, msg end + + private + + def call_superclass_method(method_name, or_else) + return or_else unless respond_to?(:superclass) && superclass.respond_to?(method_name) + + superclass.send(method_name) # rubocop: disable GitlabSecurity/PublicSend + end end def find_object(*args) diff --git a/lib/gitlab/graphql/loaders/batch_model_loader.rb b/lib/gitlab/graphql/loaders/batch_model_loader.rb index 805864cdd4c65f9556e64e449a8052a99fe22792..41c3af339092b5120505c622a6a52f615e595f08 100644 --- a/lib/gitlab/graphql/loaders/batch_model_loader.rb +++ b/lib/gitlab/graphql/loaders/batch_model_loader.rb @@ -4,20 +4,27 @@ module Gitlab module Graphql module Loaders class BatchModelLoader - attr_reader :model_class, :model_id + attr_reader :model_class, :model_id, :preloads - def initialize(model_class, model_id) + def initialize(model_class, model_id, preloads = nil) @model_class = model_class @model_id = model_id + @preloads = preloads || [] end # rubocop: disable CodeReuse/ActiveRecord def find - BatchLoader::GraphQL.for(model_id.to_i).batch(key: model_class) do |ids, loader, args| + BatchLoader::GraphQL.for([model_id.to_i, preloads]).batch(key: model_class) do |for_params, loader, args| model = args[:key] + keys_by_id = for_params.group_by(&:first) + ids = for_params.map(&:first) + preloads = for_params.flat_map(&:second).uniq results = model.where(id: ids) + results = results.preload(*preloads) unless preloads.empty? - results.each { |record| loader.call(record.id, record) } + results.each do |record| + keys_by_id.fetch(record.id, []).each { |k| loader.call(k, record) } + end end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/spec/graphql/features/authorization_spec.rb b/spec/graphql/features/authorization_spec.rb index 1d518e20da7a45eb6bc98d08ba38a042b2009326..5ae497f9d37a3c5031182a7954f0bb366f621214 100644 --- a/spec/graphql/features/authorization_spec.rb +++ b/spec/graphql/features/authorization_spec.rb @@ -179,7 +179,7 @@ describe 'type and field authorizations together' do let(:authorizing_object) { anything } let(:permission_1) { permission_collection.first } - let(:permission_2) { permission_collection.last } + let(:permission_2) { permission_collection.second } let(:type) do type_factory do |type| @@ -224,6 +224,55 @@ include_examples 'authorization with a collection of permissions' end + context 'when the resolver is a subclass of one that authorizes the object' do + let(:permission_object_one) { be_nil } + let(:permission_object_two) { be_nil } + let(:parent) do + parent = Class.new(Resolvers::BaseResolver) + parent.include(::Gitlab::Graphql::Authorize::AuthorizeResource) + parent.authorizes_object! + parent.authorize permission_1 + parent + end + + let(:resolver) do + simple_resolver(test_object, base_class: parent) + end + + include_examples 'authorization with a collection of permissions' + end + + context 'when the resolver is a subclass of one that authorizes the object, extra permission' do + let(:permission_object_one) { be_nil } + let(:permission_object_two) { be_nil } + let(:parent) do + parent = Class.new(Resolvers::BaseResolver) + parent.include(::Gitlab::Graphql::Authorize::AuthorizeResource) + parent.authorizes_object! + parent.authorize permission_1 + parent + end + + let(:resolver) do + resolver = simple_resolver(test_object, base_class: parent) + resolver.include(::Gitlab::Graphql::Authorize::AuthorizeResource) + resolver.authorize permission_2 + resolver + end + + context 'when the field does not define any permissions' do + let(:query_type) do + query_factory do |query| + query.field :item, type, + null: true, + resolver: resolver + end + end + + include_examples 'authorization with a collection of permissions' + end + end + context 'when the resolver does not authorize the object, but instead calls authorized_find!' do let(:permission_object_one) { test_object } let(:permission_object_two) { be_nil } diff --git a/spec/graphql/resolvers/group_milestones_resolver_spec.rb b/spec/graphql/resolvers/group_milestones_resolver_spec.rb index 7abc779a63c0d247971ea598cf1d7ccd05b0daea..3d0c4a9d7cb9ed15effd859121c1e81a217f55b8 100644 --- a/spec/graphql/resolvers/group_milestones_resolver_spec.rb +++ b/spec/graphql/resolvers/group_milestones_resolver_spec.rb @@ -126,16 +126,6 @@ def args(**arguments) end end - context 'when user cannot read milestones' do - it 'generates an error' do - unauthorized_user = create(:user) - - expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do - resolve_group_milestones({}, { current_user: unauthorized_user }) - end - end - end - context 'when including descendant milestones in a public group' do let_it_be(:group) { create(:group, :public) } diff --git a/spec/graphql/resolvers/project_milestones_resolver_spec.rb b/spec/graphql/resolvers/project_milestones_resolver_spec.rb index 2cf490c2b6a2a83c5dd3a8ae42ceb9c79932abc4..d99a3a40a6c2a502df632277f6a2022403c8266b 100644 --- a/spec/graphql/resolvers/project_milestones_resolver_spec.rb +++ b/spec/graphql/resolvers/project_milestones_resolver_spec.rb @@ -172,15 +172,5 @@ def resolve_project_milestones(args = {}, context = { current_user: current_user resolve_project_milestones(containing_date: t) end end - - context 'when user cannot read milestones' do - it 'generates an error' do - unauthorized_user = create(:user) - - expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do - resolve_project_milestones({}, { current_user: unauthorized_user }) - end - end - end end end diff --git a/spec/graphql/types/base_field_spec.rb b/spec/graphql/types/base_field_spec.rb index 9d02f061435ba0ffc000d2616d7213235a819af9..e701ec483a33c16b64b580150ce4a45e9f57959b 100644 --- a/spec/graphql/types/base_field_spec.rb +++ b/spec/graphql/types/base_field_spec.rb @@ -3,6 +3,91 @@ require 'spec_helper' RSpec.describe Types::BaseField do + describe 'authorized?' do + let(:object) { double } + let(:current_user) { nil } + let(:ctx) { { current_user: current_user } } + + it 'defaults to true' do + field = described_class.new(name: 'test', type: GraphQL::Types::String, null: true) + + expect(field).to be_authorized(object, nil, ctx) + end + + it 'tests the field authorization, if provided' do + field = described_class.new(name: 'test', type: GraphQL::Types::String, null: true, authorize: :foo) + + expect(Ability).to receive(:allowed?).with(current_user, :foo, object).and_return(false) + + expect(field).not_to be_authorized(object, nil, ctx) + end + + it 'tests the field authorization, if provided, when it succeeds' do + field = described_class.new(name: 'test', type: GraphQL::Types::String, null: true, authorize: :foo) + + expect(Ability).to receive(:allowed?).with(current_user, :foo, object).and_return(true) + + expect(field).to be_authorized(object, nil, ctx) + end + + it 'only tests the resolver authorization if it authorizes_object?' do + resolver = Class.new + + field = described_class.new(name: 'test', type: GraphQL::Types::String, null: true, + resolver_class: resolver) + + expect(field).to be_authorized(object, nil, ctx) + end + + it 'tests the resolver authorization, if provided' do + resolver = Class.new do + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorizes_object! + end + + field = described_class.new(name: 'test', type: GraphQL::Types::String, null: true, + resolver_class: resolver) + + expect(resolver).to receive(:authorized?).with(object, ctx).and_return(false) + + expect(field).not_to be_authorized(object, nil, ctx) + end + + it 'tests field authorization before resolver authorization, when field auth fails' do + resolver = Class.new do + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorizes_object! + end + + field = described_class.new(name: 'test', type: GraphQL::Types::String, null: true, + authorize: :foo, + resolver_class: resolver) + + expect(Ability).to receive(:allowed?).with(current_user, :foo, object).and_return(false) + + expect(field).not_to be_authorized(object, nil, ctx) + end + + it 'tests field authorization before resolver authorization, when field auth succeeds' do + resolver = Class.new do + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorizes_object! + end + + field = described_class.new(name: 'test', type: GraphQL::Types::String, null: true, + authorize: :foo, + resolver_class: resolver) + + expect(Ability).to receive(:allowed?).with(current_user, :foo, object).and_return(true) + expect(resolver).to receive(:authorized?).with(object, ctx).and_return(false) + + expect(field).not_to be_authorized(object, nil, ctx) + end + end + context 'when considering complexity' do let(:resolver) do Class.new(described_class) do diff --git a/spec/lib/gitlab/graphql/authorize/authorize_resource_spec.rb b/spec/lib/gitlab/graphql/authorize/authorize_resource_spec.rb index 0c548e1ce325cfcd2ad124d91d72f924daaa4405..ac512e28e7bf5dc34b10923306bfc7c79a478e5b 100644 --- a/spec/lib/gitlab/graphql/authorize/authorize_resource_spec.rb +++ b/spec/lib/gitlab/graphql/authorize/authorize_resource_spec.rb @@ -103,4 +103,36 @@ def self.authorization .to contain_exactly(:base_authorization, :sub_authorization) end end + + describe 'authorizes_object?' do + it 'is false by default' do + a_class = Class.new do + include Gitlab::Graphql::Authorize::AuthorizeResource + end + + expect(a_class).not_to be_authorizes_object + end + + it 'is true after calling authorizes_object!' do + a_class = Class.new do + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorizes_object! + end + + expect(a_class).to be_authorizes_object + end + + it 'is true if a parent authorizes_object' do + parent = Class.new do + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorizes_object! + end + + child = Class.new(parent) + + expect(child).to be_authorizes_object + end + end end diff --git a/spec/requests/api/graphql/milestone_spec.rb b/spec/requests/api/graphql/milestone_spec.rb index 59de116fa2b940f4d51baa9c76bfa6b291417d4e..f6835936418bbaa48c9e928be00c38189cc27011 100644 --- a/spec/requests/api/graphql/milestone_spec.rb +++ b/spec/requests/api/graphql/milestone_spec.rb @@ -5,43 +5,125 @@ RSpec.describe 'Querying a Milestone' do include GraphqlHelpers - let_it_be(:current_user) { create(:user) } + let_it_be(:guest) { create(:user) } let_it_be(:project) { create(:project) } let_it_be(:milestone) { create(:milestone, project: project) } + let_it_be(:release_a) { create(:release, project: project) } + let_it_be(:release_b) { create(:release, project: project) } - let(:query) do - graphql_query_for('milestone', { id: milestone.to_global_id.to_s }, 'title') + before_all do + milestone.releases << [release_a, release_b] + project.add_guest(guest) end - subject { graphql_data['milestone'] } - - before do - post_graphql(query, current_user: current_user) + let(:expected_release_nodes) do + contain_exactly(a_graphql_entity_for(release_a), a_graphql_entity_for(release_b)) end - context 'when the user has access to the milestone' do - before_all do - project.add_guest(current_user) + context 'when we post the query' do + let(:current_user) { nil } + let(:query) do + graphql_query_for('milestone', { id: milestone.to_global_id.to_s }, all_graphql_fields_for('Milestone')) end - it_behaves_like 'a working graphql query' + subject { graphql_data['milestone'] } - it { is_expected.to include('title' => milestone.name) } - end + before do + post_graphql(query, current_user: current_user) + end - context 'when the user does not have access to the milestone' do - it_behaves_like 'a working graphql query' + context 'when the user has access to the milestone' do + let(:current_user) { guest } - it { is_expected.to be_nil } + it_behaves_like 'a working graphql query' + + it { is_expected.to include('title' => milestone.name) } + + it 'contains release information' do + is_expected.to include('releases' => include('nodes' => expected_release_nodes)) + end + end + + context 'when the user does not have access to the milestone' do + it_behaves_like 'a working graphql query' + + it { is_expected.to be_nil } + end + + context 'when ID argument is missing' do + let(:query) do + graphql_query_for('milestone', {}, 'title') + end + + it 'raises an exception' do + expect(graphql_errors).to include(a_hash_including('message' => "Field 'milestone' is missing required arguments: id")) + end + end end - context 'when ID argument is missing' do - let(:query) do - graphql_query_for('milestone', {}, 'title') + context 'when there are two milestones' do + let_it_be(:milestone_b) { create(:milestone, project: project) } + + let(:current_user) { guest } + let(:milestone_fields) do + <<~GQL + fragment milestoneFields on Milestone { + #{all_graphql_fields_for('Milestone', max_depth: 1)} + releases { nodes { #{all_graphql_fields_for('Release', max_depth: 1)} } } + } + GQL + end + + let(:single_query) do + <<~GQL + query ($id_a: MilestoneID!) { + a: milestone(id: $id_a) { ...milestoneFields } + } + + #{milestone_fields} + GQL + end + + let(:multi_query) do + <<~GQL + query ($id_a: MilestoneID!, $id_b: MilestoneID!) { + a: milestone(id: $id_a) { ...milestoneFields } + b: milestone(id: $id_b) { ...milestoneFields } + } + #{milestone_fields} + GQL + end + + it 'produces correct results' do + r = run_with_clean_state(multi_query, + context: { current_user: current_user }, + variables: { + id_a: global_id_of(milestone).to_s, + id_b: milestone_b.to_global_id.to_s + }) + + expect(r.to_h['errors']).to be_blank + expect(graphql_dig_at(r.to_h, :data, :a, :releases, :nodes)).to match expected_release_nodes + expect(graphql_dig_at(r.to_h, :data, :b, :releases, :nodes)).to be_empty end - it 'raises an exception' do - expect(graphql_errors).to include(a_hash_including('message' => "Field 'milestone' is missing required arguments: id")) + it 'does not suffer from N+1 performance issues' do + baseline = ActiveRecord::QueryRecorder.new do + run_with_clean_state(single_query, + context: { current_user: current_user }, + variables: { id_a: milestone.to_global_id.to_s }) + end + + multi = ActiveRecord::QueryRecorder.new do + run_with_clean_state(multi_query, + context: { current_user: current_user }, + variables: { + id_a: milestone.to_global_id.to_s, + id_b: milestone_b.to_global_id.to_s + }) + end + + expect(multi).not_to exceed_query_limit(baseline) end end end diff --git a/spec/requests/api/graphql/project/milestones_spec.rb b/spec/requests/api/graphql/project/milestones_spec.rb index 3e8948d83b18b1cda3960492ae50722e061e1dbb..d1ee157fc74e87e093022ac06100fca5ffe1c5b0 100644 --- a/spec/requests/api/graphql/project/milestones_spec.rb +++ b/spec/requests/api/graphql/project/milestones_spec.rb @@ -59,6 +59,27 @@ def result_list(expected) end end + context 'the user does not have access' do + let_it_be(:project) { create(:project) } + let_it_be(:milestones) { create_list(:milestone, 2, project: project) } + + it 'is nil' do + post_graphql(query, current_user: current_user) + + expect(graphql_data_at(:project)).to be_nil + end + + context 'the user has access' do + let(:expected) { milestones } + + before do + project.add_guest(current_user) + end + + it_behaves_like 'searching with parameters' + end + end + context 'there are no search params' do let(:search_params) { nil } let(:expected) { all_milestones } diff --git a/spec/support/graphql/resolver_factories.rb b/spec/support/graphql/resolver_factories.rb index 8188f17cc43c52e388e956208f8af3474bfbbf77..3c5aad34e8ba948cb817eed2af3f9e7779ceaf2b 100644 --- a/spec/support/graphql/resolver_factories.rb +++ b/spec/support/graphql/resolver_factories.rb @@ -15,8 +15,8 @@ def new_resolver(resolved_value = 'Resolved value', method: :resolve) private - def simple_resolver(resolved_value = 'Resolved value') - Class.new(Resolvers::BaseResolver) do + def simple_resolver(resolved_value = 'Resolved value', base_class: Resolvers::BaseResolver) + Class.new(base_class) do define_method :resolve do |**_args| resolved_value end diff --git a/spec/support/matchers/exceed_query_limit.rb b/spec/support/matchers/exceed_query_limit.rb index b48c7f905b24e6517002d66dc6ea09e261d93bda..23f43e05a10a08db235f7f2889633a4dbf6aac85 100644 --- a/spec/support/matchers/exceed_query_limit.rb +++ b/spec/support/matchers/exceed_query_limit.rb @@ -323,7 +323,12 @@ def skip_cached include ExceedQueryLimitHelpers match do |block| - verify_count(&block) + if block.is_a?(ActiveRecord::QueryRecorder) + @recorder = block + verify_count + else + verify_count(&block) + end end failure_message_when_negated do |actual|