Skip to content
Snippets Groups Projects
Commit e10600cb authored by Panos Kanellidis's avatar Panos Kanellidis :white_check_mark: Committed by Albert
Browse files

Add create project subscription mutation - GraphQL

parent fb3b8cc3
No related branches found
No related tags found
1 merge request!133308Add create project subscription mutation - GraphQL
Showing
with 521 additions and 0 deletions
---
name: create_project_subscription_graphql_endpoint
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/133308
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/429339
milestone: '16.6'
type: development
group: group::pipeline execution
default_enabled: false
......@@ -5941,6 +5941,26 @@ Input type: `ProjectSetLockedInput`
| <a id="mutationprojectsetlockederrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationprojectsetlockedproject"></a>`project` | [`Project`](#project) | Project after mutation. |
 
### `Mutation.projectSubscriptionCreate`
Input type: `ProjectSubscriptionCreateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationprojectsubscriptioncreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationprojectsubscriptioncreateprojectpath"></a>`projectPath` | [`String!`](#string) | Full path of the downstream project of the Project Subscription. |
| <a id="mutationprojectsubscriptioncreateupstreampath"></a>`upstreamPath` | [`String!`](#string) | Full path of the upstream project of the Project Subscription. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationprojectsubscriptioncreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationprojectsubscriptioncreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationprojectsubscriptioncreatesubscription"></a>`subscription` | [`CiSubscriptionsProject`](#cisubscriptionsproject) | Project Subscription created by the mutation. |
### `Mutation.projectSyncFork`
 
WARNING:
......@@ -15115,6 +15135,17 @@ Represents the Geo replication and verification state of a ci_secure_file.
| <a id="cistagename"></a>`name` | [`String`](#string) | Name of the stage. |
| <a id="cistagestatus"></a>`status` | [`String`](#string) | Status of the pipeline stage. |
 
### `CiSubscriptionsProject`
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="cisubscriptionsprojectauthor"></a>`author` | [`UserCore`](#usercore) | Author of the subscription. |
| <a id="cisubscriptionsprojectdownstreamproject"></a>`downstreamProject` | [`Project`](#project) | Downstream project of the subscription. |
| <a id="cisubscriptionsprojectid"></a>`id` | [`CiSubscriptionsProjectID`](#cisubscriptionsprojectid) | Global ID of the subscription. |
| <a id="cisubscriptionsprojectupstreamproject"></a>`upstreamProject` | [`Project`](#project) | Upstream project of the subscription. |
### `CiTemplate`
 
GitLab CI/CD configuration template.
......@@ -30333,6 +30364,12 @@ A `CiStageID` is a global ID. It is encoded as a string.
 
An example `CiStageID` is: `"gid://gitlab/Ci::Stage/1"`.
 
### `CiSubscriptionsProjectID`
A `CiSubscriptionsProjectID` is a global ID. It is encoded as a string.
An example `CiSubscriptionsProjectID` is: `"gid://gitlab/Ci::Subscriptions::Project/1"`.
### `CiTriggerID`
 
A `CiTriggerID` is a global ID. It is encoded as a string.
......@@ -7,6 +7,7 @@ module MutationType
prepended do
mount_mutation ::Mutations::Ci::Ai::GenerateConfig, alpha: { milestone: '16.0' }
mount_mutation ::Mutations::Ci::ProjectSubscriptions::Create
mount_mutation ::Mutations::ComplianceManagement::Frameworks::Destroy
mount_mutation ::Mutations::ComplianceManagement::Frameworks::Update
mount_mutation ::Mutations::ComplianceManagement::Frameworks::Create
......
# frozen_string_literal: true
module Mutations
module Ci
module ProjectSubscriptions
class Create < BaseMutation
graphql_name 'ProjectSubscriptionCreate'
include FindsProject
authorize :admin_project
argument :project_path, GraphQL::Types::String,
required: true,
description: 'Full path of the downstream project of the Project Subscription.'
argument :upstream_path, GraphQL::Types::String,
required: true,
description: 'Full path of the upstream project of the Project Subscription.'
field :subscription,
Types::Ci::Subscriptions::ProjectType,
null: true,
description: "Project Subscription created by the mutation."
def resolve(project_path:, upstream_path:)
project = authorized_find!(project_path)
raise_resource_not_available_error! unless ::Feature.enabled?(:create_project_subscription_graphql_endpoint,
project)
upstream_project = find_and_authorize_upstream_project!(upstream_path)
response = ::Ci::CreateProjectSubscriptionService.new(
project: project,
upstream_project: upstream_project,
user: current_user
).execute
if response.error?
result(errors: [response.message])
else
result(subscription: response.payload[:subscription])
end
end
private
def find_and_authorize_upstream_project!(upstream_path)
upstream_project = find_object(upstream_path)
return upstream_project if upstream_project && current_user.can?(:developer_access, upstream_project)
raise_resource_not_available_error!
end
def result(subscription: nil, errors: [])
{
subscription: subscription,
errors: errors
}
end
end
end
end
end
# frozen_string_literal: true
module Types
module Ci
module Subscriptions
class ProjectType < BaseObject
graphql_name 'CiSubscriptionsProject'
authorize :read_project_subscription
field :id, Types::GlobalIDType[::Ci::Subscriptions::Project], description: "Global ID of the subscription."
field :downstream_project, Types::ProjectType,
description: "Downstream project of the subscription."
field :upstream_project, Types::ProjectType,
description: "Upstream project of the subscription."
field :author, Types::UserType, description: "Author of the subscription."
end
end
end
end
# frozen_string_literal: true
module Ci
module Subscriptions
class ProjectPolicy < BasePolicy
condition(:admin_access_to_both_projects) do
can?(:admin_project, @subject.downstream_project)
end
condition(:developer_access_to_downstream_project) do
can?(:developer_access, @subject.upstream_project)
end
rule { admin_access_to_both_projects & developer_access_to_downstream_project }.policy do
enable :read_project_subscription
end
end
end
end
# frozen_string_literal: true
module Ci
class CreateProjectSubscriptionService < BaseService
def initialize(project:, upstream_project:, user:)
super(project, user)
@upstream_project = upstream_project
end
def execute
error = validate
return error if error.present?
subscription = project.upstream_project_subscriptions.create(
upstream_project: upstream_project,
author: current_user
)
if subscription.errors.present?
return ServiceResponse.error(
message: subscription.errors.full_messages
)
end
ServiceResponse.success(payload: { subscription: subscription })
end
private
attr_reader :upstream_project
def validate
return if allowed?
ServiceResponse.error(message: "Feature unavailable for this user.")
end
def allowed?
project.licensed_feature_available?(:ci_project_subscriptions) &&
can?(current_user, :developer_access, upstream_project) &&
can?(current_user, :admin_project, project)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['CiSubscriptionsProject'], feature_category: :source_code_management do
include GraphqlHelpers
subject { described_class }
let_it_be(:fields) { %i[id downstream_project upstream_project author] }
it { is_expected.to have_graphql_fields(fields) }
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Ci::Subscriptions::ProjectPolicy, feature_category: :compliance_management do
let_it_be_with_reload(:project) { create(:project, :repository, :public) }
let_it_be(:upstream_project) { create(:project, :repository, :public) }
let_it_be(:user) { create(:user) }
let!(:subscription) do
create(:ci_subscriptions_project, downstream_project: project, upstream_project: upstream_project)
end
subject(:policy) { described_class.new(user, subscription) }
context 'when user has no permissions' do
it { is_expected.to be_disallowed(:read_project_subscription) }
end
context 'when user is maintainer for the downstream project' do
before_all do
project.add_maintainer(user)
end
it { is_expected.to be_disallowed(:read_project_subscription) }
end
context 'when user is a developer for the upstream project' do
before_all do
upstream_project.add_developer(user)
end
it { is_expected.to be_disallowed(:read_project_subscription) }
end
context 'when user is developer for both projects' do
before_all do
project.add_developer(user)
upstream_project.add_developer(user)
end
it { is_expected.to be_disallowed(:read_project_subscription) }
end
context 'when user does have access to project' do
context 'when user is a developer on the upstream project' do
before_all do
project.add_maintainer(user)
upstream_project.add_developer(user)
end
it { is_expected.to be_allowed(:read_project_subscription) }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Create project subscription', feature_category: :continuous_integration do
include GraphqlHelpers
let_it_be_with_reload(:project) { create(:project, :repository, :public) }
let_it_be(:upstream_project) { create(:project, :repository, :public) }
let_it_be(:current_user) { create(:user) }
let(:mutation) do
graphql_mutation(:project_subscription_create, params) do
<<~QL
subscription {
id
downstreamProject {
name
}
upstreamProject {
id
name
}
}
errors
QL
end
end
let(:params) { { project_path: project.full_path, upstream_path: upstream_project.full_path } }
let(:mutation_response) { graphql_mutation_response(:project_subscription_create) }
subject(:post_mutation) { post_graphql_mutation(mutation, current_user: current_user) }
before do
stub_licensed_features(ci_project_subscriptions: true)
end
context 'when the user has the required permissions' do
before_all do
project.add_maintainer(current_user)
upstream_project.add_developer(current_user)
end
context 'when a successful result is yielded' do
it 'does creates a new record' do
expect { post_mutation }.to change { ::Ci::Subscriptions::Project.count }.by(1)
end
it 'returns the subscription' do
post_mutation
expect(mutation_response['subscription']['downstreamProject']['name']).to eq(project.name)
end
end
context 'when the downstream path is invalid' do
let(:params) { { project_path: 'An/Invalid/Path', upstream_path: upstream_project.full_path } }
it 'returns an error' do
post_mutation
expect(graphql_errors)
.to include(a_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
it 'does not create a new record' do
expect { post_mutation }.not_to change { ::Ci::Subscriptions::Project.count }
end
end
context 'when the upstream path is invalid' do
let(:params) { { project_path: project.full_path, upstream_path: 'An/Invalid/Path' } }
it 'returns an error' do
post_mutation
expect(graphql_errors)
.to include(a_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
it 'does not create a new record' do
expect { post_mutation }.not_to change { ::Ci::Subscriptions::Project.count }
end
end
context 'when the service returns an error' do
let(:service) { instance_double(::Ci::CreateProjectSubscriptionService) }
let(:service_response) { ServiceResponse.error(message: 'An error message.') }
before do
allow(::Ci::CreateProjectSubscriptionService).to receive(:new) { service }
allow(service).to receive(:execute) { service_response }
end
it_behaves_like 'a mutation that returns errors in the response',
errors: ['An error message.']
it 'does not create a new record' do
expect { post_mutation }.not_to change { ::Ci::Subscriptions::Project.count }
end
end
context 'when feature flag create_project_subscription_graphql_endpoint is disabled' do
before do
stub_feature_flags(create_project_subscription_graphql_endpoint: false)
end
it 'returns an error' do
post_mutation
expect(graphql_errors)
.to include(a_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
it 'does not create a new record' do
expect { post_mutation }.not_to change { ::Ci::Subscriptions::Project.count }
end
end
end
context 'when the user does not have the maintainer role' do
before_all do
upstream_project.add_developer(current_user)
end
it 'returns an error' do
post_mutation
expect(graphql_errors)
.to include(a_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
it 'does not create a new record' do
expect { post_mutation }.not_to change { ::Ci::Subscriptions::Project.count }
end
end
context 'when the user does not have the developer role' do
before_all do
project.add_maintainer(current_user)
end
it 'returns an error' do
post_mutation
expect(graphql_errors)
.to include(a_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
it 'does not create a new record' do
expect { post_mutation }.not_to change { ::Ci::Subscriptions::Project.count }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ci::CreateProjectSubscriptionService, feature_category: :continuous_integration do
describe '#execute' do
let_it_be_with_reload(:project) { create(:project, :repository, :public) }
let_it_be(:upstream_project) { create(:project, :repository, :public) }
let_it_be(:current_user) { create(:user) }
let(:success_response) { ServiceResponse.success }
subject(:execute) do
described_class.new(project: project,
upstream_project: upstream_project,
user: current_user).execute
end
before do
stub_licensed_features(ci_project_subscriptions: true)
end
context 'when the user has the required permissions' do
before_all do
upstream_project.add_developer(current_user)
project.add_maintainer(current_user)
end
it 'returns a success response with the payload' do
subscription = execute.payload[:subscription]
expect(subscription.downstream_project).to eq(project)
expect(subscription.upstream_project).to eq(upstream_project)
end
it 'increases the DB record by 1' do
expect { execute }.to change { ::Ci::Subscriptions::Project.count }.by(1)
end
context 'when the feature is locked' do
before do
stub_licensed_features(ci_project_subscriptions: false)
end
it 'returns a service error with the relevant message' do
result = execute
expect(result.payload).to eq({})
expect(result.errors.first).to eq('Feature unavailable for this user.')
end
it 'does not create a new record' do
expect { execute }.not_to change { ::Ci::Subscriptions::Project.count }
end
end
context 'when the upstream project is taken' do
before do
create(:ci_subscriptions_project, downstream_project: project, upstream_project: upstream_project)
end
it 'returns a service error with the relevant message' do
result = execute
expect(result.payload).to eq({})
expect(result.errors.first).to eq('Upstream project has already been taken')
end
it 'does not create a new record' do
expect { execute }.not_to change { ::Ci::Subscriptions::Project.count }
end
end
end
context 'when the user does not have the developer role to the upstream project' do
before_all do
project.add_maintainer(current_user)
end
it 'returns a service error with the relevant message' do
result = execute
expect(result.payload).to eq({})
expect(result.errors.first).to eq('Feature unavailable for this user.')
end
it 'does not create a new record' do
expect { execute }.not_to change { ::Ci::Subscriptions::Project.count }
end
end
context 'when the user does not have the admin_project role to the downstream project' do
before_all do
upstream_project.add_developer(current_user)
end
it 'returns a service error with the relevant message' do
result = execute
expect(result.payload).to eq({})
expect(result.errors.first).to eq('Feature unavailable for this user.')
end
it 'does not create a new record' do
expect { execute }.not_to change { ::Ci::Subscriptions::Project.count }
end
end
end
end
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment