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`
| <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. |
### `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`
Input type: `TerraformStateDeleteInput`
......@@ -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"`.
### `SecurityTrainingProviderID`
A `SecurityTrainingProviderID` is a global ID. It is encoded as a string.
An example `SecurityTrainingProviderID` is: `"gid://gitlab/Security::TrainingProvider/1"`.
### `SnippetID`
A `SnippetID` is a global ID. It is encoded as a string.
......@@ -91,6 +91,7 @@ module EE
mount_mutation ::Mutations::SecurityPolicy::CreateSecurityPolicyProject
mount_mutation ::Mutations::Security::CiConfiguration::ConfigureDependencyScanning
mount_mutation ::Mutations::Security::CiConfiguration::ConfigureContainerScanning
mount_mutation ::Mutations::Security::TrainingProviderUpdate
mount_mutation ::Mutations::AuditEvents::ExternalAuditEventDestinations::Create
mount_mutation ::Mutations::AuditEvents::ExternalAuditEventDestinations::Destroy
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
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_trainings, class_name: 'Security::Training', inverse_of: :project
elastic_index_dependant_association :issues, on_change: :visibility_level
elastic_index_dependant_association :merge_requests, on_change: :visibility_level
......
......@@ -7,6 +7,28 @@ module Security
belongs_to :project, optional: false
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
# 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
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_trainings) }
include_examples 'ci_cd_settings delegation'
......
......@@ -13,13 +13,54 @@ RSpec.describe Security::Training do
context 'when the training is primary' do
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
context 'when the training is not primary' do
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
......
# 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 ""
msgid "Can create groups:"
msgstr ""
msgid "Can not delete primary training"
msgstr ""
msgid "Can't apply as the source branch was deleted."
msgstr ""
......
......@@ -606,6 +606,7 @@ project:
- ci_project_mirror
- sync_events
- secure_files
- security_trainings
award_emoji:
- awardable
- 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