Commit a4ec91a7 authored by Vitali Tatarintev's avatar Vitali Tatarintev

Merge branch '346479_security_training_providers_mutation' into 'master'

Mutation for security training providers

See merge request gitlab-org/gitlab!78687
parents 29427998 7655fb2e
...@@ -4310,6 +4310,28 @@ Input type: `SecurityPolicyProjectUnassignInput` ...@@ -4310,6 +4310,28 @@ Input type: `SecurityPolicyProjectUnassignInput`
| <a id="mutationsecuritypolicyprojectunassignclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationsecuritypolicyprojectunassignclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationsecuritypolicyprojectunassignerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationsecuritypolicyprojectunassignerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
### `Mutation.securityTrainingUpdate`
Input type: `SecurityTrainingUpdateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationsecuritytrainingupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationsecuritytrainingupdateisenabled"></a>`isEnabled` | [`Boolean!`](#boolean) | Sets the training provider as enabled for the project. |
| <a id="mutationsecuritytrainingupdateisprimary"></a>`isPrimary` | [`Boolean`](#boolean) | Sets the training provider as primary for the project. |
| <a id="mutationsecuritytrainingupdateprojectpath"></a>`projectPath` | [`ID!`](#id) | Full path of the project. |
| <a id="mutationsecuritytrainingupdateproviderid"></a>`providerId` | [`SecurityTrainingProviderID!`](#securitytrainingproviderid) | ID of the provider. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationsecuritytrainingupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationsecuritytrainingupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationsecuritytrainingupdatetraining"></a>`training` | [`ProjectSecurityTraining`](#projectsecuritytraining) | Represents the training entity subject to mutation. |
### `Mutation.terraformStateDelete` ### `Mutation.terraformStateDelete`
Input type: `TerraformStateDeleteInput` Input type: `TerraformStateDeleteInput`
...@@ -18644,6 +18666,12 @@ A `ReleasesLinkID` is a global ID. It is encoded as a string. ...@@ -18644,6 +18666,12 @@ A `ReleasesLinkID` is a global ID. It is encoded as a string.
An example `ReleasesLinkID` is: `"gid://gitlab/Releases::Link/1"`. An example `ReleasesLinkID` is: `"gid://gitlab/Releases::Link/1"`.
### `SecurityTrainingProviderID`
A `SecurityTrainingProviderID` is a global ID. It is encoded as a string.
An example `SecurityTrainingProviderID` is: `"gid://gitlab/Security::TrainingProvider/1"`.
### `SnippetID` ### `SnippetID`
A `SnippetID` is a global ID. It is encoded as a string. A `SnippetID` is a global ID. It is encoded as a string.
...@@ -91,6 +91,7 @@ module EE ...@@ -91,6 +91,7 @@ module EE
mount_mutation ::Mutations::SecurityPolicy::CreateSecurityPolicyProject mount_mutation ::Mutations::SecurityPolicy::CreateSecurityPolicyProject
mount_mutation ::Mutations::Security::CiConfiguration::ConfigureDependencyScanning mount_mutation ::Mutations::Security::CiConfiguration::ConfigureDependencyScanning
mount_mutation ::Mutations::Security::CiConfiguration::ConfigureContainerScanning mount_mutation ::Mutations::Security::CiConfiguration::ConfigureContainerScanning
mount_mutation ::Mutations::Security::TrainingProviderUpdate
mount_mutation ::Mutations::AuditEvents::ExternalAuditEventDestinations::Create mount_mutation ::Mutations::AuditEvents::ExternalAuditEventDestinations::Create
mount_mutation ::Mutations::AuditEvents::ExternalAuditEventDestinations::Destroy mount_mutation ::Mutations::AuditEvents::ExternalAuditEventDestinations::Destroy
mount_mutation ::Mutations::AuditEvents::ExternalAuditEventDestinations::Update mount_mutation ::Mutations::AuditEvents::ExternalAuditEventDestinations::Update
......
# frozen_string_literal: true
module Mutations
module Security
class TrainingProviderUpdate < BaseMutation
include FindsProject
graphql_name 'SecurityTrainingUpdate'
authorize :access_security_and_compliance
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Full path of the project.'
argument :provider_id, ::Types::GlobalIDType[::Security::TrainingProvider],
required: true,
description: 'ID of the provider.'
argument :is_enabled, GraphQL::Types::Boolean,
required: true,
description: 'Sets the training provider as enabled for the project.'
argument :is_primary, GraphQL::Types::Boolean,
required: false,
description: 'Sets the training provider as primary for the project.'
field :training, ::Types::Security::TrainingType,
null: true,
description: 'Represents the training entity subject to mutation.'
def resolve(project_path:, **params)
project = authorized_find!(project_path)
result = ::Security::UpdateTrainingService.new(project, params).execute
{
training: provider_data_for(result[:training]),
errors: Array(result[:message])
}
end
private
def provider_data_for(training)
return unless training.provider
training.provider.tap do |provider|
provider.assign_attributes(is_enabled: !training.destroyed?, is_primary: training.is_primary)
end
end
end
end
end
...@@ -106,6 +106,7 @@ module EE ...@@ -106,6 +106,7 @@ module EE
has_one :security_orchestration_policy_configuration, class_name: 'Security::OrchestrationPolicyConfiguration', foreign_key: :project_id, inverse_of: :project has_one :security_orchestration_policy_configuration, class_name: 'Security::OrchestrationPolicyConfiguration', foreign_key: :project_id, inverse_of: :project
has_many :security_scans, class_name: 'Security::Scan', inverse_of: :project has_many :security_scans, class_name: 'Security::Scan', inverse_of: :project
has_many :security_trainings, class_name: 'Security::Training', inverse_of: :project
elastic_index_dependant_association :issues, on_change: :visibility_level elastic_index_dependant_association :issues, on_change: :visibility_level
elastic_index_dependant_association :merge_requests, on_change: :visibility_level elastic_index_dependant_association :merge_requests, on_change: :visibility_level
......
...@@ -7,6 +7,28 @@ module Security ...@@ -7,6 +7,28 @@ module Security
belongs_to :project, optional: false belongs_to :project, optional: false
belongs_to :provider, optional: false, inverse_of: :trainings, class_name: 'Security::TrainingProvider' belongs_to :provider, optional: false, inverse_of: :trainings, class_name: 'Security::TrainingProvider'
validates :project_id, uniqueness: true, if: :is_primary? # There can be only one primary training per project
validates :is_primary, uniqueness: { scope: :project_id }, if: :is_primary?
before_destroy :prevent_deleting_primary
scope :not_including, -> (training) { where.not(id: training.id) }
private
# We prevent deleting the primary training
# if there are other trainings enabled for the project.
# Users have to select another primary before deleting trainings.
def prevent_deleting_primary
return unless is_primary? && only_training_available?
errors.add(:base, _("Can not delete primary training"))
throw :abort # rubocop:disable Cop/BanCatchThrow
end
def only_training_available?
project.security_trainings.not_including(self).exists?
end
end end
end end
# frozen_string_literal: true
module Security
class UpdateTrainingService < BaseService
def initialize(project, params)
@project = project
@params = params
end
def execute
delete? ? delete_training : upsert_training
service_response
end
private
def delete?
params[:is_enabled] == false
end
def delete_training
training&.destroy
end
def upsert_training
training.transaction do
project.security_trainings.update_all(is_primary: false) if params[:is_primary]
training.update(is_primary: params[:is_primary])
end
end
def training
@training ||= project.security_trainings.find_or_initialize_by(provider: provider) # rubocop: disable CodeReuse/ActiveRecord
end
def provider
@provider ||= GlobalID::Locator.locate(params[:provider_id])
end
def service_response
if training.errors.any?
error('Updating security training failed!', pass_back: { training: training })
else
success(training: training)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Security::TrainingProviderUpdate do
include GraphqlHelpers
describe '#resolve' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:training, refind: true) { create(:security_training) }
let(:arguments) { { project_path: project.full_path, provider_id: training.provider.id, is_enabled: true, is_primary: false } }
let(:service_result) { { status: :success, training: training } }
let(:service_object) { instance_double(::Security::UpdateTrainingService, execute: service_result) }
subject(:mutation_result) { resolve(described_class, args: arguments, ctx: { current_user: user }) }
before do
stub_licensed_features(security_dashboard: true)
allow(::Security::UpdateTrainingService).to receive(:new).and_return(service_object)
end
context 'when the user is not authorized' do
it 'does not permit the action' do
expect { mutation_result }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user is authorized' do
before do
project.add_developer(user)
end
context 'when the mutation fails' do
let(:service_result) { { status: :error, message: 'Error', training: training } }
it { is_expected.to eq({ training: training.provider, errors: ['Error'] }) }
end
context 'when the mutation succeeds' do
it { is_expected.to eq({ training: training.provider, errors: [] }) }
describe 'training' do
subject { mutation_result[:training] }
context 'when the training is deleted' do
before do
training.destroy!
end
it { is_expected.to have_attributes(is_enabled: false, is_primary: false) }
end
context 'when the training is not deleted' do
it { is_expected.to have_attributes(is_enabled: true, is_primary: false) }
end
end
end
end
end
end
...@@ -61,6 +61,7 @@ RSpec.describe Project do ...@@ -61,6 +61,7 @@ RSpec.describe Project do
it { is_expected.to have_many(:incident_management_escalation_policies).class_name('IncidentManagement::EscalationPolicy') } it { is_expected.to have_many(:incident_management_escalation_policies).class_name('IncidentManagement::EscalationPolicy') }
it { is_expected.to have_many(:security_scans) } it { is_expected.to have_many(:security_scans) }
it { is_expected.to have_many(:security_trainings) }
include_examples 'ci_cd_settings delegation' include_examples 'ci_cd_settings delegation'
......
...@@ -13,13 +13,54 @@ RSpec.describe Security::Training do ...@@ -13,13 +13,54 @@ RSpec.describe Security::Training do
context 'when the training is primary' do context 'when the training is primary' do
subject { create(:security_training, :primary) } subject { create(:security_training, :primary) }
it { is_expected.to validate_uniqueness_of(:project_id) } it { is_expected.to validate_uniqueness_of(:is_primary).scoped_to(:project_id) }
end end
context 'when the training is not primary' do context 'when the training is not primary' do
subject { create(:security_training) } subject { create(:security_training) }
it { is_expected.not_to validate_uniqueness_of(:project_id) } it { is_expected.not_to validate_uniqueness_of(:is_primary) }
end
end
end
describe '.not_including scope' do
let_it_be(:training1) { create(:security_training) }
let_it_be(:training2) { create(:security_training) }
subject { described_class.not_including(training1) }
it { is_expected.to contain_exactly(training2) }
end
describe 'deleting a record' do
subject { training.destroy } # rubocop:disable Rails/SaveBang
context 'when the record is not primary' do
let(:training) { create(:security_training) }
it { is_expected.to be_truthy }
end
context 'when the record is primary' do
let(:training) { create(:security_training, :primary) }
context 'when there is no other training enabled for the project' do
it { is_expected.to be_truthy }
end
context 'when there is another training enabled for the project' do
before do
create(:security_training, project: training.project)
end
it { is_expected.to be_falsey }
it "adds an error" do
subject
expect(training.errors.messages[:base].first).to eq('Can not delete primary training')
end
end end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Security::UpdateTrainingService do
describe '#execute' do
let_it_be(:project) { create(:project) }
let_it_be(:training_provider) { create(:security_training_provider) }
let(:is_primary) { false }
let(:params) { { provider_id: training_provider.to_global_id, is_enabled: is_enabled, is_primary: is_primary } }
let(:service_object) { described_class.new(project, params) }
subject(:update_training) { service_object.execute }
context 'when `is_enabled` argument is false' do
let(:is_enabled) { false }
context 'when the deletion fails' do
before do
allow_next_instance_of(Security::Training) do |training_instance|
allow(training_instance).to receive(:destroy) { training_instance.errors.add(:base, 'Foo') }
end
end
it { is_expected.to match({ status: :error, message: 'Updating security training failed!', training: an_instance_of(Security::Training) }) }
end
context 'when there is no training' do
it { is_expected.to match({ status: :success, training: an_instance_of(Security::Training) }) }
end
context 'when there is a training' do
let!(:training) { create(:security_training, project: project, provider: training_provider) }
it { is_expected.to eq({ status: :success, training: training }) }
it 'deletes the existing training' do
expect { update_training }.to change { project.security_trainings.count }.by(-1)
end
end
end
context 'when `is_enabled` argument is true' do
let(:is_enabled) { true }
context 'when updating the training fails' do
before do
allow_next_instance_of(Security::Training) do |training_instance|
allow(training_instance).to receive(:update) { training_instance.errors.add(:base, 'Foo') }
end
end
it { is_expected.to match({ status: :error, message: 'Updating security training failed!', training: an_instance_of(Security::Training) }) }
end
context 'when `is_primary` argument is false' do
context 'when there is no security training for the project with given provider' do
it 'creates a new security training record for the project' do
expect { update_training }.to change { project.security_trainings.where(is_primary: false).count }.by(1)
end
end
context 'when there is a security training for the project with given provider' do
let!(:existing_security_training) { create(:security_training, :primary, project: project, provider: training_provider) }
it 'updates the `is_primary` attribute of the existing security training records to false' do
expect { update_training }.to change { existing_security_training.reload.is_primary }.from(true).to(false)
end
end
end
context 'when `is_primary` argument is true' do
let(:is_primary) { true }
context 'when there is already a primary training for the project' do
let_it_be(:other_training) { create(:security_training, :primary, project: project) }
context 'when there is no security training for the project with given provider' do
it 'creates a new security training record for the project' do
expect { update_training }.to change { other_training.reload.is_primary }.to(false)
.and change { project.security_trainings.count }.by(1)
.and not_change { project.security_trainings.where(is_primary: true).count }
end
end
context 'when there is a security training for the project with given provider' do
let!(:existing_security_training) { create(:security_training, project: project, provider: training_provider) }
it 'updates the `is_primary` attribute of the security training records' do
expect { update_training }.to change { existing_security_training.reload.is_primary }.from(false).to(true)
.and change { other_training.reload.is_primary }.from(true).to(false)
end
end
end
context 'when there is not a primary training for the project' do
context 'when there is no security training for the project with given provider' do
it 'creates a new security training record for the project' do
expect { update_training }.to change { project.security_trainings.where(is_primary: true).count }.by(1)
end
end
context 'when there is a security training for the project with given provider' do
let!(:existing_security_training) { create(:security_training, project: project, provider: training_provider) }
it 'updates the `is_primary` attribute of the existing security training record to true' do
expect { update_training }.to change { existing_security_training.reload.is_primary }.from(false).to(true)
end
end
end
end
end
end
end
...@@ -6492,6 +6492,9 @@ msgstr "" ...@@ -6492,6 +6492,9 @@ msgstr ""
msgid "Can create groups:" msgid "Can create groups:"
msgstr "" msgstr ""
msgid "Can not delete primary training"
msgstr ""
msgid "Can't apply as the source branch was deleted." msgid "Can't apply as the source branch was deleted."
msgstr "" msgstr ""
......
...@@ -606,6 +606,7 @@ project: ...@@ -606,6 +606,7 @@ project:
- ci_project_mirror - ci_project_mirror
- sync_events - sync_events
- secure_files - secure_files
- security_trainings
award_emoji: award_emoji:
- awardable - awardable
- user - user
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment