diff options
Diffstat (limited to 'spec/requests/api/graphql')
94 files changed, 4498 insertions, 556 deletions
diff --git a/spec/requests/api/graphql/achievements/user_achievements_query_spec.rb b/spec/requests/api/graphql/achievements/user_achievements_query_spec.rb new file mode 100644 index 00000000000..080f375245d --- /dev/null +++ b/spec/requests/api/graphql/achievements/user_achievements_query_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'UserAchievements', feature_category: :user_profile do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group, :public) } + let_it_be(:achievement) { create(:achievement, namespace: group) } + let_it_be(:non_revoked_achievement1) { create(:user_achievement, achievement: achievement, user: user) } + let_it_be(:non_revoked_achievement2) { create(:user_achievement, :revoked, achievement: achievement, user: user) } + let_it_be(:fields) do + <<~HEREDOC + id + achievements { + nodes { + userAchievements { + nodes { + id + achievement { + id + } + user { + id + } + awardedByUser { + id + } + revokedByUser { + id + } + } + } + } + } + HEREDOC + end + + let_it_be(:query) do + graphql_query_for('namespace', { full_path: group.full_path }, fields) + end + + before_all do + group.add_guest(user) + end + + before do + post_graphql(query, current_user: user) + end + + it_behaves_like 'a working graphql query' + + it 'returns all non_revoked user_achievements' do + expect(graphql_data_at(:namespace, :achievements, :nodes, :userAchievements, :nodes)) + .to contain_exactly( + a_graphql_entity_for(non_revoked_achievement1) + ) + end + + it 'can lookahead to eliminate N+1 queries', :use_clean_rails_memory_store_caching do + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post_graphql(query, current_user: user) + end.count + + user2 = create(:user) + create(:user_achievement, achievement: achievement, user: user2) + + expect { post_graphql(query, current_user: user) }.not_to exceed_all_query_limit(control_count) + end + + context 'when the achievements feature flag is disabled' do + before do + stub_feature_flags(achievements: false) + post_graphql(query, current_user: user) + end + + specify { expect(graphql_data_at(:namespace, :achievements, :nodes, :userAchievements, :nodes)).to be_empty } + end +end diff --git a/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb b/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb index 95cabfea2fc..0437a30eccd 100644 --- a/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb +++ b/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb @@ -50,7 +50,6 @@ RSpec.describe 'Getting Ci Cd Setting', feature_category: :continuous_integratio expect(settings_data['jobTokenScopeEnabled']).to eql project.ci_cd_settings.job_token_scope_enabled? expect(settings_data['inboundJobTokenScopeEnabled']).to eql( project.ci_cd_settings.inbound_job_token_scope_enabled?) - expect(settings_data['optInJwt']).to eql project.ci_cd_settings.opt_in_jwt? end end end diff --git a/spec/requests/api/graphql/ci/config_variables_spec.rb b/spec/requests/api/graphql/ci/config_variables_spec.rb index f76bb8ff837..4bad5dec684 100644 --- a/spec/requests/api/graphql/ci/config_variables_spec.rb +++ b/spec/requests/api/graphql/ci/config_variables_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Query.project(fullPath).ciConfigVariables(sha)', feature_category: :pipeline_authoring do +RSpec.describe 'Query.project(fullPath).ciConfigVariables(ref)', feature_category: :secrets_management do include GraphqlHelpers include ReactiveCachingHelpers @@ -20,7 +20,7 @@ RSpec.describe 'Query.project(fullPath).ciConfigVariables(sha)', feature_categor %( query { project(fullPath: "#{project.full_path}") { - ciConfigVariables(sha: "#{ref}") { + ciConfigVariables(ref: "#{ref}") { key value valueOptions diff --git a/spec/requests/api/graphql/ci/group_variables_spec.rb b/spec/requests/api/graphql/ci/group_variables_spec.rb index d78b30787c9..3b8eeefb707 100644 --- a/spec/requests/api/graphql/ci/group_variables_spec.rb +++ b/spec/requests/api/graphql/ci/group_variables_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Query.group(fullPath).ciVariables', feature_category: :pipeline_authoring do +RSpec.describe 'Query.group(fullPath).ciVariables', feature_category: :secrets_management do include GraphqlHelpers let_it_be(:group) { create(:group) } diff --git a/spec/requests/api/graphql/ci/inherited_ci_variables_spec.rb b/spec/requests/api/graphql/ci/inherited_ci_variables_spec.rb new file mode 100644 index 00000000000..3b4014c178c --- /dev/null +++ b/spec/requests/api/graphql/ci/inherited_ci_variables_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Query.project(fullPath).inheritedCiVariables', feature_category: :secrets_management do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:subgroup) { create(:group, parent: group) } + let_it_be(:project) { create(:project, group: subgroup) } + + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + inheritedCiVariables { + nodes { + id + key + environmentScope + groupName + groupCiCdSettingsPath + masked + protected + raw + variableType + } + } + } + } + ) + end + + def create_variables + create(:ci_group_variable, group: group) + create(:ci_group_variable, group: subgroup) + end + + context 'when user is not a project maintainer' do + before do + project.add_developer(user) + end + + it 'returns nothing' do + post_graphql(query, current_user: user) + + expect(graphql_data.dig('project', 'inheritedCiVariables')).to be_nil + end + end + + context 'when user is a project maintainer' do + before do + project.add_maintainer(user) + end + + it "returns the project's CI variables inherited from its parent group and ancestors" do + group_var = create(:ci_group_variable, group: group, key: 'GROUP_VAR_A', + environment_scope: 'production', masked: false, protected: true, raw: true) + + subgroup_var = create(:ci_group_variable, group: subgroup, key: 'SUBGROUP_VAR_B', + masked: true, protected: false, raw: false, variable_type: 'file') + + post_graphql(query, current_user: user) + + expect(graphql_data.dig('project', 'inheritedCiVariables', 'nodes')).to eq([ + { + 'id' => group_var.to_global_id.to_s, + 'key' => 'GROUP_VAR_A', + 'environmentScope' => 'production', + 'groupName' => group.name, + 'groupCiCdSettingsPath' => group_var.group_ci_cd_settings_path, + 'masked' => false, + 'protected' => true, + 'raw' => true, + 'variableType' => 'ENV_VAR' + }, + { + 'id' => subgroup_var.to_global_id.to_s, + 'key' => 'SUBGROUP_VAR_B', + 'environmentScope' => '*', + 'groupName' => subgroup.name, + 'groupCiCdSettingsPath' => subgroup_var.group_ci_cd_settings_path, + 'masked' => true, + 'protected' => false, + 'raw' => false, + 'variableType' => 'FILE' + } + ]) + end + + it 'avoids N+1 database queries' do + create_variables + + baseline = ActiveRecord::QueryRecorder.new do + run_with_clean_state(query, context: { current_user: user }) + end + + create_variables + + multi = ActiveRecord::QueryRecorder.new do + run_with_clean_state(query, context: { current_user: user }) + end + + expect(multi).not_to exceed_query_limit(baseline) + end + end +end diff --git a/spec/requests/api/graphql/ci/instance_variables_spec.rb b/spec/requests/api/graphql/ci/instance_variables_spec.rb index 5b65ae88426..a612b4c91b6 100644 --- a/spec/requests/api/graphql/ci/instance_variables_spec.rb +++ b/spec/requests/api/graphql/ci/instance_variables_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Query.ciVariables', feature_category: :pipeline_authoring do +RSpec.describe 'Query.ciVariables', feature_category: :secrets_management do include GraphqlHelpers let(:query) do diff --git a/spec/requests/api/graphql/ci/job_spec.rb b/spec/requests/api/graphql/ci/job_spec.rb index 8121c5e5c85..960697db239 100644 --- a/spec/requests/api/graphql/ci/job_spec.rb +++ b/spec/requests/api/graphql/ci/job_spec.rb @@ -52,7 +52,8 @@ RSpec.describe 'Query.project(fullPath).pipelines.job(id)', feature_category: :c 'duration' => 25, 'kind' => 'BUILD', 'queuedDuration' => 2.0, - 'status' => job_2.status.upcase + 'status' => job_2.status.upcase, + 'failureMessage' => job_2.present.failure_message ) end diff --git a/spec/requests/api/graphql/ci/jobs_spec.rb b/spec/requests/api/graphql/ci/jobs_spec.rb index 674407c0a0e..0d5ac725edd 100644 --- a/spec/requests/api/graphql/ci/jobs_spec.rb +++ b/spec/requests/api/graphql/ci/jobs_spec.rb @@ -1,6 +1,130 @@ # frozen_string_literal: true require 'spec_helper' +RSpec.describe 'Query.jobs', feature_category: :continuous_integration do + include GraphqlHelpers + + let_it_be(:admin) { create(:admin) } + let_it_be(:project) { create(:project, :repository, :public) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + let_it_be(:runner) { create(:ci_runner) } + let_it_be(:build) do + create(:ci_build, pipeline: pipeline, name: 'my test job', ref: 'HEAD', tag_list: %w[tag1 tag2], runner: runner) + end + + let(:query) do + %( + query { + jobs { + nodes { + id + #{fields.join(' ')} + } + } + } + ) + end + + let(:jobs_graphql_data) { graphql_data_at(:jobs, :nodes) } + + let(:fields) do + %w[commitPath refPath webPath browseArtifactsPath playPath tags runner{id}] + end + + it 'returns the paths in each job of a pipeline' do + post_graphql(query, current_user: admin) + + expect(jobs_graphql_data).to contain_exactly( + a_graphql_entity_for( + build, + commit_path: "/#{project.full_path}/-/commit/#{build.sha}", + ref_path: "/#{project.full_path}/-/commits/HEAD", + web_path: "/#{project.full_path}/-/jobs/#{build.id}", + browse_artifacts_path: "/#{project.full_path}/-/jobs/#{build.id}/artifacts/browse", + play_path: "/#{project.full_path}/-/jobs/#{build.id}/play", + tags: build.tag_list, + runner: a_graphql_entity_for(runner) + ) + ) + end + + context 'when requesting individual fields' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:admin2) { create(:admin) } + let_it_be(:project2) { create(:project) } + let_it_be(:pipeline2) { create(:ci_pipeline, project: project2) } + + where(:field) { fields } + + with_them do + let(:fields) do + [field] + end + + it 'does not generate N+1 queries', :request_store, :use_sql_query_cache do + # warm-up cache and so on: + args = { current_user: admin } + args2 = { current_user: admin2 } + post_graphql(query, **args2) + + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post_graphql(query, **args) + end + + create(:ci_build, pipeline: pipeline2, name: 'my test job2', ref: 'HEAD', tag_list: %w[tag3]) + post_graphql(query, **args) + + expect { post_graphql(query, **args) }.not_to exceed_all_query_limit(control) + end + end + end +end + +RSpec.describe 'Query.jobs.runner', feature_category: :continuous_integration do + include GraphqlHelpers + + let_it_be(:admin) { create(:admin) } + + let(:jobs_runner_graphql_data) { graphql_data_at(:jobs, :nodes, :runner) } + let(:query) do + %( + query { + jobs { + nodes { + runner{ + id + adminUrl + description + } + } + } + } + ) + end + + context 'when job has no runner' do + let_it_be(:build) { create(:ci_build) } + + it 'returns nil' do + post_graphql(query, current_user: admin) + + expect(jobs_runner_graphql_data).to eq([nil]) + end + end + + context 'when job has runner' do + let_it_be(:runner) { create(:ci_runner) } + let_it_be(:build_with_runner) { create(:ci_build, runner: runner) } + + it 'returns runner attributes' do + post_graphql(query, current_user: admin) + + expect(jobs_runner_graphql_data).to contain_exactly(a_graphql_entity_for(runner, :description, 'adminUrl' => "http://localhost/admin/runners/#{runner.id}")) + end + end +end + RSpec.describe 'Query.project.pipeline', feature_category: :continuous_integration do include GraphqlHelpers @@ -260,6 +384,68 @@ RSpec.describe 'Query.project.pipeline', feature_category: :continuous_integrati end end + describe '.jobs.runnerManager' do + let_it_be(:admin) { create(:admin) } + let_it_be(:runner_manager) { create(:ci_runner_machine, created_at: Time.current, contacted_at: Time.current) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + let_it_be(:build) do + create(:ci_build, pipeline: pipeline, name: 'my test job', runner_manager: runner_manager) + end + + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + pipeline(iid: "#{pipeline.iid}") { + jobs { + nodes { + id + name + runnerManager { + #{all_graphql_fields_for('CiRunnerManager', excluded: [:runner], max_depth: 1)} + } + } + } + } + } + } + ) + end + + let(:jobs_graphql_data) { graphql_data_at(:project, :pipeline, :jobs, :nodes) } + + it 'returns the runner manager in each job of a pipeline' do + post_graphql(query, current_user: admin) + + expect(jobs_graphql_data).to contain_exactly( + a_graphql_entity_for( + build, + name: build.name, + runner_manager: a_graphql_entity_for( + runner_manager, + system_id: runner_manager.system_xid, + created_at: runner_manager.created_at.iso8601, + contacted_at: runner_manager.contacted_at.iso8601, + status: runner_manager.status.to_s.upcase + ) + ) + ) + end + + it 'does not generate N+1 queries', :request_store, :use_sql_query_cache do + admin2 = create(:admin) + + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post_graphql(query, current_user: admin) + end + + runner_manager2 = create(:ci_runner_machine) + create(:ci_build, pipeline: pipeline, name: 'my test job2', runner_manager: runner_manager2) + + expect { post_graphql(query, current_user: admin2) }.not_to exceed_all_query_limit(control) + end + end + describe '.jobs.count' do let_it_be(:pipeline) { create(:ci_pipeline, project: project) } let_it_be(:successful_job) { create(:ci_build, :success, pipeline: pipeline) } diff --git a/spec/requests/api/graphql/ci/manual_variables_spec.rb b/spec/requests/api/graphql/ci/manual_variables_spec.rb index 921c69e535d..47dccc0deb6 100644 --- a/spec/requests/api/graphql/ci/manual_variables_spec.rb +++ b/spec/requests/api/graphql/ci/manual_variables_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Query.project(fullPath).pipelines.jobs.manualVariables', feature_category: :pipeline_authoring do +RSpec.describe 'Query.project(fullPath).pipelines.jobs.manualVariables', feature_category: :secrets_management do include GraphqlHelpers let_it_be(:project) { create(:project) } diff --git a/spec/requests/api/graphql/ci/project_variables_spec.rb b/spec/requests/api/graphql/ci/project_variables_spec.rb index 0ddcac89b34..62fc2623a0f 100644 --- a/spec/requests/api/graphql/ci/project_variables_spec.rb +++ b/spec/requests/api/graphql/ci/project_variables_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Query.project(fullPath).ciVariables', feature_category: :pipeline_authoring do +RSpec.describe 'Query.project(fullPath).ciVariables', feature_category: :secrets_management do include GraphqlHelpers let_it_be(:project) { create(:project) } diff --git a/spec/requests/api/graphql/ci/runner_spec.rb b/spec/requests/api/graphql/ci/runner_spec.rb index 986e3ce9e52..52b548ce8b9 100644 --- a/spec/requests/api/graphql/ci/runner_spec.rb +++ b/spec/requests/api/graphql/ci/runner_spec.rb @@ -6,11 +6,13 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do include GraphqlHelpers let_it_be(:user) { create(:user, :admin) } - let_it_be(:group) { create(:group) } + let_it_be(:another_admin) { create(:user, :admin) } + let_it_be_with_reload(:group) { create(:group) } let_it_be(:active_instance_runner) do - create(:ci_runner, :instance, + create(:ci_runner, :instance, :with_runner_manager, description: 'Runner 1', + creator: user, contacted_at: 2.hours.ago, active: true, version: 'adfe156', @@ -28,6 +30,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do let_it_be(:inactive_instance_runner) do create(:ci_runner, :instance, description: 'Runner 2', + creator: another_admin, contacted_at: 1.day.ago, active: false, version: 'adfe157', @@ -55,7 +58,9 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do end let_it_be(:project1) { create(:project) } - let_it_be(:active_project_runner) { create(:ci_runner, :project, projects: [project1]) } + let_it_be(:active_project_runner) do + create(:ci_runner, :project, :with_runner_manager, projects: [project1]) + end shared_examples 'runner details fetch' do let(:query) do @@ -77,6 +82,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do expect(runner_data).to match a_graphql_entity_for( runner, description: runner.description, + created_by: runner.creator ? a_graphql_entity_for(runner.creator) : nil, created_at: runner.created_at&.iso8601, contacted_at: runner.contacted_at&.iso8601, version: runner.version, @@ -85,7 +91,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do locked: false, active: runner.active, paused: !runner.active, - status: runner.status('14.5').to_s.upcase, + status: runner.status.to_s.upcase, job_execution_status: runner.builds.running.any? ? 'RUNNING' : 'IDLE', maximum_timeout: runner.maximum_timeout, access_level: runner.access_level.to_s.upcase, @@ -107,15 +113,39 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do ), project_count: nil, admin_url: "http://localhost/admin/runners/#{runner.id}", + edit_admin_url: "http://localhost/admin/runners/#{runner.id}/edit", + register_admin_url: runner.registration_available? ? "http://localhost/admin/runners/#{runner.id}/register" : nil, user_permissions: { 'readRunner' => true, 'updateRunner' => true, 'deleteRunner' => true, 'assignRunner' => true - } + }, + managers: a_hash_including( + "count" => runner.runner_managers.count, + "nodes" => an_instance_of(Array), + "pageInfo" => anything + ) ) expect(runner_data['tagList']).to match_array runner.tag_list end + + it 'does not execute more queries per runner', :use_sql_query_cache, :aggregate_failures do + # warm-up license cache and so on: + personal_access_token = create(:personal_access_token, user: user) + args = { current_user: user, token: { personal_access_token: personal_access_token } } + post_graphql(query, **args) + expect(graphql_data_at(:runner)).not_to be_nil + + personal_access_token = create(:personal_access_token, user: another_admin) + args = { current_user: another_admin, token: { personal_access_token: personal_access_token } } + control = ActiveRecord::QueryRecorder.new(skip_cached: false) { post_graphql(query, **args) } + + create(:ci_runner, :instance, version: '14.0.0', tag_list: %w[tag5 tag6], creator: another_admin) + create(:ci_runner, :project, version: '14.0.1', projects: [project1], tag_list: %w[tag3 tag8], creator: another_admin) + + expect { post_graphql(query, **args) }.not_to exceed_all_query_limit(control) + end end shared_examples 'retrieval with no admin url' do @@ -135,7 +165,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do runner_data = graphql_data_at(:runner) expect(runner_data).not_to be_nil - expect(runner_data).to match a_graphql_entity_for(runner, admin_url: nil) + expect(runner_data).to match a_graphql_entity_for(runner, admin_url: nil, edit_admin_url: nil) expect(runner_data['tagList']).to match_array runner.tag_list end end @@ -307,6 +337,24 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do it_behaves_like 'runner details fetch' end + describe 'for registration type' do + context 'when registered with registration token' do + let(:runner) do + create(:ci_runner, registration_type: :registration_token) + end + + it_behaves_like 'runner details fetch' + end + + context 'when registered with authenticated user' do + let(:runner) do + create(:ci_runner, registration_type: :authenticated_user) + end + + it_behaves_like 'runner details fetch' + end + end + describe 'for group runner request' do let(:query) do %( @@ -330,24 +378,110 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do end end - describe 'for runner with status' do - let_it_be(:stale_runner) { create(:ci_runner, description: 'Stale runner 1', created_at: 3.months.ago) } - let_it_be(:never_contacted_instance_runner) { create(:ci_runner, description: 'Missing runner 1', created_at: 1.month.ago, contacted_at: nil) } - - let(:status_fragment) do + describe 'ephemeralRegisterUrl' do + let(:runner_args) { { registration_type: :authenticated_user, creator: creator } } + let(:query) do %( - status - legacyStatusWithExplicitVersion: status(legacyMode: "14.5") - newStatus: status(legacyMode: null) + query { + runner(id: "#{runner.to_global_id}") { + ephemeralRegisterUrl + } + } ) end + shared_examples 'has register url' do + it 'retrieves register url' do + post_graphql(query, current_user: user) + expect(graphql_data_at(:runner, :ephemeral_register_url)).to eq(expected_url) + end + end + + shared_examples 'has no register url' do + it 'retrieves no register url' do + post_graphql(query, current_user: user) + expect(graphql_data_at(:runner, :ephemeral_register_url)).to eq(nil) + end + end + + context 'with an instance runner', :freeze_time do + let(:creator) { user } + let(:runner) { create(:ci_runner, **runner_args) } + + context 'with valid ephemeral registration' do + it_behaves_like 'has register url' do + let(:expected_url) { "http://localhost/admin/runners/#{runner.id}/register" } + end + end + + context 'when runner ephemeral registration has expired' do + let(:runner) do + create(:ci_runner, created_at: (Ci::Runner::REGISTRATION_AVAILABILITY_TIME + 1.second).ago, **runner_args) + end + + it_behaves_like 'has no register url' + end + + context 'when runner has already been registered' do + let(:runner) { create(:ci_runner, :with_runner_manager, **runner_args) } + + it_behaves_like 'has no register url' + end + end + + context 'with a group runner' do + let(:creator) { user } + let(:runner) { create(:ci_runner, :group, groups: [group], **runner_args) } + + context 'with valid ephemeral registration' do + it_behaves_like 'has register url' do + let(:expected_url) { "http://localhost/groups/#{group.path}/-/runners/#{runner.id}/register" } + end + end + + context 'when request not from creator' do + let(:creator) { another_admin } + + before do + group.add_owner(another_admin) + end + + it_behaves_like 'has no register url' + end + end + + context 'with a project runner' do + let(:creator) { user } + let(:runner) { create(:ci_runner, :project, projects: [project1], **runner_args) } + + context 'with valid ephemeral registration' do + it_behaves_like 'has register url' do + let(:expected_url) { "http://localhost/#{project1.full_path}/-/runners/#{runner.id}/register" } + end + end + + context 'when request not from creator' do + let(:creator) { another_admin } + + before do + project1.add_owner(another_admin) + end + + it_behaves_like 'has no register url' + end + end + end + + describe 'for runner with status' do + let_it_be(:stale_runner) { create(:ci_runner, description: 'Stale runner 1', created_at: 3.months.ago) } + let_it_be(:never_contacted_instance_runner) { create(:ci_runner, description: 'Missing runner 1', created_at: 1.month.ago, contacted_at: nil) } + let(:query) do %( query { - staleRunner: runner(id: "#{stale_runner.to_global_id}") { #{status_fragment} } - pausedRunner: runner(id: "#{inactive_instance_runner.to_global_id}") { #{status_fragment} } - neverContactedInstanceRunner: runner(id: "#{never_contacted_instance_runner.to_global_id}") { #{status_fragment} } + staleRunner: runner(id: "#{stale_runner.to_global_id}") { status } + pausedRunner: runner(id: "#{inactive_instance_runner.to_global_id}") { status } + neverContactedInstanceRunner: runner(id: "#{never_contacted_instance_runner.to_global_id}") { status } } ) end @@ -357,23 +491,17 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do stale_runner_data = graphql_data_at(:stale_runner) expect(stale_runner_data).to match a_hash_including( - 'status' => 'STALE', - 'legacyStatusWithExplicitVersion' => 'STALE', - 'newStatus' => 'STALE' + 'status' => 'STALE' ) paused_runner_data = graphql_data_at(:paused_runner) expect(paused_runner_data).to match a_hash_including( - 'status' => 'PAUSED', - 'legacyStatusWithExplicitVersion' => 'PAUSED', - 'newStatus' => 'OFFLINE' + 'status' => 'OFFLINE' ) never_contacted_instance_runner_data = graphql_data_at(:never_contacted_instance_runner) expect(never_contacted_instance_runner_data).to match a_hash_including( - 'status' => 'NEVER_CONTACTED', - 'legacyStatusWithExplicitVersion' => 'NEVER_CONTACTED', - 'newStatus' => 'NEVER_CONTACTED' + 'status' => 'NEVER_CONTACTED' ) end end @@ -568,34 +696,34 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do end end - context 'with request made by creator' do + context 'with request made by creator', :frozen_time do let(:user) { creator } context 'with runner created in UI' do let(:registration_type) { :authenticated_user } - context 'with runner created in last 3 hours' do - let(:created_at) { (3.hours - 1.second).ago } + context 'with runner created in last hour' do + let(:created_at) { (Ci::Runner::REGISTRATION_AVAILABILITY_TIME - 1.second).ago } - context 'with no runner machine registed yet' do + context 'with no runner manager registered yet' do it_behaves_like 'an ephemeral_authentication_token' end - context 'with first runner machine already registed' do - let!(:runner_machine) { create(:ci_runner_machine, runner: runner) } + context 'with first runner manager already registered' do + let!(:runner_manager) { create(:ci_runner_machine, runner: runner) } it_behaves_like 'a protected ephemeral_authentication_token' end end context 'with runner created almost too long ago' do - let(:created_at) { (3.hours - 1.second).ago } + let(:created_at) { (Ci::Runner::REGISTRATION_AVAILABILITY_TIME - 1.second).ago } it_behaves_like 'an ephemeral_authentication_token' end context 'with runner created too long ago' do - let(:created_at) { 3.hours.ago } + let(:created_at) { Ci::Runner::REGISTRATION_AVAILABILITY_TIME.ago } it_behaves_like 'a protected ephemeral_authentication_token' end @@ -604,8 +732,8 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do context 'with runner registered from command line' do let(:registration_type) { :registration_token } - context 'with runner created in last 3 hours' do - let(:created_at) { (3.hours - 1.second).ago } + context 'with runner created in last 1 hour' do + let(:created_at) { (Ci::Runner::REGISTRATION_AVAILABILITY_TIME - 1.second).ago } it_behaves_like 'a protected ephemeral_authentication_token' end @@ -628,6 +756,12 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do <<~SINGLE runner(id: "#{runner.to_global_id}") { #{all_graphql_fields_for('CiRunner', excluded: excluded_fields)} + createdBy { + id + username + webPath + webUrl + } groups { nodes { id @@ -658,7 +792,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do let(:active_group_runner2) { create(:ci_runner, :group) } # Exclude fields that are already hardcoded above - let(:excluded_fields) { %w[jobs groups projects ownerProject] } + let(:excluded_fields) { %w[createdBy jobs groups projects ownerProject] } let(:single_query) do <<~QUERY @@ -691,6 +825,8 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do control = ActiveRecord::QueryRecorder.new { post_graphql(single_query, **args) } + personal_access_token = create(:personal_access_token, user: another_admin) + args = { current_user: another_admin, token: { personal_access_token: personal_access_token } } expect { post_graphql(double_query, **args) }.not_to exceed_query_limit(control) expect(graphql_data.count).to eq 6 @@ -721,20 +857,20 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do end describe 'Query limits with jobs' do - let!(:group1) { create(:group) } - let!(:group2) { create(:group) } - let!(:project1) { create(:project, :repository, group: group1) } - let!(:project2) { create(:project, :repository, group: group1) } - let!(:project3) { create(:project, :repository, group: group2) } + let_it_be(:group1) { create(:group) } + let_it_be(:group2) { create(:group) } + let_it_be(:project1) { create(:project, :repository, group: group1) } + let_it_be(:project2) { create(:project, :repository, group: group1) } + let_it_be(:project3) { create(:project, :repository, group: group2) } - let!(:merge_request1) { create(:merge_request, source_project: project1) } - let!(:merge_request2) { create(:merge_request, source_project: project3) } + let_it_be(:merge_request1) { create(:merge_request, source_project: project1) } + let_it_be(:merge_request2) { create(:merge_request, source_project: project3) } let(:project_runner2) { create(:ci_runner, :project, projects: [project1, project2]) } let!(:build1) { create(:ci_build, :success, name: 'Build One', runner: project_runner2, pipeline: pipeline1) } - let!(:pipeline1) do + let_it_be(:pipeline1) do create(:ci_pipeline, project: project1, source: :merge_request_event, merge_request: merge_request1, ref: 'main', - target_sha: 'xxx') + target_sha: 'xxx') end let(:query) do @@ -745,24 +881,7 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do jobs { nodes { id - detailedStatus { - id - detailsPath - group - icon - text - } - project { - id - name - webUrl - } - shortSha - commitPath - finishedAt - duration - queuedDuration - tags + #{field} } } } @@ -770,42 +889,69 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do QUERY end - it 'does not execute more queries per job', :aggregate_failures do - # warm-up license cache and so on: - personal_access_token = create(:personal_access_token, user: user) - args = { current_user: user, token: { personal_access_token: personal_access_token } } - post_graphql(query, **args) - - control = ActiveRecord::QueryRecorder.new(query_recorder_debug: true) { post_graphql(query, **args) } - - # Add a new build to project_runner2 - project_runner2.runner_projects << build(:ci_runner_project, runner: project_runner2, project: project3) - pipeline2 = create(:ci_pipeline, project: project3, source: :merge_request_event, merge_request: merge_request2, - ref: 'main', target_sha: 'xxx') - build2 = create(:ci_build, :success, name: 'Build Two', runner: project_runner2, pipeline: pipeline2) + context 'when requesting individual fields' do + using RSpec::Parameterized::TableSyntax - args[:current_user] = create(:user, :admin) # do not reuse same user - expect { post_graphql(query, **args) }.not_to exceed_all_query_limit(control) + where(:field) do + [ + 'detailedStatus { id detailsPath group icon text }', + 'project { id name webUrl }' + ] + %w[ + shortSha + browseArtifactsPath + commitPath + playPath + refPath + webPath + finishedAt + duration + queuedDuration + tags + ] + end - expect(graphql_data.count).to eq 1 - expect(graphql_data).to match( - a_hash_including( - 'runner' => a_graphql_entity_for( - project_runner2, - jobs: { 'nodes' => containing_exactly(a_graphql_entity_for(build1), a_graphql_entity_for(build2)) } - ) - )) + with_them do + it 'does not execute more queries per job', :use_sql_query_cache, :aggregate_failures do + admin2 = create(:user, :admin) # do not reuse same user + + # warm-up license cache and so on: + personal_access_token = create(:personal_access_token, user: user) + personal_access_token2 = create(:personal_access_token, user: admin2) + args = { current_user: user, token: { personal_access_token: personal_access_token } } + args2 = { current_user: admin2, token: { personal_access_token: personal_access_token2 } } + post_graphql(query, **args2) + + control = ActiveRecord::QueryRecorder.new(skip_cached: false) { post_graphql(query, **args) } + + # Add a new build to project_runner2 + project_runner2.runner_projects << build(:ci_runner_project, runner: project_runner2, project: project3) + pipeline2 = create(:ci_pipeline, project: project3, source: :merge_request_event, merge_request: merge_request2, + ref: 'main', target_sha: 'xxx') + build2 = create(:ci_build, :success, name: 'Build Two', runner: project_runner2, pipeline: pipeline2) + + expect { post_graphql(query, **args2) }.not_to exceed_all_query_limit(control) + + expect(graphql_data.count).to eq 1 + expect(graphql_data).to match( + a_hash_including( + 'runner' => a_graphql_entity_for( + project_runner2, + jobs: { 'nodes' => containing_exactly(a_graphql_entity_for(build1), a_graphql_entity_for(build2)) } + ) + )) + end + end end end describe 'sorting and pagination' do let(:query) do <<~GQL - query($id: CiRunnerID!, $projectSearchTerm: String, $n: Int, $cursor: String) { - runner(id: $id) { - #{fields} + query($id: CiRunnerID!, $projectSearchTerm: String, $n: Int, $cursor: String) { + runner(id: $id) { + #{fields} + } } - } GQL end @@ -824,18 +970,18 @@ RSpec.describe 'Query.runner(id)', feature_category: :runner_fleet do let(:fields) do <<~QUERY - projects(search: $projectSearchTerm, first: $n, after: $cursor) { - count - nodes { - id - } - pageInfo { - hasPreviousPage - startCursor - endCursor - hasNextPage + projects(search: $projectSearchTerm, first: $n, after: $cursor) { + count + nodes { + id + } + pageInfo { + hasPreviousPage + startCursor + endCursor + hasNextPage + } } - } QUERY end diff --git a/spec/requests/api/graphql/ci/runners_spec.rb b/spec/requests/api/graphql/ci/runners_spec.rb index 75d8609dc38..c8706ae9698 100644 --- a/spec/requests/api/graphql/ci/runners_spec.rb +++ b/spec/requests/api/graphql/ci/runners_spec.rb @@ -11,16 +11,24 @@ RSpec.describe 'Query.runners', feature_category: :runner_fleet do let_it_be(:instance_runner) { create(:ci_runner, :instance, version: 'abc', revision: '123', description: 'Instance runner', ip_address: '127.0.0.1') } let_it_be(:project_runner) { create(:ci_runner, :project, active: false, version: 'def', revision: '456', description: 'Project runner', projects: [project], ip_address: '127.0.0.1') } - let(:runners_graphql_data) { graphql_data['runners'] } + let(:runners_graphql_data) { graphql_data_at(:runners) } let(:params) { {} } let(:fields) do <<~QUERY nodes { - #{all_graphql_fields_for('CiRunner', excluded: %w[ownerProject])} + #{all_graphql_fields_for('CiRunner', excluded: %w[createdBy ownerProject])} + createdBy { + username + webPath + webUrl + } ownerProject { id + path + fullPath + webUrl } } QUERY @@ -50,6 +58,25 @@ RSpec.describe 'Query.runners', feature_category: :runner_fleet do it 'returns expected runner' do expect(runners_graphql_data['nodes']).to contain_exactly(a_graphql_entity_for(expected_runner)) end + + it 'does not execute more queries per runner', :aggregate_failures do + # warm-up license cache and so on: + personal_access_token = create(:personal_access_token, user: current_user) + args = { current_user: current_user, token: { personal_access_token: personal_access_token } } + post_graphql(query, **args) + expect(graphql_data_at(:runners, :nodes)).not_to be_empty + + admin2 = create(:admin) + personal_access_token = create(:personal_access_token, user: admin2) + args = { current_user: admin2, token: { personal_access_token: personal_access_token } } + control = ActiveRecord::QueryRecorder.new { post_graphql(query, **args) } + + create(:ci_runner, :instance, version: '14.0.0', tag_list: %w[tag5 tag6], creator: admin2) + create(:ci_runner, :project, version: '14.0.1', projects: [project], tag_list: %w[tag3 tag8], + creator: current_user) + + expect { post_graphql(query, **args) }.not_to exceed_query_limit(control) + end end context 'runner_type is INSTANCE_TYPE and status is ACTIVE' do diff --git a/spec/requests/api/graphql/current_user/todos_query_spec.rb b/spec/requests/api/graphql/current_user/todos_query_spec.rb index f7e23aeb241..ee019a99f8d 100644 --- a/spec/requests/api/graphql/current_user/todos_query_spec.rb +++ b/spec/requests/api/graphql/current_user/todos_query_spec.rb @@ -19,7 +19,7 @@ RSpec.describe 'Query current user todos', feature_category: :source_code_manage let(:fields) do <<~QUERY nodes { - #{all_graphql_fields_for('todos'.classify, max_depth: 2)} + #{all_graphql_fields_for('todos'.classify, max_depth: 2, excluded: ['productAnalyticsState'])} } QUERY end diff --git a/spec/requests/api/graphql/current_user_query_spec.rb b/spec/requests/api/graphql/current_user_query_spec.rb index 53d2580caee..aceef77920d 100644 --- a/spec/requests/api/graphql/current_user_query_spec.rb +++ b/spec/requests/api/graphql/current_user_query_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'getting project information', feature_category: :authentication_and_authorization do +RSpec.describe 'getting project information', feature_category: :system_access do include GraphqlHelpers let(:fields) do diff --git a/spec/requests/api/graphql/custom_emoji_query_spec.rb b/spec/requests/api/graphql/custom_emoji_query_spec.rb index 7b804623e01..1858ea831dd 100644 --- a/spec/requests/api/graphql/custom_emoji_query_spec.rb +++ b/spec/requests/api/graphql/custom_emoji_query_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'getting custom emoji within namespace', feature_category: :not_owned do +RSpec.describe 'getting custom emoji within namespace', feature_category: :shared do include GraphqlHelpers let_it_be(:current_user) { create(:user) } diff --git a/spec/requests/api/graphql/group/data_transfer_spec.rb b/spec/requests/api/graphql/group/data_transfer_spec.rb new file mode 100644 index 00000000000..b7c038afa54 --- /dev/null +++ b/spec/requests/api/graphql/group/data_transfer_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'group data transfers', feature_category: :source_code_management do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project_1) { create(:project, group: group) } + let_it_be(:project_2) { create(:project, group: group) } + + let(:fields) do + <<~QUERY + #{all_graphql_fields_for('GroupDataTransfer'.classify)} + QUERY + end + + let(:query) do + graphql_query_for( + 'group', + { fullPath: group.full_path }, + query_graphql_field('DataTransfer', params, fields) + ) + end + + let(:from) { Date.new(2022, 1, 1) } + let(:to) { Date.new(2023, 1, 1) } + let(:params) { { from: from, to: to } } + let(:egress_data) do + graphql_data.dig('group', 'dataTransfer', 'egressNodes', 'nodes') + end + + before do + create(:project_data_transfer, project: project_1, date: '2022-01-01', repository_egress: 1) + create(:project_data_transfer, project: project_1, date: '2022-02-01', repository_egress: 2) + create(:project_data_transfer, project: project_2, date: '2022-02-01', repository_egress: 4) + end + + subject { post_graphql(query, current_user: current_user) } + + context 'with anonymous access' do + let_it_be(:current_user) { nil } + + before do + subject + end + + it_behaves_like 'a working graphql query' + + it 'returns no data' do + expect(graphql_data_at(:group, :data_transfer)).to be_nil + expect(graphql_errors).to be_nil + end + end + + context 'with authorized user but without enough permissions' do + before do + group.add_developer(current_user) + subject + end + + it_behaves_like 'a working graphql query' + + it 'returns empty results' do + expect(graphql_data_at(:group, :data_transfer)).to be_nil + expect(graphql_errors).to be_nil + end + end + + context 'when user has enough permissions' do + before do + group.add_owner(current_user) + end + + context 'when data_transfer_monitoring_mock_data is NOT enabled' do + before do + stub_feature_flags(data_transfer_monitoring_mock_data: false) + subject + end + + it 'returns real results' do + expect(response).to have_gitlab_http_status(:ok) + + expect(egress_data.count).to eq(2) + + expect(egress_data.first.keys).to match_array( + %w[date totalEgress repositoryEgress artifactsEgress packagesEgress registryEgress] + ) + + expect(egress_data.pluck('repositoryEgress')).to match_array(%w[1 6]) + end + + it_behaves_like 'a working graphql query' + end + + context 'when data_transfer_monitoring_mock_data is enabled' do + before do + stub_feature_flags(data_transfer_monitoring_mock_data: true) + subject + end + + it 'returns mock results' do + expect(response).to have_gitlab_http_status(:ok) + + expect(egress_data.count).to eq(12) + expect(egress_data.first.keys).to match_array( + %w[date totalEgress repositoryEgress artifactsEgress packagesEgress registryEgress] + ) + end + + it_behaves_like 'a working graphql query' + end + end +end diff --git a/spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb b/spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb index 2c4770a31a7..a6eb114a279 100644 --- a/spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb +++ b/spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb @@ -26,6 +26,7 @@ RSpec.describe 'getting dependency proxy blobs in a group', feature_category: :d #{query_graphql_field('dependency_proxy_blobs', {}, dependency_proxy_blob_fields)} dependencyProxyBlobCount dependencyProxyTotalSize + dependencyProxyTotalSizeInBytes GQL end @@ -42,6 +43,7 @@ RSpec.describe 'getting dependency proxy blobs in a group', feature_category: :d let(:dependency_proxy_blobs_response) { graphql_data.dig('group', 'dependencyProxyBlobs', 'edges') } let(:dependency_proxy_blob_count_response) { graphql_data.dig('group', 'dependencyProxyBlobCount') } let(:dependency_proxy_total_size_response) { graphql_data.dig('group', 'dependencyProxyTotalSize') } + let(:dependency_proxy_total_size_in_bytes_response) { graphql_data.dig('group', 'dependencyProxyTotalSizeInBytes') } before do stub_config(dependency_proxy: { enabled: true }) @@ -121,7 +123,13 @@ RSpec.describe 'getting dependency proxy blobs in a group', feature_category: :d it 'returns the total size' do subject + expected_size = ActiveSupport::NumberHelper.number_to_human_size(blobs.inject(0) { |sum, blob| sum + blob.size }) + expect(dependency_proxy_total_size_response).to eq(expected_size) + end + + it 'returns the total size in bytes' do + subject expected_size = blobs.inject(0) { |sum, blob| sum + blob.size } - expect(dependency_proxy_total_size_response).to eq(ActiveSupport::NumberHelper.number_to_human_size(expected_size)) + expect(dependency_proxy_total_size_in_bytes_response).to eq(expected_size) end end diff --git a/spec/requests/api/graphql/group/labels_query_spec.rb b/spec/requests/api/graphql/group/labels_query_spec.rb deleted file mode 100644 index 28886f8d80b..00000000000 --- a/spec/requests/api/graphql/group/labels_query_spec.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'getting group label information', feature_category: :team_planning do - include GraphqlHelpers - - let_it_be(:group) { create(:group, :public) } - let_it_be(:label_factory) { :group_label } - let_it_be(:label_attrs) { { group: group } } - - it_behaves_like 'querying a GraphQL type with labels' do - let(:path_prefix) { ['group'] } - - def make_query(fields) - graphql_query_for('group', { full_path: group.full_path }, fields) - end - end -end diff --git a/spec/requests/api/graphql/group/milestones_spec.rb b/spec/requests/api/graphql/group/milestones_spec.rb index 28cd68493c0..209588835f2 100644 --- a/spec/requests/api/graphql/group/milestones_spec.rb +++ b/spec/requests/api/graphql/group/milestones_spec.rb @@ -35,12 +35,6 @@ RSpec.describe 'Milestones through GroupQuery', feature_category: :team_planning end context 'when filtering by timeframe' do - it 'fetches milestones between start_date and due_date' do - fetch_milestones(user, { start_date: now.to_s, end_date: (now + 2.days).to_s }) - - expect_array_response(milestone_2.to_global_id.to_s, milestone_3.to_global_id.to_s) - end - it 'fetches milestones between timeframe start and end arguments' do today = Date.today fetch_milestones(user, { timeframe: { start: today.to_s, end: (today + 2.days).to_s } }) diff --git a/spec/requests/api/graphql/issues_spec.rb b/spec/requests/api/graphql/issues_spec.rb index e437e1bbcb0..a12049a9b2e 100644 --- a/spec/requests/api/graphql/issues_spec.rb +++ b/spec/requests/api/graphql/issues_spec.rb @@ -15,6 +15,7 @@ RSpec.describe 'getting an issue list at root level', feature_category: :team_pl let_it_be(:project_b) { create(:project, :repository, :private, group: group1) } let_it_be(:project_c) { create(:project, :repository, :public, group: group2) } let_it_be(:project_d) { create(:project, :repository, :private, group: group2) } + let_it_be(:archived_project) { create(:project, :repository, :archived, group: group2) } let_it_be(:milestone1) { create(:milestone, project: project_c, due_date: 10.days.from_now) } let_it_be(:milestone2) { create(:milestone, project: project_d, due_date: 20.days.from_now) } let_it_be(:milestone3) { create(:milestone, project: project_d, due_date: 30.days.from_now) } @@ -83,6 +84,7 @@ RSpec.describe 'getting an issue list at root level', feature_category: :team_pl ) end + let_it_be(:archived_issue) { create(:issue, project: archived_project) } let_it_be(:issues, reload: true) { [issue_a, issue_b, issue_c, issue_d, issue_e] } # we need to always provide at least one filter to the query so it doesn't fail let_it_be(:base_params) { { iids: issues.map { |issue| issue.iid.to_s } } } @@ -109,6 +111,38 @@ RSpec.describe 'getting an issue list at root level', feature_category: :team_pl end end + describe 'includeArchived filter' do + let(:base_params) { { iids: [archived_issue.iid.to_s] } } + + it 'excludes issues from archived projects' do + post_query + + issue_ids = graphql_dig_at(graphql_data_at('issues', 'nodes'), :id) + + expect(issue_ids).not_to include(archived_issue.to_gid.to_s) + end + + context 'when includeArchived is true' do + let(:issue_filter_params) { { include_archived: true } } + + it 'includes issues from archived projects' do + post_query + + issue_ids = graphql_dig_at(graphql_data_at('issues', 'nodes'), :id) + + expect(issue_ids).to include(archived_issue.to_gid.to_s) + end + end + end + + it 'excludes issues from archived projects' do + post_query + + issue_ids = graphql_dig_at(graphql_data_at('issues', 'nodes'), :id) + + expect(issue_ids).not_to include(archived_issue.to_gid.to_s) + end + context 'when no filters are provided' do let(:all_query_params) { {} } diff --git a/spec/requests/api/graphql/jobs_query_spec.rb b/spec/requests/api/graphql/jobs_query_spec.rb index 0aea8e4c253..7607aeac6e0 100644 --- a/spec/requests/api/graphql/jobs_query_spec.rb +++ b/spec/requests/api/graphql/jobs_query_spec.rb @@ -5,17 +5,26 @@ require 'spec_helper' RSpec.describe 'getting job information', feature_category: :continuous_integration do include GraphqlHelpers - let_it_be(:job) { create(:ci_build, :success, name: 'job1') } - let(:query) do - graphql_query_for(:jobs) + graphql_query_for( + :jobs, {}, %( + count + nodes { + #{all_graphql_fields_for(::Types::Ci::JobType, max_depth: 1)} + }) + ) end + let_it_be(:runner) { create(:ci_runner) } + let_it_be(:job) { create(:ci_build, :success, name: 'job1', runner: runner) } + + subject(:request) { post_graphql(query, current_user: current_user) } + context 'when user is admin' do let_it_be(:current_user) { create(:admin) } - it 'has full access to all jobs', :aggregate_failure do - post_graphql(query, current_user: current_user) + it 'has full access to all jobs', :aggregate_failures do + request expect(graphql_data_at(:jobs, :count)).to eq(1) expect(graphql_data_at(:jobs, :nodes)).to contain_exactly(a_graphql_entity_for(job)) @@ -25,14 +34,14 @@ RSpec.describe 'getting job information', feature_category: :continuous_integrat let_it_be(:pending_job) { create(:ci_build, :pending) } let_it_be(:failed_job) { create(:ci_build, :failed) } - it 'gets pending jobs', :aggregate_failure do + it 'gets pending jobs', :aggregate_failures do post_graphql(graphql_query_for(:jobs, { statuses: :PENDING }), current_user: current_user) expect(graphql_data_at(:jobs, :count)).to eq(1) expect(graphql_data_at(:jobs, :nodes)).to contain_exactly(a_graphql_entity_for(pending_job)) end - it 'gets pending and failed jobs', :aggregate_failure do + it 'gets pending and failed jobs', :aggregate_failures do post_graphql(graphql_query_for(:jobs, { statuses: [:PENDING, :FAILED] }), current_user: current_user) expect(graphql_data_at(:jobs, :count)).to eq(2) @@ -40,13 +49,27 @@ RSpec.describe 'getting job information', feature_category: :continuous_integrat a_graphql_entity_for(failed_job)]) end end + + context 'when N+1 queries' do + it 'avoids N+1 queries successfully', :use_sql_query_cache do + post_graphql(query, current_user: current_user) # warmup + + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post_graphql(query, current_user: current_user) + end + + create(:ci_build, :success, name: 'job2', runner: create(:ci_runner)) + + expect { post_graphql(query, current_user: current_user) }.not_to exceed_all_query_limit(control) + end + end end context 'if the user is not an admin' do let_it_be(:current_user) { create(:user) } - it 'has no access to the jobs', :aggregate_failure do - post_graphql(query, current_user: current_user) + it 'has no access to the jobs', :aggregate_failures do + request expect(graphql_data_at(:jobs, :count)).to eq(0) expect(graphql_data_at(:jobs, :nodes)).to match_array([]) diff --git a/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb b/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb index 4dd47142c40..143bc1672f8 100644 --- a/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb +++ b/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb @@ -22,6 +22,7 @@ RSpec.describe 'Getting Metrics Dashboard Annotations', feature_category: :metri create(:metrics_dashboard_annotation, environment: environment, starting_at: to.advance(minutes: 5), dashboard_path: path) end + let(:remove_monitor_metrics) { false } let(:args) { "from: \"#{from}\", to: \"#{to}\"" } let(:fields) do <<~QUERY @@ -50,6 +51,7 @@ RSpec.describe 'Getting Metrics Dashboard Annotations', feature_category: :metri end before do + stub_feature_flags(remove_monitor_metrics: remove_monitor_metrics) project.add_developer(current_user) post_graphql(query, current_user: current_user) end @@ -85,4 +87,18 @@ RSpec.describe 'Getting Metrics Dashboard Annotations', feature_category: :metri it_behaves_like 'a working graphql query' end end + + context 'when metrics dashboard feature is unavailable' do + let(:remove_monitor_metrics) { true } + + it_behaves_like 'a working graphql query' + + it 'returns nil' do + annotations = graphql_data.dig( + 'project', 'environments', 'nodes', 0, 'metricsDashboard', 'annotations' + ) + + expect(annotations).to be_nil + end + end end diff --git a/spec/requests/api/graphql/metrics/dashboard_query_spec.rb b/spec/requests/api/graphql/metrics/dashboard_query_spec.rb index 8db0844c6d7..b7d9b59f5fe 100644 --- a/spec/requests/api/graphql/metrics/dashboard_query_spec.rb +++ b/spec/requests/api/graphql/metrics/dashboard_query_spec.rb @@ -45,7 +45,10 @@ RSpec.describe 'Getting Metrics Dashboard', feature_category: :metrics do end context 'for user with developer access' do + let(:remove_monitor_metrics) { false } + before do + stub_feature_flags(remove_monitor_metrics: remove_monitor_metrics) project.add_developer(current_user) post_graphql(query, current_user: current_user) end @@ -82,6 +85,18 @@ RSpec.describe 'Getting Metrics Dashboard', feature_category: :metrics do expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => ["dashboard: can't be blank", "panel_groups: should be an array of panel_groups objects"]) end end + + context 'metrics dashboard feature is unavailable' do + let(:remove_monitor_metrics) { true } + + it_behaves_like 'a working graphql query' + + it 'returns nil' do + dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard') + + expect(dashboard).to be_nil + end + end end context 'requested dashboard can not be found' do diff --git a/spec/requests/api/graphql/multiplexed_queries_spec.rb b/spec/requests/api/graphql/multiplexed_queries_spec.rb index 4d615d3eaa4..0a5c87ebef8 100644 --- a/spec/requests/api/graphql/multiplexed_queries_spec.rb +++ b/spec/requests/api/graphql/multiplexed_queries_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -RSpec.describe 'Multiplexed queries', feature_category: :not_owned do +RSpec.describe 'Multiplexed queries', feature_category: :shared do include GraphqlHelpers it 'returns responses for multiple queries' do diff --git a/spec/requests/api/graphql/mutations/achievements/award_spec.rb b/spec/requests/api/graphql/mutations/achievements/award_spec.rb new file mode 100644 index 00000000000..9bc0751e924 --- /dev/null +++ b/spec/requests/api/graphql/mutations/achievements/award_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Achievements::Award, feature_category: :user_profile do + include GraphqlHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:achievement) { create(:achievement, namespace: group) } + let_it_be(:recipient) { create(:user) } + + let(:mutation) { graphql_mutation(:achievements_award, params) } + let(:achievement_id) { achievement&.to_global_id } + let(:recipient_id) { recipient&.to_global_id } + let(:params) do + { + achievement_id: achievement_id, + user_id: recipient_id + } + end + + subject { post_graphql_mutation(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:achievements_create) + end + + before_all do + group.add_developer(developer) + group.add_maintainer(maintainer) + end + + context 'when the user does not have permission' do + let(:current_user) { developer } + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not create an achievement' do + expect { subject }.not_to change { Achievements::UserAchievement.count } + end + end + + context 'when the user has permission' do + let(:current_user) { maintainer } + + context 'when the params are invalid' do + let(:achievement) { nil } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s).to include('invalid value for achievementId (Expected value to not be null)') + end + end + + context 'when the recipient_id is invalid' do + let(:recipient_id) { "gid://gitlab/User/#{non_existing_record_id}" } + + it 'returns the validation error' do + subject + + expect(graphql_data_at(:achievements_award, + :errors)).to include("Couldn't find User with 'id'=#{non_existing_record_id}") + end + end + + context 'when the achievement_id is invalid' do + let(:achievement_id) { "gid://gitlab/Achievements::Achievement/#{non_existing_record_id}" } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(achievements: false) + end + + it 'returns the relevant error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + it 'creates an achievement' do + expect { subject }.to change { Achievements::UserAchievement.count }.by(1) + end + + it 'returns the new achievement' do + subject + + expect(graphql_data_at(:achievements_award, :user_achievement, :achievement, :id)) + .to eq(achievement.to_global_id.to_s) + expect(graphql_data_at(:achievements_award, :user_achievement, :user, :id)) + .to eq(recipient.to_global_id.to_s) + end + end +end diff --git a/spec/requests/api/graphql/mutations/achievements/delete_spec.rb b/spec/requests/api/graphql/mutations/achievements/delete_spec.rb new file mode 100644 index 00000000000..276da4f46a8 --- /dev/null +++ b/spec/requests/api/graphql/mutations/achievements/delete_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Achievements::Delete, feature_category: :user_profile do + include GraphqlHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:group) { create(:group) } + + let!(:achievement) { create(:achievement, namespace: group) } + let(:mutation) { graphql_mutation(:achievements_delete, params) } + let(:achievement_id) { achievement&.to_global_id } + let(:params) { { achievement_id: achievement_id } } + + subject { post_graphql_mutation(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:achievements_delete) + end + + before_all do + group.add_developer(developer) + group.add_maintainer(maintainer) + end + + context 'when the user does not have permission' do + let(:current_user) { developer } + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not revoke any achievements' do + expect { subject }.not_to change { Achievements::Achievement.count } + end + end + + context 'when the user has permission' do + let(:current_user) { maintainer } + + context 'when the params are invalid' do + let(:achievement) { nil } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s).to include('invalid value for achievementId (Expected value to not be null)') + end + end + + context 'when the achievement_id is invalid' do + let(:achievement_id) { "gid://gitlab/Achievements::Achievement/#{non_existing_record_id}" } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(achievements: false) + end + + it 'returns the relevant error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + it 'deletes the achievement' do + expect { subject }.to change { Achievements::Achievement.count }.by(-1) + end + end +end diff --git a/spec/requests/api/graphql/mutations/achievements/revoke_spec.rb b/spec/requests/api/graphql/mutations/achievements/revoke_spec.rb new file mode 100644 index 00000000000..925a1bb9fcc --- /dev/null +++ b/spec/requests/api/graphql/mutations/achievements/revoke_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Achievements::Revoke, feature_category: :user_profile do + include GraphqlHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:achievement) { create(:achievement, namespace: group) } + let_it_be(:user_achievement) { create(:user_achievement, achievement: achievement) } + + let(:mutation) { graphql_mutation(:achievements_revoke, params) } + let(:user_achievement_id) { user_achievement&.to_global_id } + let(:params) { { user_achievement_id: user_achievement_id } } + + subject { post_graphql_mutation(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:achievements_create) + end + + before_all do + group.add_developer(developer) + group.add_maintainer(maintainer) + end + + context 'when the user does not have permission' do + let(:current_user) { developer } + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not revoke any achievements' do + expect { subject }.not_to change { Achievements::UserAchievement.where(revoked_by_user_id: nil).count } + end + end + + context 'when the user has permission' do + let(:current_user) { maintainer } + + context 'when the params are invalid' do + let(:user_achievement) { nil } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s).to include('invalid value for userAchievementId (Expected value to not be null)') + end + end + + context 'when the user_achievement_id is invalid' do + let(:user_achievement_id) { "gid://gitlab/Achievements::UserAchievement/#{non_existing_record_id}" } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(achievements: false) + end + + it 'returns the relevant error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + it 'revokes an achievement' do + expect { subject }.to change { Achievements::UserAchievement.where(revoked_by_user_id: nil).count }.by(-1) + end + + it 'returns the revoked achievement' do + subject + + expect(graphql_data_at(:achievements_revoke, :user_achievement, :achievement, :id)) + .to eq(achievement.to_global_id.to_s) + expect(graphql_data_at(:achievements_revoke, :user_achievement, :revoked_by_user, :id)) + .to eq(current_user.to_global_id.to_s) + expect(graphql_data_at(:achievements_revoke, :user_achievement, :revoked_at)) + .not_to be_nil + end + end +end diff --git a/spec/requests/api/graphql/mutations/achievements/update_spec.rb b/spec/requests/api/graphql/mutations/achievements/update_spec.rb new file mode 100644 index 00000000000..b2bb01b564c --- /dev/null +++ b/spec/requests/api/graphql/mutations/achievements/update_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Achievements::Update, feature_category: :user_profile do + include GraphqlHelpers + include WorkhorseHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:group) { create(:group) } + + let!(:achievement) { create(:achievement, namespace: group) } + let(:mutation) { graphql_mutation(:achievements_update, params) } + let(:achievement_id) { achievement&.to_global_id } + let(:params) { { achievement_id: achievement_id, name: 'GitLab', avatar: avatar } } + let(:avatar) { nil } + + subject { post_graphql_mutation_with_uploads(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:achievements_update) + end + + before_all do + group.add_developer(developer) + group.add_maintainer(maintainer) + end + + context 'when the user does not have permission' do + let(:current_user) { developer } + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not update the achievement' do + expect { subject }.not_to change { achievement.reload.name } + end + end + + context 'when the user has permission' do + let(:current_user) { maintainer } + + context 'when the params are invalid' do + let(:achievement) { nil } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s).to include('invalid value for achievementId (Expected value to not be null)') + end + end + + context 'when the achievement_id is invalid' do + let(:achievement_id) { "gid://gitlab/Achievements::Achievement/#{non_existing_record_id}" } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(achievements: false) + end + + it 'returns the relevant permission error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + context 'with a new avatar' do + let(:avatar) { fixture_file_upload("spec/fixtures/dk.png") } + + it 'updates the achievement' do + subject + + achievement.reload + + expect(achievement.name).to eq('GitLab') + expect(achievement.avatar.file).not_to be_nil + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb b/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb index 64ea6d32f5f..b3d25155a6f 100644 --- a/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb +++ b/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Deleting Sidekiq jobs', :clean_gitlab_redis_queues, feature_category: :not_owned do +RSpec.describe 'Deleting Sidekiq jobs', :clean_gitlab_redis_queues, feature_category: :shared do include GraphqlHelpers let_it_be(:admin) { create(:admin) } diff --git a/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb b/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb index fdbff0f93cd..18cc85d36e0 100644 --- a/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb +++ b/spec/requests/api/graphql/mutations/award_emojis/add_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Adding an AwardEmoji', feature_category: :not_owned do +RSpec.describe 'Adding an AwardEmoji', feature_category: :shared do include GraphqlHelpers let_it_be(:current_user) { create(:user) } diff --git a/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb b/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb index e200bfc2d18..7ec2b061a88 100644 --- a/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb +++ b/spec/requests/api/graphql/mutations/award_emojis/remove_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Removing an AwardEmoji', feature_category: :not_owned do +RSpec.describe 'Removing an AwardEmoji', feature_category: :shared do include GraphqlHelpers let(:current_user) { create(:user) } diff --git a/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb b/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb index 6dba2b58357..7c6a487cdd0 100644 --- a/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb +++ b/spec/requests/api/graphql/mutations/award_emojis/toggle_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Toggling an AwardEmoji', feature_category: :not_owned do +RSpec.describe 'Toggling an AwardEmoji', feature_category: :shared do include GraphqlHelpers let_it_be(:current_user) { create(:user) } diff --git a/spec/requests/api/graphql/mutations/ci/job_cancel_spec.rb b/spec/requests/api/graphql/mutations/ci/job/cancel_spec.rb index 468a9e57f56..abad1ae0812 100644 --- a/spec/requests/api/graphql/mutations/ci/job_cancel_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job/cancel_spec.rb @@ -15,12 +15,12 @@ RSpec.describe "JobCancel", feature_category: :continuous_integration do id: job.to_global_id.to_s } graphql_mutation(:job_cancel, variables, - <<-QL + <<-QL errors job { id } - QL + QL ) end diff --git a/spec/requests/api/graphql/mutations/ci/job_play_spec.rb b/spec/requests/api/graphql/mutations/ci/job/play_spec.rb index 9ba80e51dee..0c700248f85 100644 --- a/spec/requests/api/graphql/mutations/ci/job_play_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job/play_spec.rb @@ -18,7 +18,7 @@ RSpec.describe 'JobPlay', feature_category: :continuous_integration do let(:mutation) do graphql_mutation(:job_play, variables, - <<-QL + <<-QL errors job { id @@ -28,7 +28,7 @@ RSpec.describe 'JobPlay', feature_category: :continuous_integration do } } } - QL + QL ) end @@ -63,7 +63,7 @@ RSpec.describe 'JobPlay', feature_category: :continuous_integration do } end - it 'provides those variables to the job', :aggregated_errors do + it 'provides those variables to the job', :aggregate_failures do expect_next_instance_of(Ci::PlayBuildService) do |instance| expect(instance).to receive(:execute).with(an_instance_of(Ci::Build), variables[:variables]).and_call_original end diff --git a/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb b/spec/requests/api/graphql/mutations/ci/job/retry_spec.rb index e49ee6f3163..4114c77491b 100644 --- a/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job/retry_spec.rb @@ -16,12 +16,12 @@ RSpec.describe 'JobRetry', feature_category: :continuous_integration do id: job.to_global_id.to_s } graphql_mutation(:job_retry, variables, - <<-QL + <<-QL errors job { id } - QL + QL ) end @@ -57,12 +57,12 @@ RSpec.describe 'JobRetry', feature_category: :continuous_integration do } graphql_mutation(:job_retry, variables, - <<-QL + <<-QL errors job { id } - QL + QL ) end diff --git a/spec/requests/api/graphql/mutations/ci/job_unschedule_spec.rb b/spec/requests/api/graphql/mutations/ci/job/unschedule_spec.rb index 6868b0ea279..08e155e808b 100644 --- a/spec/requests/api/graphql/mutations/ci/job_unschedule_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job/unschedule_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'JobUnschedule', feature_category: :continuous_integration do id: job.to_global_id.to_s } graphql_mutation(:job_unschedule, variables, - <<-QL + <<-QL errors job { id diff --git a/spec/requests/api/graphql/mutations/ci/job_artifact/bulk_destroy_spec.rb b/spec/requests/api/graphql/mutations/ci/job_artifact/bulk_destroy_spec.rb new file mode 100644 index 00000000000..4e25669a0ca --- /dev/null +++ b/spec/requests/api/graphql/mutations/ci/job_artifact/bulk_destroy_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'BulkDestroy', feature_category: :build_artifacts do + include GraphqlHelpers + + let(:maintainer) { create(:user) } + let(:developer) { create(:user) } + let(:first_artifact) { create(:ci_job_artifact) } + let(:second_artifact) { create(:ci_job_artifact, project: project) } + let(:second_artifact_another_project) { create(:ci_job_artifact) } + let(:project) { first_artifact.job.project } + let(:ids) { [first_artifact.to_global_id.to_s] } + let(:not_authorized_project_error_message) do + "The resource that you are attempting to access " \ + "does not exist or you don't have permission to perform this action" + end + + let(:mutation) do + variables = { + project_id: project.to_global_id.to_s, + ids: ids + } + graphql_mutation(:bulk_destroy_job_artifacts, variables, <<~FIELDS) + destroyedCount + destroyedIds + errors + FIELDS + end + + let(:mutation_response) { graphql_mutation_response(:bulk_destroy_job_artifacts) } + + it 'fails to destroy the artifact if a user not in a project' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect(graphql_errors).to include( + a_hash_including('message' => not_authorized_project_error_message) + ) + + expect(first_artifact.reload).to be_persisted + end + + context 'when the `ci_job_artifact_bulk_destroy` feature flag is disabled' do + before do + stub_feature_flags(ci_job_artifact_bulk_destroy: false) + project.add_maintainer(maintainer) + end + + it 'returns a resource not available error' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect(graphql_errors).to contain_exactly( + hash_including( + 'message' => '`ci_job_artifact_bulk_destroy` feature flag is disabled.' + ) + ) + end + end + + context "when the user is a developer in a project" do + before do + project.add_developer(developer) + end + + it 'fails to destroy the artifact' do + post_graphql_mutation(mutation, current_user: developer) + + expect(graphql_errors).to include( + a_hash_including('message' => not_authorized_project_error_message) + ) + + expect(response).to have_gitlab_http_status(:success) + expect(first_artifact.reload).to be_persisted + end + end + + context "when the user is a maintainer in a project" do + before do + project.add_maintainer(maintainer) + end + + shared_examples 'failing mutation' do + it 'rejects the request' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect(graphql_errors(mutation_response)).to include(expected_error_message) + + expected_not_found_artifacts.each do |artifact| + expect { artifact.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + expected_found_artifacts.each do |artifact| + expect(artifact.reload).to be_persisted + end + end + end + + it 'destroys the artifact' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect(mutation_response).to include("destroyedCount" => 1, "destroyedIds" => [gid_string(first_artifact)]) + expect(response).to have_gitlab_http_status(:success) + expect { first_artifact.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context "and one artifact doesn't belong to the project" do + let(:not_owned_artifact) { create(:ci_job_artifact) } + let(:ids) { [first_artifact.to_global_id.to_s, not_owned_artifact.to_global_id.to_s] } + let(:expected_error_message) { "Not all artifacts belong to requested project" } + let(:expected_not_found_artifacts) { [] } + let(:expected_found_artifacts) { [first_artifact, not_owned_artifact] } + + it_behaves_like 'failing mutation' + end + + context "and multiple artifacts belong to the maintainer's project" do + let(:ids) { [first_artifact.to_global_id.to_s, second_artifact.to_global_id.to_s] } + + it 'destroys all artifacts' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect(mutation_response).to include( + "destroyedCount" => 2, + "destroyedIds" => [gid_string(first_artifact), gid_string(second_artifact)] + ) + + expect(response).to have_gitlab_http_status(:success) + expect { first_artifact.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { second_artifact.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context "and one artifact belongs to a different maintainer's project" do + let(:ids) { [first_artifact.to_global_id.to_s, second_artifact_another_project.to_global_id.to_s] } + let(:expected_found_artifacts) { [first_artifact, second_artifact_another_project] } + let(:expected_not_found_artifacts) { [] } + let(:expected_error_message) { "Not all artifacts belong to requested project" } + + it_behaves_like 'failing mutation' + end + + context "and not found" do + let(:ids) { [first_artifact.to_global_id.to_s, second_artifact.to_global_id.to_s] } + let(:not_found_ids) { expected_not_found_artifacts.map(&:id).join(',') } + let(:expected_error_message) { "Artifacts (#{not_found_ids}) not found" } + + before do + expected_not_found_artifacts.each(&:destroy!) + end + + context "with one artifact" do + let(:expected_not_found_artifacts) { [second_artifact] } + let(:expected_found_artifacts) { [first_artifact] } + + it_behaves_like 'failing mutation' + end + + context "with all artifact" do + let(:expected_not_found_artifacts) { [first_artifact, second_artifact] } + let(:expected_found_artifacts) { [] } + + it_behaves_like 'failing mutation' + end + end + + context 'when empty request' do + before do + project.add_maintainer(maintainer) + end + + context 'with nil value' do + let(:ids) { nil } + + it 'does nothing and returns empty answer' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect_graphql_errors_to_include(/was provided invalid value for ids \(Expected value to not be null\)/) + end + end + + context 'with empty array' do + let(:ids) { [] } + + it 'raises argument error' do + post_graphql_mutation(mutation, current_user: maintainer) + + expect_graphql_errors_to_include(/IDs array of job artifacts can not be empty/) + end + end + end + + def gid_string(object) + Gitlab::GlobalId.build(object, id: object.id).to_s + end + end +end diff --git a/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb index 55e728b2141..8791d793cb4 100644 --- a/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb @@ -53,14 +53,29 @@ RSpec.describe 'CiJobTokenScopeAddProject', feature_category: :continuous_integr before do target_project.add_developer(current_user) + stub_feature_flags(frozen_outbound_job_token_scopes_override: false) end - it 'adds the target project to the job token scope' do + it 'adds the target project to the inbound job token scope' do expect do post_graphql_mutation(mutation, current_user: current_user) expect(response).to have_gitlab_http_status(:success) expect(mutation_response.dig('ciJobTokenScope', 'projects', 'nodes')).not_to be_empty - end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1) + end.to change { Ci::JobToken::ProjectScopeLink.inbound.count }.by(1) + end + + context 'when FF frozen_outbound_job_token_scopes is disabled' do + before do + stub_feature_flags(frozen_outbound_job_token_scopes: false) + end + + it 'adds the target project to the outbound job token scope' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response.dig('ciJobTokenScope', 'projects', 'nodes')).not_to be_empty + end.to change { Ci::JobToken::ProjectScopeLink.outbound.count }.by(1) + end end context 'when invalid target project is provided' do diff --git a/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb b/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb index 99e55c44773..aa00069b241 100644 --- a/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb @@ -5,6 +5,10 @@ require 'spec_helper' RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integration do include GraphqlHelpers + before do + stub_feature_flags(frozen_outbound_job_token_scopes_override: false) + end + let_it_be(:project) do create(:project, keep_latest_artifact: true, @@ -18,12 +22,11 @@ RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integr full_path: project.full_path, keep_latest_artifact: false, job_token_scope_enabled: false, - inbound_job_token_scope_enabled: false, - opt_in_jwt: true + inbound_job_token_scope_enabled: false } end - let(:mutation) { graphql_mutation(:ci_cd_settings_update, variables) } + let(:mutation) { graphql_mutation(:project_ci_cd_settings_update, variables) } context 'when unauthorized' do let(:user) { create(:user) } @@ -61,7 +64,36 @@ RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integr expect(project.keep_latest_artifact).to eq(false) end - it 'updates job_token_scope_enabled' do + describe 'ci_cd_settings_update deprecated mutation' do + let(:mutation) { graphql_mutation(:ci_cd_settings_update, variables) } + + it 'returns error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_errors).to( + include( + hash_including('message' => '`remove_cicd_settings_update` feature flag is enabled.') + ) + ) + end + + context 'when remove_cicd_settings_update FF is disabled' do + before do + stub_feature_flags(remove_cicd_settings_update: false) + end + + it 'updates ci cd settings' do + post_graphql_mutation(mutation, current_user: user) + + project.reload + + expect(response).to have_gitlab_http_status(:success) + expect(project.keep_latest_artifact).to eq(false) + end + end + end + + it 'allows setting job_token_scope_enabled to false' do post_graphql_mutation(mutation, current_user: user) project.reload @@ -70,6 +102,50 @@ RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integr expect(project.ci_outbound_job_token_scope_enabled).to eq(false) end + context 'when job_token_scope_enabled: true' do + let(:variables) do + { + full_path: project.full_path, + keep_latest_artifact: false, + job_token_scope_enabled: true, + inbound_job_token_scope_enabled: false + } + end + + it 'prevents the update', :aggregate_failures do + project.update!(ci_outbound_job_token_scope_enabled: false) + post_graphql_mutation(mutation, current_user: user) + + project.reload + + expect(response).to have_gitlab_http_status(:success) + expect(graphql_errors).to( + include( + hash_including( + 'message' => 'job_token_scope_enabled can only be set to false' + ) + ) + ) + expect(project.ci_outbound_job_token_scope_enabled).to eq(false) + end + end + + context 'when FF frozen_outbound_job_token_scopes is disabled' do + before do + stub_feature_flags(frozen_outbound_job_token_scopes: false) + end + + it 'allows setting job_token_scope_enabled to true' do + project.update!(ci_outbound_job_token_scope_enabled: true) + post_graphql_mutation(mutation, current_user: user) + + project.reload + + expect(response).to have_gitlab_http_status(:success) + expect(project.ci_outbound_job_token_scope_enabled).to eq(false) + end + end + it 'does not update job_token_scope_enabled if not specified' do variables.except!(:job_token_scope_enabled) @@ -101,30 +177,6 @@ RSpec.describe 'ProjectCiCdSettingsUpdate', feature_category: :continuous_integr expect(response).to have_gitlab_http_status(:success) expect(project.ci_inbound_job_token_scope_enabled).to eq(true) end - - context 'when ci_inbound_job_token_scope disabled' do - before do - stub_feature_flags(ci_inbound_job_token_scope: false) - end - - it 'does not update inbound_job_token_scope_enabled' do - post_graphql_mutation(mutation, current_user: user) - - project.reload - - expect(response).to have_gitlab_http_status(:success) - expect(project.ci_inbound_job_token_scope_enabled).to eq(true) - end - end - end - - it 'updates ci_opt_in_jwt' do - post_graphql_mutation(mutation, current_user: user) - - project.reload - - expect(response).to have_gitlab_http_status(:success) - expect(project.ci_opt_in_jwt).to eq(true) end context 'when bad arguments are provided' do diff --git a/spec/requests/api/graphql/mutations/ci/runner/create_spec.rb b/spec/requests/api/graphql/mutations/ci/runner/create_spec.rb new file mode 100644 index 00000000000..1658c277ed0 --- /dev/null +++ b/spec/requests/api/graphql/mutations/ci/runner/create_spec.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'RunnerCreate', feature_category: :runner_fleet do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group_owner) { create(:user) } + let_it_be(:admin) { create(:admin) } + + let_it_be(:group) { create(:group) } + let_it_be(:other_group) { create(:group) } + + let(:mutation_params) do + { + description: 'create description', + maintenance_note: 'create maintenance note', + maximum_timeout: 900, + access_level: 'REF_PROTECTED', + paused: true, + run_untagged: false, + tag_list: %w[tag1 tag2] + }.deep_merge(mutation_scope_params) + end + + let(:mutation) do + variables = { + **mutation_params + } + + graphql_mutation( + :runner_create, + variables, + <<-QL + runner { + ephemeralAuthenticationToken + + runnerType + description + maintenanceNote + paused + tagList + accessLevel + locked + maximumTimeout + runUntagged + } + errors + QL + ) + end + + let(:mutation_response) { graphql_mutation_response(:runner_create) } + + before do + group.add_owner(group_owner) + end + + shared_context 'when model is invalid returns error' do + let(:mutation_params) do + { + description: '', + maintenanceNote: '', + paused: true, + accessLevel: 'NOT_PROTECTED', + runUntagged: false, + tagList: [], + maximumTimeout: 1 + }.deep_merge(mutation_scope_params) + end + + it do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + + expect(mutation_response['errors']).to contain_exactly( + 'Tags list can not be empty when runner is not allowed to pick untagged jobs', + 'Maximum timeout needs to be at least 10 minutes' + ) + end + end + + shared_context 'when user does not have permissions' do + let(:current_user) { user } + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include( + 'The resource that you are attempting to access does not exist ' \ + "or you don't have permission to perform this action" + ) + end + end + + shared_context 'when :create_runner_workflow_for_namespace feature flag is disabled' do + before do + stub_feature_flags(create_runner_workflow_for_namespace: [other_group]) + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include('`create_runner_workflow_for_namespace` feature flag is disabled.') + end + end + + shared_examples 'when runner is created successfully' do + it do + expected_args = { user: current_user, params: anything } + expect_next_instance_of(::Ci::Runners::CreateRunnerService, expected_args) do |service| + expect(service).to receive(:execute).and_call_original + end + + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + + expect(mutation_response['errors']).to eq([]) + expect(mutation_response['runner']).not_to be_nil + mutation_params.except(:group_id, :project_id).each_key do |key| + expect(mutation_response['runner'][key.to_s.camelize(:lower)]).to eq mutation_params[key] + end + + expect(mutation_response['runner']['ephemeralAuthenticationToken']) + .to start_with Ci::Runner::CREATED_RUNNER_TOKEN_PREFIX + end + end + + context 'when runnerType is INSTANCE_TYPE' do + let(:mutation_scope_params) do + { runner_type: 'INSTANCE_TYPE' } + end + + it_behaves_like 'when user does not have permissions' + + context 'when user has permissions', :enable_admin_mode do + let(:current_user) { admin } + + context 'when :create_runner_workflow_for_admin feature flag is disabled' do + before do + stub_feature_flags(create_runner_workflow_for_admin: false) + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include('`create_runner_workflow_for_admin` feature flag is disabled.') + end + end + + it_behaves_like 'when runner is created successfully' + it_behaves_like 'when model is invalid returns error' + end + end + + context 'when runnerType is GROUP_TYPE' do + let(:mutation_scope_params) do + { + runner_type: 'GROUP_TYPE', + group_id: group.to_global_id + } + end + + before do + stub_feature_flags(create_runner_workflow_for_namespace: [group]) + end + + it_behaves_like 'when user does not have permissions' + + context 'when user has permissions' do + context 'when user is group owner' do + let(:current_user) { group_owner } + + it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled' + it_behaves_like 'when runner is created successfully' + it_behaves_like 'when model is invalid returns error' + + context 'when group_id is missing' do + let(:mutation_scope_params) do + { runner_type: 'GROUP_TYPE' } + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include('`group_id` is missing') + end + end + + context 'when group_id is malformed' do + let(:mutation_scope_params) do + { + runner_type: 'GROUP_TYPE', + group_id: '' + } + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include( + "RunnerCreateInput! was provided invalid value for groupId" + ) + end + end + + context 'when group_id does not exist' do + let(:mutation_scope_params) do + { + runner_type: 'GROUP_TYPE', + group_id: "gid://gitlab/Group/#{non_existing_record_id}" + } + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(flattened_errors).not_to be_empty + end + end + end + + context 'when user is admin in admin mode', :enable_admin_mode do + let(:current_user) { admin } + + it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled' + it_behaves_like 'when runner is created successfully' + it_behaves_like 'when model is invalid returns error' + end + end + end + + context 'when runnerType is PROJECT_TYPE' do + let_it_be(:project) { create(:project, namespace: group) } + + let(:mutation_scope_params) do + { + runner_type: 'PROJECT_TYPE', + project_id: project.to_global_id + } + end + + it_behaves_like 'when user does not have permissions' + + context 'when user has permissions' do + context 'when user is group owner' do + let(:current_user) { group_owner } + + it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled' + it_behaves_like 'when runner is created successfully' + it_behaves_like 'when model is invalid returns error' + + context 'when project_id is missing' do + let(:mutation_scope_params) do + { runner_type: 'PROJECT_TYPE' } + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include('`project_id` is missing') + end + end + + context 'when project_id is malformed' do + let(:mutation_scope_params) do + { + runner_type: 'PROJECT_TYPE', + project_id: '' + } + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include( + "RunnerCreateInput! was provided invalid value for projectId" + ) + end + end + + context 'when project_id does not exist' do + let(:mutation_scope_params) do + { + runner_type: 'PROJECT_TYPE', + project_id: "gid://gitlab/Project/#{non_existing_record_id}" + } + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect_graphql_errors_to_include( + 'The resource that you are attempting to access does not exist ' \ + "or you don't have permission to perform this action" + ) + end + end + end + + context 'when user is admin in admin mode', :enable_admin_mode do + let(:current_user) { admin } + + it_behaves_like 'when :create_runner_workflow_for_namespace feature flag is disabled' + it_behaves_like 'when runner is created successfully' + it_behaves_like 'when model is invalid returns error' + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb b/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb index f544cef8864..ef0d44395bf 100644 --- a/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb +++ b/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Create a new cluster agent token', feature_category: :kubernetes_management do +RSpec.describe 'Create a new cluster agent token', feature_category: :deployment_management do include GraphqlHelpers let_it_be(:cluster_agent) { create(:cluster_agent) } diff --git a/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb b/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb index 66e6c5cc629..1d1e72dcff9 100644 --- a/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb +++ b/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Create a new cluster agent', feature_category: :kubernetes_management do +RSpec.describe 'Create a new cluster agent', feature_category: :deployment_management do include GraphqlHelpers let(:project) { create(:project, :public, :repository) } diff --git a/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb b/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb index 27a566dfb8c..b70a6282a7a 100644 --- a/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb +++ b/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Delete a cluster agent', feature_category: :kubernetes_management do +RSpec.describe 'Delete a cluster agent', feature_category: :deployment_management do include GraphqlHelpers let(:cluster_agent) { create(:cluster_agent) } diff --git a/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb b/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb index 8b76c19cda6..ef159e41d3d 100644 --- a/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb +++ b/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb @@ -39,7 +39,7 @@ RSpec.describe 'Destroying a container repository', feature_category: :container expect(DeleteContainerRepositoryWorker) .not_to receive(:perform_async) - expect { subject }.to change { ::Packages::Event.count }.by(1) + subject expect(container_repository_mutation_response).to match_schema('graphql/container_repository') expect(container_repository_mutation_response['status']).to eq('DELETE_SCHEDULED') @@ -53,7 +53,7 @@ RSpec.describe 'Destroying a container repository', feature_category: :container expect(DeleteContainerRepositoryWorker) .not_to receive(:perform_async).with(user.id, container_repository.id) - expect { subject }.not_to change { ::Packages::Event.count } + subject expect(mutation_response).to be_nil end diff --git a/spec/requests/api/graphql/mutations/container_repository/destroy_tags_spec.rb b/spec/requests/api/graphql/mutations/container_repository/destroy_tags_spec.rb index 9e07a831076..0cb607e13ec 100644 --- a/spec/requests/api/graphql/mutations/container_repository/destroy_tags_spec.rb +++ b/spec/requests/api/graphql/mutations/container_repository/destroy_tags_spec.rb @@ -36,7 +36,7 @@ RSpec.describe 'Destroying a container repository tags', feature_category: :cont it 'destroys the container repository tags' do expect(Projects::ContainerRepository::DeleteTagsService) .to receive(:new).and_call_original - expect { subject }.to change { ::Packages::Event.count }.by(1) + subject expect(tag_names_response).to eq(tags) expect(errors_response).to eq([]) @@ -50,7 +50,7 @@ RSpec.describe 'Destroying a container repository tags', feature_category: :cont expect(Projects::ContainerRepository::DeleteTagsService) .not_to receive(:new) - expect { subject }.not_to change { ::Packages::Event.count } + subject expect(mutation_response).to be_nil end @@ -89,7 +89,7 @@ RSpec.describe 'Destroying a container repository tags', feature_category: :cont let(:tags) { Array.new(Mutations::ContainerRepositories::DestroyTags::LIMIT + 1, 'x') } it 'returns too many tags error' do - expect { subject }.not_to change { ::Packages::Event.count } + subject explanation = graphql_errors.dig(0, 'message') expect(explanation).to eq(Mutations::ContainerRepositories::DestroyTags::TOO_MANY_TAGS_ERROR_MESSAGE) @@ -113,7 +113,7 @@ RSpec.describe 'Destroying a container repository tags', feature_category: :cont it 'does not create a package event' do expect(::Packages::CreateEventService).not_to receive(:new) - expect { subject }.not_to change { ::Packages::Event.count } + subject end end end diff --git a/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb b/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb index ea2ce8a13e2..19a52086f34 100644 --- a/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb +++ b/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Creation of a new Custom Emoji', feature_category: :not_owned do +RSpec.describe 'Creation of a new Custom Emoji', feature_category: :shared do include GraphqlHelpers let_it_be(:current_user) { create(:user) } diff --git a/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb b/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb index ad7a043909a..2623d3d8410 100644 --- a/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb +++ b/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Deletion of custom emoji', feature_category: :not_owned do +RSpec.describe 'Deletion of custom emoji', feature_category: :shared do include GraphqlHelpers let_it_be(:group) { create(:group) } diff --git a/spec/requests/api/graphql/mutations/design_management/update_spec.rb b/spec/requests/api/graphql/mutations/design_management/update_spec.rb new file mode 100644 index 00000000000..9558f2538f1 --- /dev/null +++ b/spec/requests/api/graphql/mutations/design_management/update_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "updating designs", feature_category: :design_management do + include GraphqlHelpers + include DesignManagementTestHelpers + + let_it_be(:issue) { create(:issue) } + let_it_be_with_reload(:design) { create(:design, description: 'old description', issue: issue) } + let_it_be(:developer) { create(:user, developer_projects: [issue.project]) } + + let(:user) { developer } + let(:description) { 'new description' } + + let(:mutation) do + input = { + id: design.to_global_id.to_s, + description: description + }.compact + + graphql_mutation(:design_management_update, input, <<~FIELDS) + errors + design { + description + descriptionHtml + } + FIELDS + end + + let(:update_design) { post_graphql_mutation(mutation, current_user: user) } + let(:mutation_response) { graphql_mutation_response(:design_management_update) } + + before do + enable_design_management + end + + it 'updates design' do + update_design + + expect(graphql_errors).not_to be_present + expect(mutation_response).to eq( + 'errors' => [], + 'design' => { + 'description' => description, + 'descriptionHtml' => "<p data-sourcepos=\"1:1-1:15\" dir=\"auto\">#{description}</p>" + } + ) + end + + context 'when the user is not allowed to update designs' do + let(:user) { create(:user) } + + it 'returns an error' do + update_design + + expect(graphql_errors).to be_present + end + end + + context 'when update fails' do + let(:description) { 'x' * 1_000_001 } + + it 'returns an error' do + update_design + + expect(graphql_errors).not_to be_present + expect(mutation_response).to eq( + 'errors' => ["Description is too long (maximum is 1000000 characters)"], + 'design' => { + 'description' => 'old description', + 'descriptionHtml' => '<p data-sourcepos="1:1-1:15" dir="auto">old description</p>' + } + ) + end + end +end diff --git a/spec/requests/api/graphql/mutations/issues/bulk_update_spec.rb b/spec/requests/api/graphql/mutations/issues/bulk_update_spec.rb index b9c83311908..b729585a89b 100644 --- a/spec/requests/api/graphql/mutations/issues/bulk_update_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/bulk_update_spec.rb @@ -8,7 +8,9 @@ RSpec.describe 'Bulk update issues', feature_category: :team_planning do let_it_be(:developer) { create(:user) } let_it_be(:group) { create(:group).tap { |group| group.add_developer(developer) } } let_it_be(:project) { create(:project, group: group) } - let_it_be(:updatable_issues, reload: true) { create_list(:issue, 2, project: project) } + let_it_be(:label1) { create(:group_label, group: group) } + let_it_be(:label2) { create(:group_label, group: group) } + let_it_be(:updatable_issues, reload: true) { create_list(:issue, 2, project: project, label_ids: [label1.id]) } let_it_be(:milestone) { create(:milestone, group: group) } let(:parent) { project } @@ -21,10 +23,36 @@ RSpec.describe 'Bulk update issues', feature_category: :team_planning do let(:additional_arguments) do { assignee_ids: [current_user.to_gid.to_s], - milestone_id: milestone.to_gid.to_s + milestone_id: milestone.to_gid.to_s, + state_event: :CLOSE, + add_label_ids: [label2.to_gid.to_s], + remove_label_ids: [label1.to_gid.to_s], + subscription_event: :UNSUBSCRIBE } end + before_all do + updatable_issues.each { |i| i.subscribe(developer, project) } + end + + context 'when Gitlab is FOSS only' do + unless Gitlab.ee? + context 'when parent is a group' do + let(:parent) { group } + + it 'does not allow bulk updating issues at the group level' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_errors).to contain_exactly( + hash_including( + 'message' => match(/does not represent an instance of IssueParent/) + ) + ) + end + end + end + end + context 'when the `bulk_update_issues_mutation` feature flag is disabled' do before do stub_feature_flags(bulk_update_issues_mutation: false) @@ -67,6 +95,11 @@ RSpec.describe 'Bulk update issues', feature_category: :team_planning do updatable_issues.each(&:reload) end.to change { updatable_issues.flat_map(&:assignee_ids) }.from([]).to([current_user.id] * 2) .and(change { updatable_issues.map(&:milestone_id) }.from([nil] * 2).to([milestone.id] * 2)) + .and(change { updatable_issues.map(&:state) }.from(['opened'] * 2).to(['closed'] * 2)) + .and(change { updatable_issues.flat_map(&:label_ids) }.from([label1.id] * 2).to([label2.id] * 2)) + .and( + change { updatable_issues.map { |i| i.subscribed?(developer, project) } }.from([true] * 2).to([false] * 2) + ) expect(mutation_response).to include( 'updatedIssueCount' => updatable_issues.count @@ -88,37 +121,6 @@ RSpec.describe 'Bulk update issues', feature_category: :team_planning do end end - context 'when scoping to a parent group' do - let(:parent) { group } - - it 'updates all issues' do - expect do - post_graphql_mutation(mutation, current_user: current_user) - updatable_issues.each(&:reload) - end.to change { updatable_issues.flat_map(&:assignee_ids) }.from([]).to([current_user.id] * 2) - .and(change { updatable_issues.map(&:milestone_id) }.from([nil] * 2).to([milestone.id] * 2)) - - expect(mutation_response).to include( - 'updatedIssueCount' => updatable_issues.count - ) - end - - context 'when current user cannot read the specified group' do - let(:parent) { create(:group, :private) } - - it 'returns a resource not found error' do - post_graphql_mutation(mutation, current_user: current_user) - - expect(graphql_errors).to contain_exactly( - hash_including( - 'message' => "The resource that you are attempting to access does not exist or you don't have " \ - 'permission to perform this action' - ) - ) - end - end - end - context 'when setting arguments to null or none' do let(:additional_arguments) { { assignee_ids: [], milestone_id: nil } } diff --git a/spec/requests/api/graphql/mutations/issues/create_spec.rb b/spec/requests/api/graphql/mutations/issues/create_spec.rb index d2d2f0014d6..b5a9c549045 100644 --- a/spec/requests/api/graphql/mutations/issues/create_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/create_spec.rb @@ -66,7 +66,6 @@ RSpec.describe 'Create an issue', feature_category: :team_planning do created_issue = Issue.last expect(created_issue.work_item_type.base_type).to eq('task') - expect(created_issue.issue_type).to eq('task') end end diff --git a/spec/requests/api/graphql/mutations/members/groups/bulk_update_spec.rb b/spec/requests/api/graphql/mutations/members/groups/bulk_update_spec.rb index ad70129a7bc..f15b52f53a3 100644 --- a/spec/requests/api/graphql/mutations/members/groups/bulk_update_spec.rb +++ b/spec/requests/api/graphql/mutations/members/groups/bulk_update_spec.rb @@ -5,126 +5,14 @@ require 'spec_helper' RSpec.describe 'GroupMemberBulkUpdate', feature_category: :subgroups do include GraphqlHelpers - let_it_be(:current_user) { create(:user) } - let_it_be(:user1) { create(:user) } - let_it_be(:user2) { create(:user) } - let_it_be(:group) { create(:group) } - let_it_be(:group_member1) { create(:group_member, group: group, user: user1) } - let_it_be(:group_member2) { create(:group_member, group: group, user: user2) } + let_it_be(:parent_group) { create(:group) } + let_it_be(:parent_group_member) { create(:group_member, group: parent_group) } + let_it_be(:group) { create(:group, parent: parent_group) } + let_it_be(:source) { group } + let_it_be(:member_type) { :group_member } let_it_be(:mutation_name) { :group_member_bulk_update } + let_it_be(:source_id_key) { 'group_id' } + let_it_be(:response_member_field) { 'groupMembers' } - let(:input) do - { - 'group_id' => group.to_global_id.to_s, - 'user_ids' => [user1.to_global_id.to_s, user2.to_global_id.to_s], - 'access_level' => 'GUEST' - } - end - - let(:extra_params) { { expires_at: 10.days.from_now } } - let(:input_params) { input.merge(extra_params) } - let(:mutation) { graphql_mutation(mutation_name, input_params) } - let(:mutation_response) { graphql_mutation_response(mutation_name) } - - context 'when user is not logged-in' do - it_behaves_like 'a mutation that returns a top-level access error' - end - - context 'when user is not an owner' do - before do - group.add_maintainer(current_user) - end - - it_behaves_like 'a mutation that returns a top-level access error' - end - - context 'when user is an owner' do - before do - group.add_owner(current_user) - end - - shared_examples 'updates the user access role' do - specify do - post_graphql_mutation(mutation, current_user: current_user) - - new_access_levels = mutation_response['groupMembers'].map { |member| member['accessLevel']['integerValue'] } - expect(response).to have_gitlab_http_status(:success) - expect(mutation_response['errors']).to be_empty - expect(new_access_levels).to all(be Gitlab::Access::GUEST) - end - end - - it_behaves_like 'updates the user access role' - - context 'when inherited members are passed' do - let_it_be(:subgroup) { create(:group, parent: group) } - let_it_be(:subgroup_member) { create(:group_member, group: subgroup) } - - let(:input) do - { - 'group_id' => group.to_global_id.to_s, - 'user_ids' => [user1.to_global_id.to_s, user2.to_global_id.to_s, subgroup_member.user.to_global_id.to_s], - 'access_level' => 'GUEST' - } - end - - it 'does not update the members' do - post_graphql_mutation(mutation, current_user: current_user) - - error = Mutations::Members::Groups::BulkUpdate::INVALID_MEMBERS_ERROR - expect(json_response['errors'].first['message']).to include(error) - end - end - - context 'when members count is more than the allowed limit' do - let(:max_members_update_limit) { 1 } - - before do - stub_const('Mutations::Members::Groups::BulkUpdate::MAX_MEMBERS_UPDATE_LIMIT', max_members_update_limit) - end - - it 'does not update the members' do - post_graphql_mutation(mutation, current_user: current_user) - - error = Mutations::Members::Groups::BulkUpdate::MAX_MEMBERS_UPDATE_ERROR - expect(json_response['errors'].first['message']).to include(error) - end - end - - context 'when the update service raises access denied error' do - before do - allow_next_instance_of(Members::UpdateService) do |instance| - allow(instance).to receive(:execute).and_raise(Gitlab::Access::AccessDeniedError) - end - end - - it 'does not update the members' do - post_graphql_mutation(mutation, current_user: current_user) - - expect(mutation_response['groupMembers']).to be_nil - expect(mutation_response['errors']) - .to contain_exactly("Unable to update members, please check user permissions.") - end - end - - context 'when the update service returns an error message' do - before do - allow_next_instance_of(Members::UpdateService) do |instance| - error_result = { - message: 'Expires at cannot be a date in the past', - status: :error, - members: [group_member1] - } - allow(instance).to receive(:execute).and_return(error_result) - end - end - - it 'will pass through the error' do - post_graphql_mutation(mutation, current_user: current_user) - - expect(mutation_response['groupMembers'].first['id']).to eq(group_member1.to_global_id.to_s) - expect(mutation_response['errors']).to contain_exactly('Expires at cannot be a date in the past') - end - end - end + it_behaves_like 'members bulk update mutation' end diff --git a/spec/requests/api/graphql/mutations/members/projects/bulk_update_spec.rb b/spec/requests/api/graphql/mutations/members/projects/bulk_update_spec.rb new file mode 100644 index 00000000000..cbef9715cbe --- /dev/null +++ b/spec/requests/api/graphql/mutations/members/projects/bulk_update_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'ProjectMemberBulkUpdate', feature_category: :projects do + include GraphqlHelpers + + let_it_be(:parent_group) { create(:group) } + let_it_be(:parent_group_member) { create(:group_member, group: parent_group) } + let_it_be(:project) { create(:project, group: parent_group) } + let_it_be(:source) { project } + let_it_be(:member_type) { :project_member } + let_it_be(:mutation_name) { :project_member_bulk_update } + let_it_be(:source_id_key) { 'project_id' } + let_it_be(:response_member_field) { 'projectMembers' } + + it_behaves_like 'members bulk update mutation' +end diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb index b5f2042c42a..d41628704a1 100644 --- a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb +++ b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb @@ -106,7 +106,7 @@ RSpec.describe 'Setting assignees of a merge request', :assume_throttled, featur end context 'when passing an empty list of assignees' do - let(:db_query_limit) { 31 } + let(:db_query_limit) { 35 } let(:input) { { assignee_usernames: [] } } before do diff --git a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb index bce57b47aab..d81744abe1b 100644 --- a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb +++ b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb @@ -19,7 +19,11 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Create, feature_categ graphql_mutation_response(:create_annotation) end - specify { expect(described_class).to require_graphql_authorizations(:create_metrics_dashboard_annotation) } + before do + stub_feature_flags(remove_monitor_metrics: false) + end + + specify { expect(described_class).to require_graphql_authorizations(:admin_metrics_dashboard_annotation) } context 'when annotation source is environment' do let(:mutation) do @@ -103,6 +107,15 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Create, feature_categ it_behaves_like 'an invalid argument to the mutation', argument_name: :environment_id end + + context 'when metrics dashboard feature is unavailable' do + before do + stub_feature_flags(remove_monitor_metrics: true) + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] + end end end diff --git a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb index f505dc25dc0..09977cd19d7 100644 --- a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb +++ b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb @@ -17,7 +17,11 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Delete, feature_categ graphql_mutation_response(:delete_annotation) end - specify { expect(described_class).to require_graphql_authorizations(:delete_metrics_dashboard_annotation) } + before do + stub_feature_flags(remove_monitor_metrics: false) + end + + specify { expect(described_class).to require_graphql_authorizations(:admin_metrics_dashboard_annotation) } context 'when the user has permission to delete the annotation' do before do @@ -54,6 +58,15 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Delete, feature_categ expect(mutation_response['errors']).to eq([service_response[:message]]) end end + + context 'when metrics dashboard feature is unavailable' do + before do + stub_feature_flags(remove_monitor_metrics: true) + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] + end end context 'when the user does not have permission to delete the annotation' do diff --git a/spec/requests/api/graphql/mutations/notes/create/note_spec.rb b/spec/requests/api/graphql/mutations/notes/create/note_spec.rb index a6253ba424b..e6feba059c4 100644 --- a/spec/requests/api/graphql/mutations/notes/create/note_spec.rb +++ b/spec/requests/api/graphql/mutations/notes/create/note_spec.rb @@ -104,7 +104,8 @@ RSpec.describe 'Adding a Note', feature_category: :team_planning do end context 'as work item' do - let(:noteable) { create(:work_item, :issue, project: project) } + let_it_be(:project) { create(:project) } + let_it_be(:noteable) { create(:work_item, :issue, project: project) } context 'when using internal param' do let(:variables_extra) { { internal: true } } @@ -130,6 +131,20 @@ RSpec.describe 'Adding a Note', feature_category: :team_planning do it_behaves_like 'a mutation that returns top-level errors', errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] end + + context 'when body contains quick actions' do + let_it_be(:noteable) { create(:work_item, :task, project: project) } + + let(:variables_extra) { {} } + + it_behaves_like 'work item supports labels widget updates via quick actions' + it_behaves_like 'work item does not support labels widget updates via quick actions' + it_behaves_like 'work item supports assignee widget updates via quick actions' + it_behaves_like 'work item does not support assignee widget updates via quick actions' + it_behaves_like 'work item supports start and due date widget updates via quick actions' + it_behaves_like 'work item does not support start and due date widget updates via quick actions' + it_behaves_like 'work item supports type change via quick actions' + end end end diff --git a/spec/requests/api/graphql/mutations/projects/sync_fork_spec.rb b/spec/requests/api/graphql/mutations/projects/sync_fork_spec.rb new file mode 100644 index 00000000000..c5dc6f390d9 --- /dev/null +++ b/spec/requests/api/graphql/mutations/projects/sync_fork_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "Sync project fork", feature_category: :source_code_management do + include GraphqlHelpers + include ProjectForksHelper + include ExclusiveLeaseHelpers + + let_it_be(:source_project) { create(:project, :repository, :public) } + let_it_be(:current_user) { create(:user, maintainer_projects: [source_project]) } + let_it_be(:project, refind: true) { fork_project(source_project, current_user, { repository: true }) } + let_it_be(:target_branch) { project.default_branch } + + let(:mutation) do + params = { project_path: project.full_path, target_branch: target_branch } + + graphql_mutation(:project_sync_fork, params) do + <<-QL.strip_heredoc + details { + ahead + behind + isSyncing + hasConflicts + } + errors + QL + end + end + + before do + source_project.change_head('feature') + end + + context 'when synchronize_fork feature flag is disabled' do + before do + stub_feature_flags(synchronize_fork: false) + end + + it 'does not call the sync service' do + expect(::Projects::Forks::SyncWorker).not_to receive(:perform_async) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_mutation_response(:project_sync_fork)).to eq( + { + 'details' => nil, + 'errors' => ['Feature flag is disabled'] + }) + end + end + + context 'when the branch is protected', :use_clean_rails_redis_caching do + let_it_be(:protected_branch) do + create(:protected_branch, :no_one_can_push, project: project, name: target_branch) + end + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not call the sync service' do + expect(::Projects::Forks::SyncWorker).not_to receive(:perform_async) + + post_graphql_mutation(mutation, current_user: current_user) + end + end + + context 'when the user does not have permission' do + let_it_be(:current_user) { create(:user) } + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not call the sync service' do + expect(::Projects::Forks::SyncWorker).not_to receive(:perform_async) + + post_graphql_mutation(mutation, current_user: current_user) + end + end + + context 'when the user has permission' do + context 'and the sync service executes successfully', :sidekiq_inline do + it 'calls the sync service' do + expect(::Projects::Forks::SyncWorker).to receive(:perform_async).and_call_original + + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_mutation_response(:project_sync_fork)).to eq( + { + 'details' => { 'ahead' => 30, 'behind' => 0, "hasConflicts" => false, "isSyncing" => false }, + 'errors' => [] + }) + end + end + + context 'and the sync service fails to execute' do + let(:target_branch) { 'markdown' } + + def expect_error_response(message) + expect(::Projects::Forks::SyncWorker).not_to receive(:perform_async) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_mutation_response(:project_sync_fork)['errors']).to eq([message]) + end + + context 'when fork details cannot be resolved' do + let_it_be(:project) { source_project } + + it 'returns an error' do + expect_error_response('This branch of this project cannot be updated from the upstream') + end + end + + context 'when the specified branch does not exist' do + let(:target_branch) { 'non-existent-branch' } + + it 'returns an error' do + expect_error_response('Target branch does not exist') + end + end + + context 'when the previous execution resulted in a conflict' do + it 'returns an error' do + expect_next_instance_of(::Projects::Forks::Details) do |instance| + expect(instance).to receive(:has_conflicts?).twice.and_return(true) + end + + expect_error_response('The synchronization cannot happen due to the merge conflict') + expect(graphql_mutation_response(:project_sync_fork)['details']['hasConflicts']).to eq(true) + end + end + + context 'when the request is rate limited' do + it 'returns an error' do + expect(Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true) + + expect_error_response('This service has been called too many times.') + end + end + + context 'when another fork sync is in progress' do + it 'returns an error' do + expect_next_instance_of(Projects::Forks::Details) do |instance| + lease = instance_double(Gitlab::ExclusiveLease, try_obtain: false, exists?: true) + expect(instance).to receive(:exclusive_lease).twice.and_return(lease) + end + + expect_error_response('Another fork sync is already in progress') + expect(graphql_mutation_response(:project_sync_fork)['details']['isSyncing']).to eq(true) + end + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb index 418a0e47a36..311ff48a846 100644 --- a/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb +++ b/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb @@ -32,7 +32,6 @@ RSpec.describe 'Creation of a new release asset link', feature_category: :releas url linkType directAssetUrl - external } errors FIELDS @@ -49,8 +48,7 @@ RSpec.describe 'Creation of a new release asset link', feature_category: :releas name: mutation_arguments[:name], url: mutation_arguments[:url], linkType: mutation_arguments[:linkType], - directAssetUrl: end_with(mutation_arguments[:directAssetPath]), - external: true + directAssetUrl: end_with(mutation_arguments[:directAssetPath]) }.with_indifferent_access expect(mutation_response[:link]).to include(expected_response) diff --git a/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb index b6d2c3f691d..cda1030c6d6 100644 --- a/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb +++ b/spec/requests/api/graphql/mutations/release_asset_links/delete_spec.rb @@ -22,7 +22,6 @@ RSpec.describe 'Deletes a release asset link', feature_category: :release_orches url linkType directAssetUrl - external } errors FIELDS @@ -39,8 +38,7 @@ RSpec.describe 'Deletes a release asset link', feature_category: :release_orches name: release_link.name, url: release_link.url, linkType: release_link.link_type.upcase, - directAssetUrl: end_with(release_link.filepath), - external: true + directAssetUrl: end_with(release_link.filepath) }.with_indifferent_access expect(mutation_response[:link]).to match(expected_response) diff --git a/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb index 61395cc4042..45028cba3ae 100644 --- a/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb +++ b/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb @@ -40,7 +40,6 @@ RSpec.describe 'Updating an existing release asset link', feature_category: :rel url linkType directAssetUrl - external } errors FIELDS @@ -57,8 +56,7 @@ RSpec.describe 'Updating an existing release asset link', feature_category: :rel name: mutation_arguments[:name], url: mutation_arguments[:url], linkType: mutation_arguments[:linkType], - directAssetUrl: end_with(mutation_arguments[:directAssetPath]), - external: true + directAssetUrl: end_with(mutation_arguments[:directAssetPath]) }.with_indifferent_access expect(mutation_response[:link]).to include(expected_response) diff --git a/spec/requests/api/graphql/mutations/releases/create_spec.rb b/spec/requests/api/graphql/mutations/releases/create_spec.rb index 295b8c0e97e..7cb421f17a3 100644 --- a/spec/requests/api/graphql/mutations/releases/create_spec.rb +++ b/spec/requests/api/graphql/mutations/releases/create_spec.rb @@ -59,7 +59,6 @@ RSpec.describe 'Creation of a new release', feature_category: :release_orchestra name url linkType - external directAssetUrl } } @@ -135,7 +134,6 @@ RSpec.describe 'Creation of a new release', feature_category: :release_orchestra name: asset_link[:name], url: asset_link[:url], linkType: asset_link[:linkType], - external: true, directAssetUrl: expected_direct_asset_url }] } diff --git a/spec/requests/api/graphql/mutations/snippets/update_spec.rb b/spec/requests/api/graphql/mutations/snippets/update_spec.rb index fa087e6773c..3b98ee3c2e9 100644 --- a/spec/requests/api/graphql/mutations/snippets/update_spec.rb +++ b/spec/requests/api/graphql/mutations/snippets/update_spec.rb @@ -193,7 +193,6 @@ RSpec.describe 'Updating a Snippet', feature_category: :source_code_management d end it_behaves_like 'Snowplow event tracking with RedisHLL context' do - let(:feature_flag_name) { :route_hll_to_snowplow_phase2 } let(:user) { current_user } let(:property) { 'g_edit_by_snippet_ide' } let(:namespace) { project.namespace } @@ -203,8 +202,6 @@ RSpec.describe 'Updating a Snippet', feature_category: :source_code_management d let(:context) do [Gitlab::Tracking::ServicePingContext.new(data_source: :redis_hll, event: event_name).to_context] end - - let(:feature_flag_name) { :route_hll_to_snowplow_phase2 } end end end diff --git a/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb b/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb index 967ad75c906..65b8083c74f 100644 --- a/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb +++ b/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb @@ -11,7 +11,8 @@ RSpec.describe Mutations::UserPreferences::Update, feature_category: :user_profi let(:input) do { - 'issuesSort' => sort_value + 'issuesSort' => sort_value, + 'visibilityPipelineIdType' => 'IID' } end @@ -24,15 +25,20 @@ RSpec.describe Mutations::UserPreferences::Update, feature_category: :user_profi expect(response).to have_gitlab_http_status(:success) expect(mutation_response['userPreferences']['issuesSort']).to eq(sort_value) + expect(mutation_response['userPreferences']['visibilityPipelineIdType']).to eq('IID') expect(current_user.user_preference.persisted?).to eq(true) expect(current_user.user_preference.issues_sort).to eq(Types::IssueSortEnum.values[sort_value].value.to_s) + expect(current_user.user_preference.visibility_pipeline_id_type).to eq('iid') end end context 'when user has existing preference' do before do - current_user.create_user_preference!(issues_sort: Types::IssueSortEnum.values['TITLE_DESC'].value) + current_user.create_user_preference!( + issues_sort: Types::IssueSortEnum.values['TITLE_DESC'].value, + visibility_pipeline_id_type: 'id' + ) end it 'updates the existing value' do @@ -42,8 +48,10 @@ RSpec.describe Mutations::UserPreferences::Update, feature_category: :user_profi expect(response).to have_gitlab_http_status(:success) expect(mutation_response['userPreferences']['issuesSort']).to eq(sort_value) + expect(mutation_response['userPreferences']['visibilityPipelineIdType']).to eq('IID') expect(current_user.user_preference.issues_sort).to eq(Types::IssueSortEnum.values[sort_value].value.to_s) + expect(current_user.user_preference.visibility_pipeline_id_type).to eq('iid') end end end diff --git a/spec/requests/api/graphql/mutations/work_items/convert_spec.rb b/spec/requests/api/graphql/mutations/work_items/convert_spec.rb new file mode 100644 index 00000000000..97289597331 --- /dev/null +++ b/spec/requests/api/graphql/mutations/work_items/convert_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "Converts a work item to a new type", feature_category: :team_planning do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } } + let_it_be(:new_type) { create(:work_item_type, :incident, :default) } + let_it_be(:work_item, refind: true) do + create(:work_item, :task, project: project, milestone: create(:milestone, project: project)) + end + + let(:work_item_type_id) { new_type.to_global_id.to_s } + let(:mutation) { graphql_mutation(:workItemConvert, input) } + let(:mutation_response) { graphql_mutation_response(:work_item_convert) } + let(:input) do + { + 'id' => work_item.to_global_id.to_s, + 'work_item_type_id' => work_item_type_id + } + end + + context 'when user is not allowed to update a work item' do + let(:current_user) { create(:user) } + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when user has permissions to convert the work item type' do + let(:current_user) { developer } + + context 'when work item type does not exist' do + let(:work_item_type_id) { "gid://gitlab/WorkItems::Type/#{non_existing_record_id}" } + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_errors).to include( + a_hash_including('message' => "Work Item type with id #{non_existing_record_id} was not found") + ) + end + end + + it 'converts the work item', :aggregate_failures do + expect do + post_graphql_mutation(mutation, current_user: current_user) + end.to change { work_item.reload.work_item_type }.to(new_type) + + expect(response).to have_gitlab_http_status(:success) + expect(work_item.reload.work_item_type.base_type).to eq('incident') + expect(mutation_response['workItem']).to include('id' => work_item.to_global_id.to_s) + expect(work_item.reload.milestone).to be_nil + end + + it_behaves_like 'has spam protection' do + let(:mutation_class) { ::Mutations::WorkItems::Convert } + end + end +end diff --git a/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb b/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb index 97bf060356a..6a6ad1b14fd 100644 --- a/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb @@ -23,7 +23,7 @@ RSpec.describe "Create a work item from a task in a work item's description", fe } end - let(:mutation) { graphql_mutation(:workItemCreateFromTask, input) } + let(:mutation) { graphql_mutation(:workItemCreateFromTask, input, nil, ['productAnalyticsState']) } let(:mutation_response) { graphql_mutation_response(:work_item_create_from_task) } context 'the user is not allowed to update a work item' do @@ -45,7 +45,6 @@ RSpec.describe "Create a work item from a task in a work item's description", fe expect(response).to have_gitlab_http_status(:success) expect(work_item.description).to eq("- [ ] #{created_work_item.to_reference}+") - expect(created_work_item.issue_type).to eq('task') expect(created_work_item.work_item_type.base_type).to eq('task') expect(created_work_item.work_item_parent).to eq(work_item) expect(created_work_item).to be_confidential diff --git a/spec/requests/api/graphql/mutations/work_items/create_spec.rb b/spec/requests/api/graphql/mutations/work_items/create_spec.rb index 16f78b67b5c..fca3c84e534 100644 --- a/spec/requests/api/graphql/mutations/work_items/create_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/create_spec.rb @@ -5,52 +5,43 @@ require 'spec_helper' RSpec.describe 'Create a work item', feature_category: :team_planning do include GraphqlHelpers - let_it_be(:project) { create(:project) } - let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:developer) { create(:user).tap { |user| group.add_developer(user) } } let(:input) do { 'title' => 'new title', 'description' => 'new description', 'confidential' => true, - 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_global_id.to_s + 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_gid.to_s } end - let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path)) } - + let(:fields) { nil } let(:mutation_response) { graphql_mutation_response(:work_item_create) } + let(:current_user) { developer } - context 'the user is not allowed to create a work item' do - let(:current_user) { create(:user) } - - it_behaves_like 'a mutation that returns a top-level access error' - end - - context 'when user has permissions to create a work item' do - let(:current_user) { developer } - + RSpec.shared_examples 'creates work item' do it 'creates the work item' do expect do post_graphql_mutation(mutation, current_user: current_user) end.to change(WorkItem, :count).by(1) created_work_item = WorkItem.last - expect(response).to have_gitlab_http_status(:success) - expect(created_work_item.issue_type).to eq('task') expect(created_work_item).to be_confidential expect(created_work_item.work_item_type.base_type).to eq('task') expect(mutation_response['workItem']).to include( input.except('workItemTypeId').merge( - 'id' => created_work_item.to_global_id.to_s, + 'id' => created_work_item.to_gid.to_s, 'workItemType' => hash_including('name' => 'Task') ) ) end context 'when input is invalid' do - let(:input) { { 'title' => '', 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_global_id.to_s } } + let(:input) { { 'title' => '', 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_gid.to_s } } it 'does not create and returns validation errors' do expect do @@ -90,16 +81,14 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do FIELDS end - let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) } - context 'when setting parent' do - let_it_be(:parent) { create(:work_item, project: project) } + let_it_be(:parent) { create(:work_item, **container_params) } let(:input) do { title: 'item1', - workItemTypeId: WorkItems::Type.default_by_type(:task).to_global_id.to_s, - hierarchyWidget: { 'parentId' => parent.to_global_id.to_s } + workItemTypeId: WorkItems::Type.default_by_type(:task).to_gid.to_s, + hierarchyWidget: { 'parentId' => parent.to_gid.to_s } } end @@ -110,14 +99,14 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do expect(widgets_response).to include( { 'children' => { 'edges' => [] }, - 'parent' => { 'id' => parent.to_global_id.to_s }, + 'parent' => { 'id' => parent.to_gid.to_s }, 'type' => 'HIERARCHY' } ) end context 'when parent work item type is invalid' do - let_it_be(:parent) { create(:work_item, :task, project: project) } + let_it_be(:parent) { create(:work_item, :task, **container_params) } it 'returns error' do post_graphql_mutation(mutation, current_user: current_user) @@ -137,6 +126,40 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do expect(graphql_errors.first['message']).to include('No object found for `parentId') end end + + context 'when adjacent is already in place' do + let_it_be(:adjacent) { create(:work_item, :task, **container_params) } + + let(:work_item) { WorkItem.last } + + let(:input) do + { + title: 'item1', + workItemTypeId: WorkItems::Type.default_by_type(:task).to_gid.to_s, + hierarchyWidget: { 'parentId' => parent.to_gid.to_s } + } + end + + before(:all) do + create(:parent_link, work_item_parent: parent, work_item: adjacent, relative_position: 0) + end + + it 'creates work item and sets the relative position to be AFTER adjacent' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + end.to change(WorkItem, :count).by(1) + + expect(response).to have_gitlab_http_status(:success) + expect(widgets_response).to include( + { + 'children' => { 'edges' => [] }, + 'parent' => { 'id' => parent.to_gid.to_s }, + 'type' => 'HIERARCHY' + } + ) + expect(work_item.parent_link.relative_position).to be > adjacent.parent_link.relative_position + end + end end context 'when unsupported widget input is sent' do @@ -144,7 +167,7 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do { 'title' => 'new title', 'description' => 'new description', - 'workItemTypeId' => WorkItems::Type.default_by_type(:test_case).to_global_id.to_s, + 'workItemTypeId' => WorkItems::Type.default_by_type(:test_case).to_gid.to_s, 'hierarchyWidget' => {} } end @@ -172,17 +195,15 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do FIELDS end - let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) } - context 'when setting milestone on work item creation' do let_it_be(:project_milestone) { create(:milestone, project: project) } - let_it_be(:group_milestone) { create(:milestone, project: project) } + let_it_be(:group_milestone) { create(:milestone, group: group) } let(:input) do { title: 'some WI', - workItemTypeId: WorkItems::Type.default_by_type(:task).to_global_id.to_s, - milestoneWidget: { 'milestoneId' => milestone.to_global_id.to_s } + workItemTypeId: WorkItems::Type.default_by_type(:task).to_gid.to_s, + milestoneWidget: { 'milestoneId' => milestone.to_gid.to_s } } end @@ -196,13 +217,18 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do expect(widgets_response).to include( { 'type' => 'MILESTONE', - 'milestone' => { 'id' => milestone.to_global_id.to_s } + 'milestone' => { 'id' => milestone.to_gid.to_s } } ) end end context 'when assigning a project milestone' do + before do + group_work_item = container_params[:namespace].present? + skip('cannot set a project level milestone to a group level work item') if group_work_item + end + it_behaves_like "work item's milestone is set" do let(:milestone) { project_milestone } end @@ -216,4 +242,66 @@ RSpec.describe 'Create a work item', feature_category: :team_planning do end end end + + context 'the user is not allowed to create a work item' do + let(:current_user) { create(:user) } + let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) } + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when user has permissions to create a work item' do + context 'when creating work items in a project' do + context 'with projectPath' do + let_it_be(:container_params) { { project: project } } + let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path), fields) } + + it_behaves_like 'creates work item' + end + + context 'with namespacePath' do + let_it_be(:container_params) { { project: project } } + let(:mutation) { graphql_mutation(:workItemCreate, input.merge('namespacePath' => project.full_path), fields) } + + it_behaves_like 'creates work item' + end + end + + context 'when creating work items in a group' do + let_it_be(:container_params) { { namespace: group } } + let(:mutation) { graphql_mutation(:workItemCreate, input.merge(namespacePath: group.full_path), fields) } + + it_behaves_like 'creates work item' + end + + context 'when both projectPath and namespacePath are passed' do + let_it_be(:container_params) { { project: project } } + let(:mutation) do + graphql_mutation( + :workItemCreate, + input.merge('projectPath' => project.full_path, 'namespacePath' => project.full_path), + fields + ) + end + + it_behaves_like 'a mutation that returns top-level errors', errors: [ + Mutations::WorkItems::Create::MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR + ] + end + + context 'when neither of projectPath nor namespacePath are passed' do + let_it_be(:container_params) { { project: project } } + let(:mutation) do + graphql_mutation( + :workItemCreate, + input, + fields + ) + end + + it_behaves_like 'a mutation that returns top-level errors', errors: [ + Mutations::WorkItems::Create::MUTUALLY_EXCLUSIVE_ARGUMENTS_ERROR + ] + end + end end diff --git a/spec/requests/api/graphql/mutations/work_items/export_spec.rb b/spec/requests/api/graphql/mutations/work_items/export_spec.rb new file mode 100644 index 00000000000..d5d07ea65f8 --- /dev/null +++ b/spec/requests/api/graphql/mutations/work_items/export_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Export work items', feature_category: :team_planning do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:reporter) { create(:user).tap { |user| project.add_reporter(user) } } + let_it_be(:guest) { create(:user).tap { |user| project.add_guest(user) } } + let_it_be(:work_item) { create(:work_item, project: project) } + + let(:input) { { 'projectPath' => project.full_path } } + let(:mutation) { graphql_mutation(:workItemExport, input) } + let(:mutation_response) { graphql_mutation_response(:work_item_export) } + + context 'when user is not allowed to export work items' do + let(:current_user) { guest } + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when import_export_work_items_csv feature flag is disabled' do + let(:current_user) { reporter } + + before do + stub_feature_flags(import_export_work_items_csv: false) + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: ['`import_export_work_items_csv` feature flag is disabled.'] + end + + context 'when user has permissions to export work items' do + let(:current_user) { reporter } + let(:input) do + super().merge( + 'selectedFields' => %w[TITLE DESCRIPTION AUTHOR TYPE AUTHOR_USERNAME CREATED_AT], + 'authorUsername' => 'admin', + 'iids' => [work_item.iid.to_s], + 'state' => 'opened', + 'types' => 'TASK', + 'search' => 'any', + 'in' => 'TITLE' + ) + end + + it 'schedules export job with given arguments', :aggregate_failures do + expected_arguments = { + selected_fields: ['title', 'description', 'author', 'type', 'author username', 'created_at'], + author_username: 'admin', + iids: [work_item.iid.to_s], + state: 'opened', + issue_types: ['task'], + search: 'any', + in: ['title'] + } + + expect(IssuableExportCsvWorker) + .to receive(:perform_async).with(:work_item, current_user.id, project.id, expected_arguments) + + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response['message']).to eq( + 'Your CSV export request has succeeded. The result will be emailed to ' \ + "#{reporter.notification_email_or_default}." + ) + expect(mutation_response['errors']).to be_empty + end + end +end diff --git a/spec/requests/api/graphql/mutations/work_items/update_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_spec.rb index ddd294e8f82..ce1c2c01faa 100644 --- a/spec/requests/api/graphql/mutations/work_items/update_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/update_spec.rb @@ -7,20 +7,21 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } + let_it_be(:author) { create(:user).tap { |user| project.add_reporter(user) } } let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } } let_it_be(:reporter) { create(:user).tap { |user| project.add_reporter(user) } } let_it_be(:guest) { create(:user).tap { |user| project.add_guest(user) } } - let_it_be(:work_item, refind: true) { create(:work_item, project: project) } + let_it_be(:work_item, refind: true) { create(:work_item, project: project, author: author) } let(:work_item_event) { 'CLOSE' } let(:input) { { 'stateEvent' => work_item_event, 'title' => 'updated title' } } let(:fields) do <<~FIELDS - workItem { - state - title - } - errors + workItem { + state + title + } + errors FIELDS end @@ -81,10 +82,10 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do context 'when updating confidentiality' do let(:fields) do <<~FIELDS - workItem { - confidential - } - errors + workItem { + confidential + } + errors FIELDS end @@ -126,18 +127,18 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do context 'with description widget input' do let(:fields) do <<~FIELDS - workItem { - title - description - state - widgets { - type - ... on WorkItemWidgetDescription { - description + workItem { + title + description + state + widgets { + type + ... on WorkItemWidgetDescription { + description + } } } - } - errors + errors FIELDS end @@ -445,31 +446,84 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do let(:widgets_response) { mutation_response['workItem']['widgets'] } let(:fields) do <<~FIELDS - workItem { - description - widgets { - type - ... on WorkItemWidgetHierarchy { - parent { - id - } - children { - edges { - node { - id + workItem { + description + widgets { + type + ... on WorkItemWidgetHierarchy { + parent { + id + } + children { + edges { + node { + id + } } } } } } - } - errors + errors FIELDS end + let_it_be(:valid_parent) { create(:work_item, project: project) } + let_it_be(:valid_child1) { create(:work_item, :task, project: project, created_at: 5.minutes.ago) } + let_it_be(:valid_child2) { create(:work_item, :task, project: project, created_at: 5.minutes.from_now) } + let(:input_base) { { parentId: valid_parent.to_gid.to_s } } + let(:child1_ref) { { adjacentWorkItemId: valid_child1.to_global_id.to_s } } + let(:child2_ref) { { adjacentWorkItemId: valid_child2.to_global_id.to_s } } + let(:relative_range) { [valid_child1, valid_child2].map(&:parent_link).map(&:relative_position) } + + let(:invalid_relative_position_error) do + WorkItems::Widgets::HierarchyService::UpdateService::INVALID_RELATIVE_POSITION_ERROR + end + + shared_examples 'updates work item parent and sets the relative position' do + it do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change(work_item, :work_item_parent).from(nil).to(valid_parent) + + expect(response).to have_gitlab_http_status(:success) + expect(widgets_response).to include({ 'type' => 'HIERARCHY', 'children' => { 'edges' => [] }, + 'parent' => { 'id' => valid_parent.to_global_id.to_s } }) + + expect(work_item.parent_link.relative_position).to be_between(*relative_range) + end + end + + shared_examples 'sets the relative position and does not update work item parent' do + it do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to not_change(work_item, :work_item_parent) + + expect(response).to have_gitlab_http_status(:success) + expect(widgets_response).to include({ 'type' => 'HIERARCHY', 'children' => { 'edges' => [] }, + 'parent' => { 'id' => valid_parent.to_global_id.to_s } }) + + expect(work_item.parent_link.relative_position).to be_between(*relative_range) + end + end + + shared_examples 'returns "relative position is not valid" error message' do + it do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to not_change(work_item, :work_item_parent) + + expect(mutation_response['workItem']).to be_nil + expect(mutation_response['errors']).to match_array([invalid_relative_position_error]) + end + end + context 'when updating parent' do let_it_be(:work_item, reload: true) { create(:work_item, :task, project: project) } - let_it_be(:valid_parent) { create(:work_item, project: project) } let_it_be(:invalid_parent) { create(:work_item, :task, project: project) } context 'when parent work item type is invalid' do @@ -492,20 +546,15 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do context 'when parent work item has a valid type' do let(:input) { { 'hierarchyWidget' => { 'parentId' => valid_parent.to_global_id.to_s } } } - it 'sets the parent for the work item' do + it 'updates work item parent' do expect do post_graphql_mutation(mutation, current_user: current_user) work_item.reload end.to change(work_item, :work_item_parent).from(nil).to(valid_parent) expect(response).to have_gitlab_http_status(:success) - expect(widgets_response).to include( - { - 'children' => { 'edges' => [] }, - 'parent' => { 'id' => valid_parent.to_global_id.to_s }, - 'type' => 'HIERARCHY' - } - ) + expect(widgets_response).to include({ 'type' => 'HIERARCHY', 'children' => { 'edges' => [] }, + 'parent' => { 'id' => valid_parent.to_global_id.to_s } }) end context 'when a parent is already present' do @@ -522,6 +571,31 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do end.to change(work_item, :work_item_parent).from(existing_parent).to(valid_parent) end end + + context 'when updating relative position' do + before(:all) do + create(:parent_link, work_item_parent: valid_parent, work_item: valid_child1) + create(:parent_link, work_item_parent: valid_parent, work_item: valid_child2) + end + + context "when incomplete positioning arguments are given" do + let(:input) { { hierarchyWidget: input_base.merge(child1_ref) } } + + it_behaves_like 'returns "relative position is not valid" error message' + end + + context 'when moving after adjacent' do + let(:input) { { hierarchyWidget: input_base.merge(child1_ref).merge(relativePosition: 'AFTER') } } + + it_behaves_like 'updates work item parent and sets the relative position' + end + + context 'when moving before adjacent' do + let(:input) { { hierarchyWidget: input_base.merge(child2_ref).merge(relativePosition: 'BEFORE') } } + + it_behaves_like 'updates work item parent and sets the relative position' + end + end end context 'when parentId is null' do @@ -577,9 +651,37 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do end end + context 'when reordering existing child' do + let_it_be(:work_item, reload: true) { create(:work_item, :task, project: project) } + + context "when parent is already assigned" do + before(:all) do + create(:parent_link, work_item_parent: valid_parent, work_item: work_item) + create(:parent_link, work_item_parent: valid_parent, work_item: valid_child1) + create(:parent_link, work_item_parent: valid_parent, work_item: valid_child2) + end + + context "when incomplete positioning arguments are given" do + let(:input) { { hierarchyWidget: child1_ref } } + + it_behaves_like 'returns "relative position is not valid" error message' + end + + context 'when moving after adjacent' do + let(:input) { { hierarchyWidget: child1_ref.merge(relativePosition: 'AFTER') } } + + it_behaves_like 'sets the relative position and does not update work item parent' + end + + context 'when moving before adjacent' do + let(:input) { { hierarchyWidget: child2_ref.merge(relativePosition: 'BEFORE') } } + + it_behaves_like 'sets the relative position and does not update work item parent' + end + end + end + context 'when updating children' do - let_it_be(:valid_child1) { create(:work_item, :task, project: project) } - let_it_be(:valid_child2) { create(:work_item, :task, project: project) } let_it_be(:invalid_child) { create(:work_item, project: project) } let(:input) { { 'hierarchyWidget' => { 'childrenIds' => children_ids } } } @@ -639,23 +741,29 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do context 'when updating assignees' do let(:fields) do <<~FIELDS - workItem { - widgets { - type - ... on WorkItemWidgetAssignees { - assignees { - nodes { - id - username + workItem { + title + workItemType { name } + widgets { + type + ... on WorkItemWidgetAssignees { + assignees { + nodes { + id + username + } } } - } - ... on WorkItemWidgetDescription { - description + ... on WorkItemWidgetDescription { + description + } + ... on WorkItemWidgetStartAndDueDate { + startDate + dueDate + } } } - } - errors + errors FIELDS end @@ -728,6 +836,79 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do ) end end + + context 'when changing work item type' do + let_it_be(:work_item) { create(:work_item, :task, project: project) } + let(:description) { "/type Issue" } + + let(:input) { { 'descriptionWidget' => { 'description' => description } } } + + context 'with multiple commands' do + let_it_be(:work_item) { create(:work_item, :task, project: project) } + + let(:description) { "Updating work item\n/type Issue\n/due tomorrow\n/title Foo" } + + it 'updates the work item type and other attributes' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change { work_item.work_item_type.base_type }.from('task').to('issue') + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['workItemType']['name']).to eq('Issue') + expect(mutation_response['workItem']['title']).to eq('Foo') + expect(mutation_response['workItem']['widgets']).to include( + 'type' => 'START_AND_DUE_DATE', + 'dueDate' => Date.tomorrow.strftime('%Y-%m-%d'), + 'startDate' => nil + ) + end + end + + context 'when conversion is not permitted' do + let_it_be(:issue) { create(:work_item, project: project) } + let_it_be(:link) { create(:parent_link, work_item_parent: issue, work_item: work_item) } + + let(:error_msg) { 'Work item type cannot be changed to Issue with Issue as parent type.' } + + it 'does not update the work item type' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.not_to change { work_item.work_item_type.base_type } + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['errors']).to include(error_msg) + end + end + + context 'when new type does not support a widget' do + before do + work_item.update!(start_date: Date.current, due_date: Date.tomorrow) + WorkItems::Type.default_by_type(:issue).widget_definitions + .find_by_widget_type(:start_and_due_date).update!(disabled: true) + end + + it 'updates the work item type and clear widget attributes' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + work_item.reload + end.to change { work_item.work_item_type.base_type }.from('task').to('issue') + .and change { work_item.start_date }.to(nil) + .and change { work_item.start_date }.to(nil) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['workItemType']['name']).to eq('Issue') + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'START_AND_DUE_DATE', + 'startDate' => nil, + 'dueDate' => nil + } + ) + end + end + end end context 'when the work item type does not support the assignees widget' do @@ -766,17 +947,17 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do let(:fields) do <<~FIELDS - workItem { - widgets { - type - ... on WorkItemWidgetMilestone { - milestone { - id + workItem { + widgets { + type + ... on WorkItemWidgetMilestone { + milestone { + id + } } } } - } - errors + errors FIELDS end @@ -843,18 +1024,427 @@ RSpec.describe 'Update a work item', feature_category: :team_planning do end end + context 'when updating notifications subscription' do + let_it_be(:current_user) { reporter } + let(:input) { { 'notificationsWidget' => { 'subscribed' => desired_state } } } + + let(:fields) do + <<~FIELDS + workItem { + widgets { + type + ... on WorkItemWidgetNotifications { + subscribed + } + } + } + errors + FIELDS + end + + subject(:update_work_item) { post_graphql_mutation(mutation, current_user: current_user) } + + shared_examples 'subscription updated successfully' do + let_it_be(:subscription) do + create( + :subscription, project: project, + user: current_user, + subscribable: work_item, + subscribed: !desired_state + ) + end + + it "updates existing work item's subscription state" do + expect do + update_work_item + subscription.reload + end.to change(subscription, :subscribed).to(desired_state) + .and(change { work_item.reload.subscribed?(reporter, project) }.to(desired_state)) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'subscribed' => desired_state, + 'type' => 'NOTIFICATIONS' + } + ) + end + end + + shared_examples 'subscription update ignored' do + context 'when user is subscribed with a subscription record' do + let_it_be(:subscription) do + create( + :subscription, project: project, + user: current_user, + subscribable: work_item, + subscribed: !desired_state + ) + end + + it 'ignores the update request' do + expect do + update_work_item + subscription.reload + end.to not_change(subscription, :subscribed) + .and(not_change { work_item.subscribed?(current_user, project) }) + + expect(response).to have_gitlab_http_status(:success) + end + end + + context 'when user is subscribed by being a participant' do + let_it_be(:current_user) { author } + + it 'ignores the update request' do + expect do + update_work_item + end.to not_change(Subscription, :count) + .and(not_change { work_item.subscribed?(current_user, project) }) + + expect(response).to have_gitlab_http_status(:success) + end + end + end + + context 'when work item update fails' do + let_it_be(:desired_state) { false } + let(:input) { { 'title' => nil, 'notificationsWidget' => { 'subscribed' => desired_state } } } + + it_behaves_like 'subscription update ignored' + end + + context 'when user cannot update work item' do + let_it_be(:desired_state) { false } + + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?) + .with(current_user, :update_subscription, work_item).and_return(false) + end + + it_behaves_like 'subscription update ignored' + end + + context 'when user can update work item' do + context 'when subscribing to notifications' do + let_it_be(:desired_state) { true } + + it_behaves_like 'subscription updated successfully' + end + + context 'when unsubscribing from notifications' do + let_it_be(:desired_state) { false } + + it_behaves_like 'subscription updated successfully' + + context 'when user is subscribed by being a participant' do + let_it_be(:current_user) { author } + + it 'creates a subscription with desired state' do + expect { update_work_item }.to change(Subscription, :count).by(1) + .and(change { work_item.reload.subscribed?(author, project) }.to(desired_state)) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'subscribed' => desired_state, + 'type' => 'NOTIFICATIONS' + } + ) + end + end + end + end + end + + context 'when updating currentUserTodos' do + let_it_be(:current_user) { reporter } + + let(:fields) do + <<~FIELDS + workItem { + widgets { + type + ... on WorkItemWidgetCurrentUserTodos { + currentUserTodos { + nodes { + id + state + } + } + } + } + } + errors + FIELDS + end + + subject(:update_work_item) { post_graphql_mutation(mutation, current_user: current_user) } + + context 'when adding a new todo' do + let(:input) { { 'currentUserTodosWidget' => { 'action' => 'ADD' } } } + + context 'when user has access to the work item' do + it 'adds a new todo for the user on the work item' do + expect { update_work_item }.to change { current_user.todos.count }.by(1) + + created_todo = current_user.todos.last + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'CURRENT_USER_TODOS', + 'currentUserTodos' => { + 'nodes' => [ + { 'id' => created_todo.to_global_id.to_s, 'state' => 'pending' } + ] + } + } + ) + end + end + + context 'when user has no access' do + let_it_be(:current_user) { create(:user) } + + it 'does not create a new todo' do + expect { update_work_item }.to change { Todo.count }.by(0) + + expect(response).to have_gitlab_http_status(:success) + end + end + end + + context 'when marking all todos of the work item as done' do + let_it_be(:pending_todo1) do + create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending) + end + + let_it_be(:pending_todo2) do + create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending) + end + + let(:input) { { 'currentUserTodosWidget' => { 'action' => 'MARK_AS_DONE' } } } + + context 'when user has access' do + it 'marks all todos of the user on the work item as done' do + expect { update_work_item }.to change { current_user.todos.done.count }.by(2) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'CURRENT_USER_TODOS', + 'currentUserTodos' => { + 'nodes' => match_array([ + { 'id' => pending_todo1.to_global_id.to_s, 'state' => 'done' }, + { 'id' => pending_todo2.to_global_id.to_s, 'state' => 'done' } + ]) + } + } + ) + end + end + + context 'when user has no access' do + let_it_be(:current_user) { create(:user) } + + it 'does not mark todos as done' do + expect { update_work_item }.to change { Todo.done.count }.by(0) + + expect(response).to have_gitlab_http_status(:success) + end + end + end + + context 'when marking one todo of the work item as done' do + let_it_be(:pending_todo1) do + create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending) + end + + let_it_be(:pending_todo2) do + create(:todo, target: work_item, target_type: 'WorkItem', user: current_user, state: :pending) + end + + let(:input) do + { 'currentUserTodosWidget' => { 'action' => 'MARK_AS_DONE', todo_id: global_id_of(pending_todo1) } } + end + + context 'when user has access' do + it 'marks the todo of the work item as done' do + expect { update_work_item }.to change { current_user.todos.done.count }.by(1) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'type' => 'CURRENT_USER_TODOS', + 'currentUserTodos' => { + 'nodes' => match_array([ + { 'id' => pending_todo1.to_global_id.to_s, 'state' => 'done' }, + { 'id' => pending_todo2.to_global_id.to_s, 'state' => 'pending' } + ]) + } + } + ) + end + end + + context 'when user has no access' do + let_it_be(:current_user) { create(:user) } + + it 'does not mark the todo as done' do + expect { update_work_item }.to change { Todo.done.count }.by(0) + + expect(response).to have_gitlab_http_status(:success) + end + end + end + end + + context 'when updating awardEmoji' do + let_it_be(:current_user) { work_item.author } + let_it_be(:upvote) { create(:award_emoji, :upvote, awardable: work_item, user: current_user) } + let(:award_action) { 'ADD' } + let(:award_name) { 'star' } + let(:input) { { 'awardEmojiWidget' => { 'action' => award_action, 'name' => award_name } } } + + let(:fields) do + <<~FIELDS + workItem { + widgets { + type + ... on WorkItemWidgetAwardEmoji { + upvotes + downvotes + awardEmoji { + nodes { + name + user { id } + } + } + } + } + } + errors + FIELDS + end + + subject(:update_work_item) { post_graphql_mutation(mutation, current_user: current_user) } + + context 'when user cannot award work item' do + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?) + .with(current_user, :award_emoji, work_item).and_return(false) + end + + it 'ignores the update request' do + expect do + update_work_item + end.to not_change(AwardEmoji, :count) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['errors']).to be_empty + expect(graphql_errors).to be_blank + end + end + + context 'when user can award work item' do + shared_examples 'request with error' do |message| + it 'ignores update and returns an error' do + expect do + update_work_item + end.not_to change(AwardEmoji, :count) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']).to be_nil + expect(mutation_response['errors'].first).to include(message) + end + end + + shared_examples 'request that removes emoji' do + it "updates work item's award emoji" do + expect do + update_work_item + end.to change(AwardEmoji, :count).by(-1) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'upvotes' => 0, + 'downvotes' => 0, + 'awardEmoji' => { 'nodes' => [] }, + 'type' => 'AWARD_EMOJI' + } + ) + end + end + + shared_examples 'request that adds emoji' do + it "updates work item's award emoji" do + expect do + update_work_item + end.to change(AwardEmoji, :count).by(1) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['workItem']['widgets']).to include( + { + 'upvotes' => 1, + 'downvotes' => 0, + 'awardEmoji' => { 'nodes' => [ + { 'name' => 'thumbsup', 'user' => { 'id' => current_user.to_gid.to_s } }, + { 'name' => award_name, 'user' => { 'id' => current_user.to_gid.to_s } } + ] }, + 'type' => 'AWARD_EMOJI' + } + ) + end + end + + context 'when adding award emoji' do + it_behaves_like 'request that adds emoji' + + context 'when the emoji name is not valid' do + let(:award_name) { 'xxqq' } + + it_behaves_like 'request with error', 'Name is not a valid emoji name' + end + end + + context 'when removing award emoji' do + let(:award_action) { 'REMOVE' } + + context 'when emoji was awarded by current user' do + let(:award_name) { 'thumbsup' } + + it_behaves_like 'request that removes emoji' + end + + context 'when emoji was awarded by a different user' do + let(:award_name) { 'thumbsdown' } + + before do + create(:award_emoji, :downvote, awardable: work_item) + end + + it_behaves_like 'request with error', + 'User has not awarded emoji of type thumbsdown on the awardable' + end + end + end + end + context 'when unsupported widget input is sent' do - let_it_be(:test_case) { create(:work_item_type, :default, :test_case) } - let_it_be(:work_item) { create(:work_item, work_item_type: test_case, project: project) } + let_it_be(:work_item) { create(:work_item, :test_case, project: project) } let(:input) do { - 'hierarchyWidget' => {} + 'assigneesWidget' => { 'assigneeIds' => [developer.to_gid.to_s] } } end it_behaves_like 'a mutation that returns top-level errors', - errors: ["Following widget keys are not supported by Test Case type: [:hierarchy_widget]"] + errors: ["Following widget keys are not supported by Test Case type: [:assignees_widget]"] end end end diff --git a/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb index 999c685ac6a..717de983871 100644 --- a/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/update_task_spec.rb @@ -18,7 +18,7 @@ RSpec.describe 'Update a work item task', feature_category: :team_planning do let(:task_params) { { 'title' => 'UPDATED' } } let(:task_input) { { 'id' => task.to_global_id.to_s }.merge(task_params) } let(:input) { { 'id' => work_item.to_global_id.to_s, 'taskData' => task_input } } - let(:mutation) { graphql_mutation(:workItemUpdateTask, input) } + let(:mutation) { graphql_mutation(:workItemUpdateTask, input, nil, ['productAnalyticsState']) } let(:mutation_response) { graphql_mutation_response(:work_item_update_task) } context 'the user is not allowed to read a work item' do diff --git a/spec/requests/api/graphql/namespace/projects_spec.rb b/spec/requests/api/graphql/namespace/projects_spec.rb index 4e12da3e3ab..83edacaf831 100644 --- a/spec/requests/api/graphql/namespace/projects_spec.rb +++ b/spec/requests/api/graphql/namespace/projects_spec.rb @@ -23,7 +23,7 @@ RSpec.describe 'getting projects', feature_category: :projects do projects(includeSubgroups: #{include_subgroups}) { edges { node { - #{all_graphql_fields_for('Project', max_depth: 1)} + #{all_graphql_fields_for('Project', max_depth: 1, excluded: ['productAnalyticsState'])} } } } diff --git a/spec/requests/api/graphql/packages/package_spec.rb b/spec/requests/api/graphql/packages/package_spec.rb index 82fcc5254ad..7610a4aaac1 100644 --- a/spec/requests/api/graphql/packages/package_spec.rb +++ b/spec/requests/api/graphql/packages/package_spec.rb @@ -270,6 +270,31 @@ RSpec.describe 'package details', feature_category: :package_registry do it 'returns composer_config_repository_url correctly' do expect(graphql_data_at(:package, :composer_config_repository_url)).to eq("localhost/#{group.id}") end + + context 'with access to package registry for everyone' do + before do + project.project_feature.update!(package_registry_access_level: ProjectFeature::PUBLIC) + subject + end + + it 'returns pypi_url correctly' do + expect(graphql_data_at(:package, :pypi_url)).to eq("http://__token__:<your_personal_token>@localhost/api/v4/projects/#{project.id}/packages/pypi/simple") + end + end + + context 'when project is public' do + let_it_be(:public_project) { create(:project, :public, group: group) } + let_it_be(:composer_package) { create(:composer_package, project: public_project) } + let(:package_global_id) { global_id_of(composer_package) } + + before do + subject + end + + it 'returns pypi_url correctly' do + expect(graphql_data_at(:package, :pypi_url)).to eq("http://localhost/api/v4/projects/#{public_project.id}/packages/pypi/simple") + end + end end context 'web_path' do diff --git a/spec/requests/api/graphql/project/alert_management/alert/metrics_dashboard_url_spec.rb b/spec/requests/api/graphql/project/alert_management/alert/metrics_dashboard_url_spec.rb index b430fdeb18f..3417f9529bd 100644 --- a/spec/requests/api/graphql/project/alert_management/alert/metrics_dashboard_url_spec.rb +++ b/spec/requests/api/graphql/project/alert_management/alert/metrics_dashboard_url_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'getting Alert Management Alert Assignees', feature_category: :projects do +RSpec.describe 'getting Alert Management Alert Assignees', feature_category: :incident_management do include GraphqlHelpers let_it_be(:project) { create(:project) } @@ -29,6 +29,7 @@ RSpec.describe 'getting Alert Management Alert Assignees', feature_category: :pr let(:first_alert) { alerts.first } before do + stub_feature_flags(remove_monitor_metrics: false) project.add_developer(current_user) end @@ -44,6 +45,17 @@ RSpec.describe 'getting Alert Management Alert Assignees', feature_category: :pr expect(first_alert).to include('metricsDashboardUrl' => dashboard_url_for_alert) end + + context 'when metrics dashboard feature is unavailable' do + before do + stub_feature_flags(remove_monitor_metrics: true) + end + + it 'returns nil' do + post_graphql(graphql_query, current_user: current_user) + expect(first_alert['metricsDashboardUrl']).to be_nil + end + end end context 'with gitlab-managed prometheus payload' do @@ -58,5 +70,16 @@ RSpec.describe 'getting Alert Management Alert Assignees', feature_category: :pr expect(first_alert).to include('metricsDashboardUrl' => dashboard_url_for_alert) end + + context 'when metrics dashboard feature is unavailable' do + before do + stub_feature_flags(remove_monitor_metrics: true) + end + + it 'returns nil' do + post_graphql(graphql_query, current_user: current_user) + expect(first_alert['metricsDashboardUrl']).to be_nil + end + end end end diff --git a/spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb b/spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb index 16dd0dfcfcb..c1ac0367853 100644 --- a/spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb +++ b/spec/requests/api/graphql/project/alert_management/alert/notes_spec.rb @@ -51,7 +51,7 @@ RSpec.describe 'getting Alert Management Alert Notes', feature_category: :team_p expect(first_notes_result.first).to include( 'id' => first_system_note.to_global_id.to_s, - 'systemNoteIconName' => 'git-merge', + 'systemNoteIconName' => 'merge', 'body' => first_system_note.note ) end diff --git a/spec/requests/api/graphql/project/base_service_spec.rb b/spec/requests/api/graphql/project/base_service_spec.rb index 7b1b95eaf58..b27cddea07b 100644 --- a/spec/requests/api/graphql/project/base_service_spec.rb +++ b/spec/requests/api/graphql/project/base_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'query Jira service', feature_category: :authentication_and_authorization do +RSpec.describe 'query Jira service', feature_category: :system_access do include GraphqlHelpers let_it_be(:current_user) { create(:user) } diff --git a/spec/requests/api/graphql/project/ci_access_authorized_agents_spec.rb b/spec/requests/api/graphql/project/ci_access_authorized_agents_spec.rb new file mode 100644 index 00000000000..dd76f6425fe --- /dev/null +++ b/spec/requests/api/graphql/project/ci_access_authorized_agents_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Project.ci_access_authorized_agents', feature_category: :deployment_management do + include GraphqlHelpers + + let_it_be(:organization) { create(:group) } + let_it_be(:agent_management_project) { create(:project, :private, group: organization) } + let_it_be(:agent) { create(:cluster_agent, project: agent_management_project) } + + let_it_be(:deployment_project) { create(:project, :private, group: organization) } + let_it_be(:deployment_developer) { create(:user).tap { |u| deployment_project.add_developer(u) } } + let_it_be(:deployment_reporter) { create(:user).tap { |u| deployment_project.add_reporter(u) } } + + let(:user) { deployment_developer } + + let(:query) do + %( + query { + project(fullPath: "#{deployment_project.full_path}") { + ciAccessAuthorizedAgents { + nodes { + agent { + id + name + project { + name + } + } + config + } + } + } + } + ) + end + + subject { GitlabSchema.execute(query, context: { current_user: user }).as_json } + + context 'with project authorization' do + let!(:ci_access) { create(:agent_ci_access_project_authorization, agent: agent, project: deployment_project) } + + it 'returns the authorized agent' do + authorized_agents = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes') + + expect(authorized_agents.count).to eq(1) + + authorized_agent = authorized_agents.first + + expect(authorized_agent['agent']['id']).to eq(agent.to_global_id.to_s) + expect(authorized_agent['agent']['name']).to eq(agent.name) + expect(authorized_agent['config']).to eq({ "default_namespace" => "production" }) + expect(authorized_agent['agent']['project']).to be_nil # User is not authorized to read other resources. + end + + context 'when user is developer in the agent management project' do + before do + agent_management_project.add_developer(deployment_developer) + end + + it 'returns the project information as well' do + authorized_agent = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes').first + + expect(authorized_agent['agent']['project']['name']).to eq(agent_management_project.name) + end + end + + context 'when user is reporter' do + let(:user) { deployment_reporter } + + it 'returns nothing' do + expect(subject['data']['project']['ciAccessAuthorizedAgents']).to be_nil + end + end + end + + context 'with group authorization' do + let!(:ci_access) { create(:agent_ci_access_group_authorization, agent: agent, group: organization) } + + it 'returns the authorized agent' do + authorized_agents = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes') + + expect(authorized_agents.count).to eq(1) + + authorized_agent = authorized_agents.first + + expect(authorized_agent['agent']['id']).to eq(agent.to_global_id.to_s) + expect(authorized_agent['agent']['name']).to eq(agent.name) + expect(authorized_agent['config']).to eq({ "default_namespace" => "production" }) + expect(authorized_agent['agent']['project']).to be_nil # User is not authorized to read other resources. + end + + context 'when user is developer in the agent management project' do + before do + agent_management_project.add_developer(deployment_developer) + end + + it 'returns the project information as well' do + authorized_agent = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes').first + + expect(authorized_agent['agent']['project']['name']).to eq(agent_management_project.name) + end + end + + context 'when user is reporter' do + let(:user) { deployment_reporter } + + it 'returns nothing' do + expect(subject['data']['project']['ciAccessAuthorizedAgents']).to be_nil + end + end + end + + context 'when deployment project is not authorized to ci_access to the agent' do + it 'returns empty' do + authorized_agents = subject.dig('data', 'project', 'ciAccessAuthorizedAgents', 'nodes') + + expect(authorized_agents).to be_empty + end + end +end diff --git a/spec/requests/api/graphql/project/cluster_agents_spec.rb b/spec/requests/api/graphql/project/cluster_agents_spec.rb index 0881eb9cdc3..181f21001ea 100644 --- a/spec/requests/api/graphql/project/cluster_agents_spec.rb +++ b/spec/requests/api/graphql/project/cluster_agents_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Project.cluster_agents', feature_category: :kubernetes_management do +RSpec.describe 'Project.cluster_agents', feature_category: :deployment_management do include GraphqlHelpers let_it_be(:project) { create(:project, :public) } @@ -53,10 +53,11 @@ RSpec.describe 'Project.cluster_agents', feature_category: :kubernetes_managemen let_it_be(:token_1) { create(:cluster_agent_token, agent: agents.second) } let_it_be(:token_2) { create(:cluster_agent_token, agent: agents.second, last_used_at: 3.days.ago) } let_it_be(:token_3) { create(:cluster_agent_token, agent: agents.second, last_used_at: 2.days.ago) } + let_it_be(:revoked_token) { create(:cluster_agent_token, :revoked, agent: agents.second) } let(:cluster_agents_fields) { [:id, query_nodes(:tokens, of: 'ClusterAgentToken')] } - it 'can select tokens in last_used_at order' do + it 'can select active tokens in last_used_at order' do post_graphql(query, current_user: current_user) tokens = graphql_data_at(:project, :cluster_agents, :nodes, :tokens, :nodes) diff --git a/spec/requests/api/graphql/project/commit_references_spec.rb b/spec/requests/api/graphql/project/commit_references_spec.rb new file mode 100644 index 00000000000..4b545adee12 --- /dev/null +++ b/spec/requests/api/graphql/project/commit_references_spec.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Query.project(fullPath).commitReferences(commitSha)', feature_category: :source_code_management do + include GraphqlHelpers + include Presentable + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:repository) { project.repository.raw } + let_it_be(:current_user) { project.first_owner } + let_it_be(:branches_names) { %w[master not-merged-branch v1.1.0] } + let_it_be(:tag_name) { 'v1.0.0' } + let_it_be(:commit_sha) { repository.commit.id } + + let(:post_query) { post_graphql(query, current_user: current_user) } + let(:data) { graphql_data.dig(*path) } + let(:base_args) { {} } + let(:args) { base_args } + + shared_context 'with the limit argument' do + context 'with limit of 2' do + let(:args) { { limit: 2 } } + + it 'returns the right amount of refs' do + post_query + expect(data.count).to be <= 2 + end + end + + context 'with limit of -2' do + let(:args) { { limit: -2 } } + + it 'casts an argument error "limit must be greater then 0"' do + post_query + expect(graphql_errors).to include(custom_graphql_error(path - ['names'], + 'limit must be within 1..1000')) + end + end + + context 'with limit of 1001' do + let(:args) { { limit: 1001 } } + + it 'casts an argument error "limit must be greater then 0"' do + post_query + expect(graphql_errors).to include(custom_graphql_error(path - ['names'], + 'limit must be within 1..1000')) + end + end + end + + describe 'the path commitReferences should return nil' do + let(:path) { %w[project commitReferences] } + + let(:query) do + graphql_query_for(:project, { fullPath: project.full_path }, + query_graphql_field( + :commitReferences, + { commitSha: commit_sha }, + query_graphql_field(:tippingTags, :names) + ) + ) + end + + context 'when commit does not exist' do + let(:commit_sha) { '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff4' } + + it 'commitReferences returns nil' do + post_query + expect(data).to eq(nil) + end + end + + context 'when sha length is incorrect' do + let(:commit_sha) { 'foo' } + + it 'commitReferences returns nil' do + post_query + expect(data).to eq(nil) + end + end + + context 'when user is not authorized' do + let(:commit_sha) { repository.commit.id } + let(:current_user) { create(:user) } + + it 'commitReferences returns nil' do + post_query + expect(data).to eq(nil) + end + end + end + + context 'with containing refs' do + let(:base_args) { { excludeTipped: false } } + let(:excluded_tipped_args) do + hash = base_args.dup + hash[:excludeTipped] = true + hash + end + + context 'with path Query.project(fullPath).commitReferences(commitSha).containingTags' do + let_it_be(:commit_sha) { repository.find_tag(tag_name).target_commit.sha } + let_it_be(:path) { %w[project commitReferences containingTags names] } + let(:query) do + graphql_query_for( + :project, + { fullPath: project.full_path }, + query_graphql_field( + :commitReferences, + { commitSha: commit_sha }, + query_graphql_field(:containingTags, args, :names) + ) + ) + end + + context 'without excludeTipped argument' do + it 'returns tags names containing the commit' do + post_query + expect(data).to eq(%w[v1.0.0 v1.1.0 v1.1.1]) + end + end + + context 'with excludeTipped argument' do + let_it_be(:ref_prefix) { Gitlab::Git::TAG_REF_PREFIX } + + let(:args) { excluded_tipped_args } + + it 'returns tags names containing the commit without the tipped tags' do + excluded_refs = project.repository + .refs_by_oid(oid: commit_sha, ref_patterns: [ref_prefix]) + .map { |n| n.delete_prefix(ref_prefix) } + + post_query + expect(data).to eq(%w[v1.0.0 v1.1.0 v1.1.1] - excluded_refs) + end + end + + include_context 'with the limit argument' + end + + context 'with path Query.project(fullPath).commitReferences(commitSha).containingBranches' do + let_it_be(:ref_prefix) { Gitlab::Git::BRANCH_REF_PREFIX } + let_it_be(:path) { %w[project commitReferences containingBranches names] } + + let(:query) do + graphql_query_for( + :project, + { fullPath: project.full_path }, + query_graphql_field( + :commitReferences, + { commitSha: commit_sha }, + query_graphql_field(:containingBranches, args, :names) + ) + ) + end + + context 'without excludeTipped argument' do + it 'returns branch names containing the commit' do + refs = project.repository.branch_names_contains(commit_sha) + + post_query + + expect(data).to eq(refs) + end + end + + context 'with excludeTipped argument' do + let(:args) { excluded_tipped_args } + + it 'returns branch names containing the commit without the tipped branch' do + refs = project.repository.branch_names_contains(commit_sha) + + excluded_refs = project.repository + .refs_by_oid(oid: commit_sha, ref_patterns: [ref_prefix]) + .map { |n| n.delete_prefix(ref_prefix) } + + post_query + + expect(data).to eq(refs - excluded_refs) + end + end + + include_context 'with the limit argument' + end + end + + context 'with tipping refs' do + context 'with path Query.project(fullPath).commitReferences(commitSha).tippingTags' do + let(:commit_sha) { repository.find_tag(tag_name).dereferenced_target.sha } + let(:path) { %w[project commitReferences tippingTags names] } + + let(:query) do + graphql_query_for( + :project, + { fullPath: project.full_path }, + query_graphql_field( + :commitReferences, + { commitSha: commit_sha }, + query_graphql_field(:tippingTags, args, :names) + ) + ) + end + + context 'with authorized user' do + it 'returns tags names tipping the commit' do + post_query + + expect(data).to eq([tag_name]) + end + end + + include_context 'with the limit argument' + end + + context 'with path Query.project(fullPath).commitReferences(commitSha).tippingBranches' do + let(:path) { %w[project commitReferences tippingBranches names] } + + let(:query) do + graphql_query_for( + :project, + { fullPath: project.full_path }, + query_graphql_field( + :commitReferences, + { commitSha: commit_sha }, + query_graphql_field(:tippingBranches, args, :names) + ) + ) + end + + it 'returns branches names tipping the commit' do + post_query + + expect(data).to eq(branches_names) + end + + include_context 'with the limit argument' + end + end +end diff --git a/spec/requests/api/graphql/project/container_repositories_spec.rb b/spec/requests/api/graphql/project/container_repositories_spec.rb index 7ccf8a6f5bf..9a40a972256 100644 --- a/spec/requests/api/graphql/project/container_repositories_spec.rb +++ b/spec/requests/api/graphql/project/container_repositories_spec.rb @@ -12,7 +12,7 @@ RSpec.describe 'getting container repositories in a project', feature_category: let_it_be(:container_repositories) { [container_repository, container_repositories_delete_scheduled, container_repositories_delete_failed].flatten } let_it_be(:container_expiration_policy) { project.container_expiration_policy } - let(:excluded_fields) { %w[pipeline jobs] } + let(:excluded_fields) { %w[pipeline jobs productAnalyticsState] } let(:container_repositories_fields) do <<~GQL edges { @@ -155,7 +155,7 @@ RSpec.describe 'getting container repositories in a project', feature_category: it_behaves_like 'handling graphql network errors with the container registry' it_behaves_like 'not hitting graphql network errors with the container registry' do - let(:excluded_fields) { %w[pipeline jobs tags tagsCount] } + let(:excluded_fields) { %w[pipeline jobs tags tagsCount productAnalyticsState] } end it 'returns the total count of container repositories' do diff --git a/spec/requests/api/graphql/project/data_transfer_spec.rb b/spec/requests/api/graphql/project/data_transfer_spec.rb new file mode 100644 index 00000000000..aafa8d65eb9 --- /dev/null +++ b/spec/requests/api/graphql/project/data_transfer_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'project data transfers', feature_category: :source_code_management do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:project) { create(:project) } + + let(:fields) do + <<~QUERY + #{all_graphql_fields_for('ProjectDataTransfer'.classify)} + QUERY + end + + let(:query) do + graphql_query_for( + 'project', + { fullPath: project.full_path }, + query_graphql_field('DataTransfer', params, fields) + ) + end + + let(:from) { Date.new(2022, 1, 1) } + let(:to) { Date.new(2023, 1, 1) } + let(:params) { { from: from, to: to } } + let(:egress_data) do + graphql_data.dig('project', 'dataTransfer', 'egressNodes', 'nodes') + end + + before do + create(:project_data_transfer, project: project, date: '2022-01-01', repository_egress: 1) + create(:project_data_transfer, project: project, date: '2022-02-01', repository_egress: 2) + end + + subject { post_graphql(query, current_user: current_user) } + + context 'with anonymous access' do + let_it_be(:current_user) { nil } + + before do + subject + end + + it_behaves_like 'a working graphql query' + + it 'returns no data' do + expect(graphql_data_at(:project, :data_transfer)).to be_nil + expect(graphql_errors).to be_nil + end + end + + context 'with authorized user but without enough permissions' do + before do + project.add_developer(current_user) + subject + end + + it_behaves_like 'a working graphql query' + + it 'returns empty results' do + expect(graphql_data_at(:project, :data_transfer)).to be_nil + expect(graphql_errors).to be_nil + end + end + + context 'when user has enough permissions' do + before do + project.add_owner(current_user) + end + + context 'when data_transfer_monitoring_mock_data is NOT enabled' do + before do + stub_feature_flags(data_transfer_monitoring_mock_data: false) + subject + end + + it 'returns real results' do + expect(response).to have_gitlab_http_status(:ok) + + expect(egress_data.count).to eq(2) + + expect(egress_data.first.keys).to match_array( + %w[date totalEgress repositoryEgress artifactsEgress packagesEgress registryEgress] + ) + + expect(egress_data.pluck('repositoryEgress')).to match_array(%w[1 2]) + end + + it_behaves_like 'a working graphql query' + end + + context 'when data_transfer_monitoring_mock_data is enabled' do + before do + stub_feature_flags(data_transfer_monitoring_mock_data: true) + subject + end + + it 'returns mock results' do + expect(response).to have_gitlab_http_status(:ok) + + expect(egress_data.count).to eq(12) + expect(egress_data.first.keys).to match_array( + %w[date totalEgress repositoryEgress artifactsEgress packagesEgress registryEgress] + ) + end + + it_behaves_like 'a working graphql query' + end + end +end diff --git a/spec/requests/api/graphql/project/environments_spec.rb b/spec/requests/api/graphql/project/environments_spec.rb index 618f591affa..bb1763ee228 100644 --- a/spec/requests/api/graphql/project/environments_spec.rb +++ b/spec/requests/api/graphql/project/environments_spec.rb @@ -102,7 +102,7 @@ RSpec.describe 'Project Environments query', feature_category: :continuous_deliv end describe 'last deployments of environments' do - ::Deployment.statuses.each do |status, _| + ::Deployment.statuses.each do |status, _| # rubocop:disable RSpec/UselessDynamicDefinition let_it_be(:"production_#{status}_deployment") do create(:deployment, status.to_sym, environment: production, project: project) end diff --git a/spec/requests/api/graphql/project/flow_metrics_spec.rb b/spec/requests/api/graphql/project/flow_metrics_spec.rb new file mode 100644 index 00000000000..3b5758b3a2e --- /dev/null +++ b/spec/requests/api/graphql/project/flow_metrics_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'getting project flow metrics', feature_category: :value_stream_management do + include GraphqlHelpers + + let_it_be(:group) { create(:group) } + let_it_be(:project1) { create(:project, :repository, group: group) } + # This is done so we can use the same count expectations in the shared examples and + # reuse the shared example for the group-level test. + let_it_be(:project2) { project1 } + let_it_be(:production_environment1) { create(:environment, :production, project: project1) } + let_it_be(:production_environment2) { production_environment1 } + let_it_be(:current_user) { create(:user, maintainer_projects: [project1]) } + + let(:full_path) { project1.full_path } + let(:context) { :project } + + it_behaves_like 'value stream analytics flow metrics issueCount examples' + + it_behaves_like 'value stream analytics flow metrics deploymentCount examples' +end diff --git a/spec/requests/api/graphql/project/fork_details_spec.rb b/spec/requests/api/graphql/project/fork_details_spec.rb index efd48b00833..91a04dc7c50 100644 --- a/spec/requests/api/graphql/project/fork_details_spec.rb +++ b/spec/requests/api/graphql/project/fork_details_spec.rb @@ -10,12 +10,13 @@ RSpec.describe 'getting project fork details', feature_category: :source_code_ma let_it_be(:current_user) { create(:user, maintainer_projects: [project]) } let_it_be(:forked_project) { fork_project(project, current_user, repository: true) } + let(:ref) { 'feature' } let(:queried_project) { forked_project } let(:query) do graphql_query_for(:project, { full_path: queried_project.full_path }, <<~QUERY - forkDetails(ref: "feature"){ + forkDetails(ref: "#{ref}"){ ahead behind } @@ -23,12 +24,23 @@ RSpec.describe 'getting project fork details', feature_category: :source_code_ma ) end - it 'returns fork details' do - post_graphql(query, current_user: current_user) + context 'when a ref is specified' do + using RSpec::Parameterized::TableSyntax - expect(graphql_data['project']['forkDetails']).to eq( - { 'ahead' => 1, 'behind' => 29 } - ) + where(:ref, :counts) do + 'feature' | { 'ahead' => 1, 'behind' => 29 } + 'v1.1.1' | { 'ahead' => 5, 'behind' => 0 } + '7b5160f9bb23a3d58a0accdbe89da13b96b1ece9' | { 'ahead' => 9, 'behind' => 0 } + 'non-existent-branch' | { 'ahead' => nil, 'behind' => nil } + end + + with_them do + it 'returns fork details' do + post_graphql(query, current_user: current_user) + + expect(graphql_data['project']['forkDetails']).to eq(counts) + end + end end context 'when a project is not a fork' do @@ -41,6 +53,16 @@ RSpec.describe 'getting project fork details', feature_category: :source_code_ma end end + context 'when project source is not visible' do + it 'does not return fork details' do + project.team.truncate + + post_graphql(query, current_user: current_user) + + expect(graphql_data['project']['forkDetails']).to be_nil + end + end + context 'when a user cannot read the code' do let_it_be(:current_user) { create(:user) } diff --git a/spec/requests/api/graphql/project/merge_request_spec.rb b/spec/requests/api/graphql/project/merge_request_spec.rb index 76e5d687fd1..80c7258c05d 100644 --- a/spec/requests/api/graphql/project/merge_request_spec.rb +++ b/spec/requests/api/graphql/project/merge_request_spec.rb @@ -480,4 +480,31 @@ RSpec.describe 'getting merge request information nested in a project', feature_ merge_request.assignees << user end end + + context 'when selecting `awardEmoji`' do + let_it_be(:award_emoji) { create(:award_emoji, awardable: merge_request, user: current_user) } + + let(:mr_fields) do + <<~QUERY + awardEmoji { + nodes { + user { + username + } + name + } + } + QUERY + end + + it 'includes award emojis' do + post_graphql(query, current_user: current_user) + + response = merge_request_graphql_data['awardEmoji']['nodes'] + + expect(response.length).to eq(1) + expect(response.first['user']['username']).to eq(current_user.username) + expect(response.first['name']).to eq(award_emoji.name) + end + end end diff --git a/spec/requests/api/graphql/project/merge_requests_spec.rb b/spec/requests/api/graphql/project/merge_requests_spec.rb index 8407faa967e..e3c4396e7d8 100644 --- a/spec/requests/api/graphql/project/merge_requests_spec.rb +++ b/spec/requests/api/graphql/project/merge_requests_spec.rb @@ -226,6 +226,28 @@ RSpec.describe 'getting merge request listings nested in a project', feature_cat it_behaves_like 'when searching with parameters' end + context 'when searching by approved' do + let(:approved_mr) { create(:merge_request, target_project: project, source_project: project) } + + before do + create(:approval, merge_request: approved_mr) + end + + context 'when true' do + let(:search_params) { { approved: true } } + let(:mrs) { [approved_mr] } + + it_behaves_like 'when searching with parameters' + end + + context 'when false' do + let(:search_params) { { approved: false } } + let(:mrs) { all_merge_requests } + + it_behaves_like 'when searching with parameters' + end + end + context 'when requesting `approved_by`' do let(:search_params) { { iids: [merge_request_a.iid.to_s, merge_request_b.iid.to_s] } } let(:extra_iid_for_second_query) { merge_request_c.iid.to_s } @@ -331,7 +353,7 @@ RSpec.describe 'getting merge request listings nested in a project', feature_cat end context 'when award emoji votes' do - let(:requested_fields) { [:upvotes, :downvotes] } + let(:requested_fields) { 'upvotes downvotes awardEmoji { nodes { name } }' } before do create_list(:award_emoji, 2, name: 'thumbsup', awardable: merge_request_a) @@ -588,8 +610,9 @@ RSpec.describe 'getting merge request listings nested in a project', feature_cat end let(:query) do + # Adding a no-op `not` filter to mimic the same query as the frontend does graphql_query_for(:project, { full_path: project.full_path }, <<~QUERY) - mergeRequests(mergedAfter: "2020-01-01", mergedBefore: "2020-01-05", first: 0) { + mergeRequests(mergedAfter: "2020-01-01", mergedBefore: "2020-01-05", first: 0, not: { labels: null }) { totalTimeToMerge count } diff --git a/spec/requests/api/graphql/project/milestones_spec.rb b/spec/requests/api/graphql/project/milestones_spec.rb index 3b31da77a75..7a79bf2184a 100644 --- a/spec/requests/api/graphql/project/milestones_spec.rb +++ b/spec/requests/api/graphql/project/milestones_spec.rb @@ -137,18 +137,6 @@ RSpec.describe 'getting milestone listings nested in a project', feature_categor it_behaves_like 'searching with parameters' end - context 'searching by custom range' do - let(:expected) { [no_end, fully_future] } - let(:search_params) do - { - start_date: (today + 6.days).iso8601, - end_date: (today + 7.days).iso8601 - } - end - - it_behaves_like 'searching with parameters' - end - context 'using timeframe argument' do let(:expected) { [no_end, fully_future] } let(:search_params) do @@ -188,23 +176,6 @@ RSpec.describe 'getting milestone listings nested in a project', feature_categor end end - it 'is invalid to provide timeframe and start_date/end_date' do - query = <<~GQL - query($path: ID!, $tstart: Date!, $tend: Date!, $start: Time!, $end: Time!) { - project(fullPath: $path) { - milestones(timeframe: { start: $tstart, end: $tend }, startDate: $start, endDate: $end) { - nodes { id } - } - } - } - GQL - - post_graphql(query, current_user: current_user, - variables: vars.merge(vars.transform_keys { |k| :"t#{k}" })) - - expect(graphql_errors).to contain_exactly(a_hash_including('message' => include('deprecated in favor of timeframe'))) - end - it 'is invalid to invert the timeframe arguments' do query = <<~GQL query($path: ID!, $start: Date!, $end: Date!) { diff --git a/spec/requests/api/graphql/project/project_statistics_redirect_spec.rb b/spec/requests/api/graphql/project/project_statistics_redirect_spec.rb new file mode 100644 index 00000000000..8049a75ace3 --- /dev/null +++ b/spec/requests/api/graphql/project/project_statistics_redirect_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'rendering project storage type routes', feature_category: :shared do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :public) } + let_it_be(:user) { create(:user) } + + let(:query) do + graphql_query_for('project', + { 'fullPath' => project.full_path }, + "statisticsDetailsPaths { #{all_graphql_fields_for('ProjectStatisticsRedirect')} }") + end + + it_behaves_like 'a working graphql query' do + before do + post_graphql(query, current_user: user) + end + end + + shared_examples 'valid routes for storage type' do + it 'contains all keys' do + post_graphql(query, current_user: user) + + expect(graphql_data['project']['statisticsDetailsPaths'].keys).to match_array( + %w[repository buildArtifacts wiki packages snippets containerRegistry] + ) + end + + it 'contains valid paths' do + repository_url = Gitlab::Routing.url_helpers.project_tree_url(project, "master") + wiki_url = Gitlab::Routing.url_helpers.project_wikis_pages_url(project) + build_artifacts_url = Gitlab::Routing.url_helpers.project_artifacts_url(project) + packages_url = Gitlab::Routing.url_helpers.project_packages_url(project) + snippets_url = Gitlab::Routing.url_helpers.project_snippets_url(project) + container_registry_url = Gitlab::Routing.url_helpers.project_container_registry_index_url(project) + + post_graphql(query, current_user: user) + + expect(graphql_data['project']['statisticsDetailsPaths'].values).to match_array [repository_url, + wiki_url, + build_artifacts_url, + packages_url, + snippets_url, + container_registry_url] + end + end + + context 'when project is public' do + it_behaves_like 'valid routes for storage type' + + context 'when user is nil' do + let_it_be(:user) { nil } + + it_behaves_like 'valid routes for storage type' + end + end + + context 'when project is private' do + let_it_be(:project) { create(:project, :private) } + + before do + project.add_reporter(user) + end + + it_behaves_like 'valid routes for storage type' + + context 'when user is nil' do + it 'hides statisticsDetailsPaths for nil users' do + post_graphql(query, current_user: nil) + + expect(graphql_data['project']).to be_blank + end + end + end +end diff --git a/spec/requests/api/graphql/project/release_spec.rb b/spec/requests/api/graphql/project/release_spec.rb index 477388585ca..8d4a39d6b30 100644 --- a/spec/requests/api/graphql/project/release_spec.rb +++ b/spec/requests/api/graphql/project/release_spec.rb @@ -132,7 +132,7 @@ RSpec.describe 'Query.project(fullPath).release(tagName)', feature_category: :re let(:release_fields) do query_graphql_field(:assets, nil, - query_graphql_field(:links, nil, 'nodes { id name url external, directAssetUrl }')) + query_graphql_field(:links, nil, 'nodes { id name url, directAssetUrl }')) end it 'finds all release links' do @@ -141,7 +141,6 @@ RSpec.describe 'Query.project(fullPath).release(tagName)', feature_category: :re expected = release.links.map do |link| a_graphql_entity_for( link, :name, :url, - 'external' => link.external?, 'directAssetUrl' => link.filepath ? Gitlab::Routing.url_helpers.project_release_url(project, release) << "/downloads#{link.filepath}" : link.url ) end @@ -322,16 +321,15 @@ RSpec.describe 'Query.project(fullPath).release(tagName)', feature_category: :re let(:release_fields) do query_graphql_field(:assets, nil, - query_graphql_field(:links, nil, 'nodes { id name url external, directAssetUrl }')) + query_graphql_field(:links, nil, 'nodes { id name url, directAssetUrl }')) end - it 'finds all non source external release links' do + it 'finds all non source release links' do post_query expected = release.links.map do |link| a_graphql_entity_for( link, :name, :url, - 'external' => true, 'directAssetUrl' => link.filepath ? Gitlab::Routing.url_helpers.project_release_url(project, release) << "/downloads#{link.filepath}" : link.url ) end diff --git a/spec/requests/api/graphql/project/user_access_authorized_agents_spec.rb b/spec/requests/api/graphql/project/user_access_authorized_agents_spec.rb new file mode 100644 index 00000000000..b8017171fd1 --- /dev/null +++ b/spec/requests/api/graphql/project/user_access_authorized_agents_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Project.user_access_authorized_agents', feature_category: :deployment_management do + include GraphqlHelpers + + let_it_be(:organization) { create(:group) } + let_it_be(:agent_management_project) { create(:project, :private, group: organization) } + let_it_be(:agent) { create(:cluster_agent, project: agent_management_project) } + + let_it_be(:deployment_project) { create(:project, :private, group: organization) } + let_it_be(:deployment_developer) { create(:user).tap { |u| deployment_project.add_developer(u) } } + let_it_be(:deployment_reporter) { create(:user).tap { |u| deployment_project.add_reporter(u) } } + + let(:user) { deployment_developer } + + let(:query) do + %( + query { + project(fullPath: "#{deployment_project.full_path}") { + userAccessAuthorizedAgents { + nodes { + agent { + id + name + project { + name + } + } + config + } + } + } + } + ) + end + + subject { GitlabSchema.execute(query, context: { current_user: user }).as_json } + + context 'with project authorization' do + let!(:user_access) { create(:agent_user_access_project_authorization, agent: agent, project: deployment_project) } + + it 'returns the authorized agent' do + authorized_agents = subject.dig('data', 'project', 'userAccessAuthorizedAgents', 'nodes') + + expect(authorized_agents.count).to eq(1) + + authorized_agent = authorized_agents.first + + expect(authorized_agent['agent']['id']).to eq(agent.to_global_id.to_s) + expect(authorized_agent['agent']['name']).to eq(agent.name) + expect(authorized_agent['config']).to eq({}) + expect(authorized_agent['agent']['project']).to be_nil # User is not authorized to read other resources. + end + + context 'when user is developer in the agent management project' do + before do + agent_management_project.add_developer(deployment_developer) + end + + it 'returns the project information as well' do + authorized_agent = subject.dig('data', 'project', 'userAccessAuthorizedAgents', 'nodes').first + + expect(authorized_agent['agent']['project']['name']).to eq(agent_management_project.name) + end + end + + context 'when user is reporter' do + let(:user) { deployment_reporter } + + it 'returns nothing' do + expect(subject['data']['project']['userAccessAuthorizedAgents']).to be_nil + end + end + end + + context 'with group authorization' do + let_it_be(:deployment_group) { create(:group, :private, parent: organization) } + + let!(:user_access) { create(:agent_user_access_group_authorization, agent: agent, group: deployment_group) } + + before_all do + deployment_group.add_developer(deployment_developer) + deployment_group.add_reporter(deployment_reporter) + end + + it 'returns the authorized agent' do + authorized_agents = subject.dig('data', 'project', 'userAccessAuthorizedAgents', 'nodes') + + expect(authorized_agents.count).to eq(1) + + authorized_agent = authorized_agents.first + + expect(authorized_agent['agent']['id']).to eq(agent.to_global_id.to_s) + expect(authorized_agent['agent']['name']).to eq(agent.name) + expect(authorized_agent['config']).to eq({}) + expect(authorized_agent['agent']['project']).to be_nil # User is not authorized to read other resources. + end + + context 'when user is developer in the agent management project' do + before do + agent_management_project.add_developer(deployment_developer) + end + + it 'returns the project information as well' do + authorized_agent = subject.dig('data', 'project', 'userAccessAuthorizedAgents', 'nodes').first + + expect(authorized_agent['agent']['project']['name']).to eq(agent_management_project.name) + end + end + + context 'when user is reporter' do + let(:user) { deployment_reporter } + + it 'returns nothing' do + expect(subject['data']['project']['userAccessAuthorizedAgents']).to be_nil + end + end + end + + context 'when deployment project is not authorized to user_access to the agent' do + it 'returns empty' do + authorized_agents = subject.dig('data', 'project', 'userAccessAuthorizedAgents', 'nodes') + + expect(authorized_agents).to be_empty + end + end +end diff --git a/spec/requests/api/graphql/project/work_items_spec.rb b/spec/requests/api/graphql/project/work_items_spec.rb index f49165a88ea..628a2117e9d 100644 --- a/spec/requests/api/graphql/project/work_items_spec.rb +++ b/spec/requests/api/graphql/project/work_items_spec.rb @@ -120,24 +120,55 @@ RSpec.describe 'getting a work item list for a project', feature_category: :team end context 'when querying WorkItemWidgetHierarchy' do - let_it_be(:children) { create_list(:work_item, 3, :task, project: project) } + let_it_be(:children) { create_list(:work_item, 4, :task, project: project) } let_it_be(:child_link1) { create(:parent_link, work_item_parent: item1, work_item: children[0]) } + let_it_be(:child_link2) { create(:parent_link, work_item_parent: item1, work_item: children[1]) } let(:fields) do <<~GRAPHQL - nodes { - widgets { - type - ... on WorkItemWidgetHierarchy { - hasChildren - parent { id } - children { nodes { id } } - } + nodes { + id + widgets { + type + ... on WorkItemWidgetHierarchy { + hasChildren + parent { id } + children { nodes { id } } } } + } GRAPHQL end + context 'with ordered children' do + let(:items_data) { graphql_data['project']['workItems']['nodes'] } + let(:work_item_data) { items_data.find { |item| item['id'] == item1.to_gid.to_s } } + let(:work_item_widget) { work_item_data["widgets"].find { |widget| widget.key?("children") } } + let(:children_ids) { work_item_widget.dig("children", "nodes").pluck("id") } + + let(:first_child) { children[0].to_gid.to_s } + let(:second_child) { children[1].to_gid.to_s } + + it 'returns children ordered by created_at by default' do + post_graphql(query, current_user: current_user) + + expect(children_ids).to eq([first_child, second_child]) + end + + context 'when ordered by relative position' do + before do + child_link1.update!(relative_position: 20) + child_link2.update!(relative_position: 10) + end + + it 'returns children in correct order' do + post_graphql(query, current_user: current_user) + + expect(children_ids).to eq([second_child, first_child]) + end + end + end + it 'executes limited number of N+1 queries' do post_graphql(query, current_user: current_user) # warm-up @@ -146,13 +177,11 @@ RSpec.describe 'getting a work item list for a project', feature_category: :team end parent_work_items = create_list(:work_item, 2, project: project) - create(:parent_link, work_item_parent: parent_work_items[0], work_item: children[1]) - create(:parent_link, work_item_parent: parent_work_items[1], work_item: children[2]) + create(:parent_link, work_item_parent: parent_work_items[0], work_item: children[2]) + create(:parent_link, work_item_parent: parent_work_items[1], work_item: children[3]) - # There are 2 extra queries for fetching the children field - # See: https://gitlab.com/gitlab-org/gitlab/-/issues/363569 expect { post_graphql(query, current_user: current_user) } - .not_to exceed_query_limit(control).with_threshold(2) + .not_to exceed_query_limit(control) end it 'avoids N+1 queries when children are added to a work item' do @@ -162,8 +191,8 @@ RSpec.describe 'getting a work item list for a project', feature_category: :team post_graphql(query, current_user: current_user) end - create(:parent_link, work_item_parent: item1, work_item: children[1]) create(:parent_link, work_item_parent: item1, work_item: children[2]) + create(:parent_link, work_item_parent: item1, work_item: children[3]) expect { post_graphql(query, current_user: current_user) } .not_to exceed_query_limit(control) @@ -313,6 +342,79 @@ RSpec.describe 'getting a work item list for a project', feature_category: :team end end + context 'when fetching work item notifications widget' do + let(:fields) do + <<~GRAPHQL + nodes { + widgets { + type + ... on WorkItemWidgetNotifications { + subscribed + } + } + } + GRAPHQL + end + + it 'executes limited number of N+1 queries', :use_sql_query_cache do + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post_graphql(query, current_user: current_user) + end + + create_list(:work_item, 3, project: project) + + # Performs 1 extra query per item to fetch subscriptions + expect { post_graphql(query, current_user: current_user) } + .not_to exceed_all_query_limit(control).with_threshold(3) + expect_graphql_errors_to_be_empty + end + end + + context 'when fetching work item award emoji widget' do + let(:fields) do + <<~GRAPHQL + nodes { + widgets { + type + ... on WorkItemWidgetAwardEmoji { + awardEmoji { + nodes { + name + emoji + user { id } + } + } + upvotes + downvotes + } + } + } + GRAPHQL + end + + before do + create(:award_emoji, name: 'star', user: current_user, awardable: item1) + create(:award_emoji, :upvote, awardable: item1) + create(:award_emoji, :downvote, awardable: item1) + end + + it 'executes limited number of N+1 queries', :use_sql_query_cache do + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post_graphql(query, current_user: current_user) + end + + create_list(:work_item, 2, project: project) do |item| + create(:award_emoji, name: 'rocket', awardable: item) + create_list(:award_emoji, 2, :upvote, awardable: item) + create_list(:award_emoji, 2, :downvote, awardable: item) + end + + expect { post_graphql(query, current_user: current_user) } + .not_to exceed_all_query_limit(control) + expect_graphql_errors_to_be_empty + end + end + def item_ids graphql_dig_at(items_data, :node, :id) end diff --git a/spec/requests/api/graphql/project_query_spec.rb b/spec/requests/api/graphql/project_query_spec.rb index 281a08e6548..9f51258c163 100644 --- a/spec/requests/api/graphql/project_query_spec.rb +++ b/spec/requests/api/graphql/project_query_spec.rb @@ -120,6 +120,67 @@ RSpec.describe 'getting project information', feature_category: :projects do end end + describe 'is_catalog_resource' do + before do + project.add_owner(current_user) + end + + let(:catalog_resource_query) do + <<~GRAPHQL + { + project(fullPath: "#{project.full_path}") { + isCatalogResource + } + } + GRAPHQL + end + + context 'when the project is not a catalog resource' do + it 'is false' do + post_graphql(catalog_resource_query, current_user: current_user) + + expect(graphql_data.dig('project', 'isCatalogResource')).to be(false) + end + end + + context 'when the project is a catalog resource' do + before do + create(:catalog_resource, project: project) + end + + it 'is true' do + post_graphql(catalog_resource_query, current_user: current_user) + + expect(graphql_data.dig('project', 'isCatalogResource')).to be(true) + end + end + + context 'for N+1 queries with isCatalogResource' do + let_it_be(:project1) { create(:project, group: group) } + let_it_be(:project2) { create(:project, group: group) } + + it 'avoids N+1 database queries' do + pending('See: https://gitlab.com/gitlab-org/gitlab/-/issues/403634') + ctx = { current_user: current_user } + + baseline_query = graphql_query_for(:project, { full_path: project1.full_path }, 'isCatalogResource') + + query = <<~GQL + query { + a: #{query_graphql_field(:project, { full_path: project1.full_path }, 'isCatalogResource')} + b: #{query_graphql_field(:project, { full_path: project2.full_path }, 'isCatalogResource')} + } + GQL + + control = ActiveRecord::QueryRecorder.new do + run_with_clean_state(baseline_query, context: ctx) + end + + expect { run_with_clean_state(query, context: ctx) }.not_to exceed_query_limit(control) + end + end + end + context 'when the user has reporter access to the project' do let(:statistics_query) do <<~GRAPHQL diff --git a/spec/requests/api/graphql/query_spec.rb b/spec/requests/api/graphql/query_spec.rb index 2b9d66ec744..0602cfec149 100644 --- a/spec/requests/api/graphql/query_spec.rb +++ b/spec/requests/api/graphql/query_spec.rb @@ -2,10 +2,10 @@ require 'spec_helper' -RSpec.describe 'Query', feature_category: :not_owned do +RSpec.describe 'Query', feature_category: :shared do include GraphqlHelpers - let_it_be(:project) { create(:project) } + let_it_be(:project) { create(:project, public_builds: false) } let_it_be(:issue) { create(:issue, project: project) } let_it_be(:developer) { create(:user) } @@ -116,4 +116,36 @@ RSpec.describe 'Query', feature_category: :not_owned do end end end + + describe '.ciPipelineStage' do + let_it_be(:ci_stage) { create(:ci_stage, name: 'graphql test stage', project: project) } + + let(:query) do + <<~GRAPHQL + { + ciPipelineStage(id: "#{ci_stage.to_global_id}") { + name + } + } + GRAPHQL + end + + context 'when the current user has access to the stage' do + it 'fetches the stage for the given ID' do + project.add_developer(developer) + + post_graphql(query, current_user: developer) + + expect(graphql_data.dig('ciPipelineStage', 'name')).to eq('graphql test stage') + end + end + + context 'when the current user does not have access to the stage' do + it 'returns nil' do + post_graphql(query, current_user: developer) + + expect(graphql_data['ciPipelineStage']).to be_nil + end + end + end end diff --git a/spec/requests/api/graphql/user/user_achievements_query_spec.rb b/spec/requests/api/graphql/user/user_achievements_query_spec.rb new file mode 100644 index 00000000000..27d32d07372 --- /dev/null +++ b/spec/requests/api/graphql/user/user_achievements_query_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'UserAchievements', feature_category: :user_profile do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group, :private) } + let_it_be(:achievement) { create(:achievement, namespace: group) } + let_it_be(:non_revoked_achievement) { create(:user_achievement, achievement: achievement, user: user) } + let_it_be(:revoked_achievement) { create(:user_achievement, :revoked, achievement: achievement, user: user) } + let_it_be(:fields) do + <<~HEREDOC + userAchievements { + nodes { + id + achievement { + id + } + user { + id + } + awardedByUser { + id + } + revokedByUser { + id + } + } + } + HEREDOC + end + + let_it_be(:query) do + graphql_query_for('user', { id: user.to_global_id.to_s }, fields) + end + + let(:current_user) { user } + + before_all do + group.add_guest(user) + end + + before do + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'returns all non_revoked user_achievements' do + expect(graphql_data_at(:user, :userAchievements, :nodes)).to contain_exactly( + a_graphql_entity_for(non_revoked_achievement) + ) + end + + it 'can lookahead to eliminate N+1 queries', :use_clean_rails_memory_store_caching do + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post_graphql(query, current_user: user) + end.count + + achievement2 = create(:achievement, namespace: group) + create_list(:user_achievement, 2, achievement: achievement2, user: user) + + expect { post_graphql(query, current_user: user) }.not_to exceed_all_query_limit(control_count) + end + + context 'when the achievements feature flag is disabled for a namespace' do + let_it_be(:group2) { create(:group) } + let_it_be(:achievement2) { create(:achievement, namespace: group2) } + let_it_be(:user_achievement2) { create(:user_achievement, achievement: achievement2, user: user) } + + before do + stub_feature_flags(achievements: false) + stub_feature_flags(achievements: group2) + post_graphql(query, current_user: current_user) + end + + it 'does not return user_achievements for that namespace' do + expect(graphql_data_at(:user, :userAchievements, :nodes)).to contain_exactly( + a_graphql_entity_for(user_achievement2) + ) + end + end + + context 'when current user is not a member of the private group' do + let(:current_user) { create(:user) } + + it 'returns all achievements' do + expect(graphql_data_at(:user, :userAchievements, :nodes)).to contain_exactly( + a_graphql_entity_for(non_revoked_achievement) + ) + end + end +end diff --git a/spec/requests/api/graphql/user_spec.rb b/spec/requests/api/graphql/user_spec.rb index c19dfa6f3f3..41ee233dfc5 100644 --- a/spec/requests/api/graphql/user_spec.rb +++ b/spec/requests/api/graphql/user_spec.rb @@ -10,6 +10,12 @@ RSpec.describe 'User', feature_category: :user_profile do shared_examples 'a working user query' do it_behaves_like 'a working graphql query' do before do + # TODO: This license stub is necessary because the remote development workspaces field + # defined in the EE version of UserInterface gets picked up here and thus the license + # check happens. This comes from the `ancestors` call in + # lib/graphql/schema/member/has_fields.rb#fields in the graphql library. + stub_licensed_features(remote_development: true) + post_graphql(query, current_user: current_user) end end @@ -36,9 +42,17 @@ RSpec.describe 'User', feature_category: :user_profile do end context 'when username parameter is used' do - let(:query) { graphql_query_for(:user, { username: current_user.username.to_s }) } + context 'when username is identically cased' do + let(:query) { graphql_query_for(:user, { username: current_user.username.to_s }) } - it_behaves_like 'a working user query' + it_behaves_like 'a working user query' + end + + context 'when username is differently cased' do + let(:query) { graphql_query_for(:user, { username: current_user.username.to_s.upcase }) } + + it_behaves_like 'a working user query' + end end context 'when username and id parameter are used' do diff --git a/spec/requests/api/graphql/work_item_spec.rb b/spec/requests/api/graphql/work_item_spec.rb index 0fad4f4ff3a..dc5004a121b 100644 --- a/spec/requests/api/graphql/work_item_spec.rb +++ b/spec/requests/api/graphql/work_item_spec.rb @@ -36,9 +36,15 @@ RSpec.describe 'Query.work_item(id)', feature_category: :team_planning do end context 'when the user can read the work item' do + let(:incoming_email_token) { current_user.incoming_email_token } + let(:work_item_email) do + "p+#{project.full_path_slug}-#{project.project_id}-#{incoming_email_token}-issue-#{work_item.iid}@gl.ab" + end + before do project.add_developer(developer) project.add_guest(guest) + stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab") post_graphql(query, current_user: current_user) end @@ -55,11 +61,15 @@ RSpec.describe 'Query.work_item(id)', feature_category: :team_planning do 'title' => work_item.title, 'confidential' => work_item.confidential, 'workItemType' => hash_including('id' => work_item.work_item_type.to_gid.to_s), + 'reference' => work_item.to_reference, + 'createNoteEmail' => work_item_email, 'userPermissions' => { 'readWorkItem' => true, 'updateWorkItem' => true, 'deleteWorkItem' => false, - 'adminWorkItem' => true + 'adminWorkItem' => true, + 'adminParentLink' => true, + 'setWorkItemMetadata' => true }, 'project' => hash_including('id' => project.to_gid.to_s, 'fullPath' => project.full_path) ) @@ -373,6 +383,161 @@ RSpec.describe 'Query.work_item(id)', feature_category: :team_planning do ) end end + + describe 'notifications widget' do + let(:work_item_fields) do + <<~GRAPHQL + id + widgets { + type + ... on WorkItemWidgetNotifications { + subscribed + } + } + GRAPHQL + end + + it 'returns widget information' do + expect(work_item_data).to include( + 'id' => work_item.to_gid.to_s, + 'widgets' => include( + hash_including( + 'type' => 'NOTIFICATIONS', + 'subscribed' => work_item.subscribed?(current_user, project) + ) + ) + ) + end + end + + describe 'currentUserTodos widget' do + let_it_be(:current_user) { developer } + let_it_be(:other_todo) { create(:todo, state: :pending, user: current_user) } + + let_it_be(:done_todo) do + create(:todo, state: :done, target: work_item, target_type: work_item.class.name, user: current_user) + end + + let_it_be(:pending_todo) do + create(:todo, state: :pending, target: work_item, target_type: work_item.class.name, user: current_user) + end + + let_it_be(:other_user_todo) do + create(:todo, state: :pending, target: work_item, target_type: work_item.class.name, user: create(:user)) + end + + let(:work_item_fields) do + <<~GRAPHQL + id + widgets { + type + ... on WorkItemWidgetCurrentUserTodos { + currentUserTodos { + nodes { + id + state + } + } + } + } + GRAPHQL + end + + context 'with access' do + it 'returns widget information' do + expect(work_item_data).to include( + 'id' => work_item.to_gid.to_s, + 'widgets' => include( + hash_including( + 'type' => 'CURRENT_USER_TODOS', + 'currentUserTodos' => { + 'nodes' => match_array( + [done_todo, pending_todo].map { |t| { 'id' => t.to_gid.to_s, 'state' => t.state } } + ) + } + ) + ) + ) + end + end + + context 'with filter' do + let(:work_item_fields) do + <<~GRAPHQL + id + widgets { + type + ... on WorkItemWidgetCurrentUserTodos { + currentUserTodos(state: done) { + nodes { + id + state + } + } + } + } + GRAPHQL + end + + it 'returns widget information' do + expect(work_item_data).to include( + 'id' => work_item.to_gid.to_s, + 'widgets' => include( + hash_including( + 'type' => 'CURRENT_USER_TODOS', + 'currentUserTodos' => { + 'nodes' => match_array( + [done_todo].map { |t| { 'id' => t.to_gid.to_s, 'state' => t.state } } + ) + } + ) + ) + ) + end + end + end + + describe 'award emoji widget' do + let_it_be(:emoji) { create(:award_emoji, name: 'star', awardable: work_item) } + let_it_be(:upvote) { create(:award_emoji, :upvote, awardable: work_item) } + let_it_be(:downvote) { create(:award_emoji, :downvote, awardable: work_item) } + + let(:work_item_fields) do + <<~GRAPHQL + id + widgets { + type + ... on WorkItemWidgetAwardEmoji { + upvotes + downvotes + awardEmoji { + nodes { + name + } + } + } + } + GRAPHQL + end + + it 'returns widget information' do + expect(work_item_data).to include( + 'id' => work_item.to_gid.to_s, + 'widgets' => include( + hash_including( + 'type' => 'AWARD_EMOJI', + 'upvotes' => work_item.upvotes, + 'downvotes' => work_item.downvotes, + 'awardEmoji' => { + 'nodes' => match_array( + [emoji, upvote, downvote].map { |e| { 'name' => e.name } } + ) + } + ) + ) + ) + end + end end context 'when an Issue Global ID is provided' do @@ -398,4 +563,23 @@ RSpec.describe 'Query.work_item(id)', feature_category: :team_planning do ) end end + + context 'when the user cannot set work item metadata' do + let(:current_user) { guest } + + before do + project.add_guest(guest) + post_graphql(query, current_user: current_user) + end + + it 'returns correct user permission' do + expect(work_item_data).to include( + 'id' => work_item.to_gid.to_s, + 'userPermissions' => + hash_including( + 'setWorkItemMetadata' => false + ) + ) + end + end end |