diff --git a/config/bounded_contexts.yml b/config/bounded_contexts.yml
index 3a1d223210f3584e2030ef64d4a58c275264e212..96b3668905f001c8cf1fe72ff1c187ff50a12b49 100644
--- a/config/bounded_contexts.yml
+++ b/config/bounded_contexts.yml
@@ -99,7 +99,7 @@ domains:
feature_categories:
- code_suggestions
- Compliance:
+ ComplianceManagement:
description:
feature_categories:
- compliance_management
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 6814542cecc0de803fe3216e1968f94ebf559885..70263531bc49d2e9c31816d440c0f73971fe73db 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -7927,6 +7927,28 @@ Input type: `projectTextReplaceInput`
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+### `Mutation.projectUpdateComplianceFrameworks`
+
+Update compliance frameworks for a project.
+
+Input type: `ProjectUpdateComplianceFrameworksInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `complianceFrameworkIds` | [`[ComplianceManagementFrameworkID!]!`](#compliancemanagementframeworkid) | IDs of the compliance framework to update for the project. |
+| `projectId` | [`ProjectID!`](#projectid) | ID of the project to change the compliance framework of. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+| `project` | [`Project`](#project) | Project after mutation. |
+
### `Mutation.prometheusIntegrationCreate`
Input type: `PrometheusIntegrationCreateInput`
diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md
index 0489154fe619f3a98bdf4870c02a2cf67fb5a1e3..11593e806a634e70102829ed06c1a4c27c53888a 100644
--- a/doc/user/compliance/audit_event_types.md
+++ b/doc/user/compliance/audit_event_types.md
@@ -134,8 +134,10 @@ Audit event types belong to the following product categories.
| [`allow_committer_approval_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/102256) | Event triggered on updating prevent merge request approval from committers from group merge request setting| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.6](https://gitlab.com/gitlab-org/gitlab/-/issues/373949) | Group |
| [`allow_overrides_to_approver_list_per_merge_request_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/102256) | Event triggered on updating prevent users from modifying MR approval rules in merge requests from group merge request setting| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.6](https://gitlab.com/gitlab-org/gitlab/-/issues/373949) | Group |
| [`audit_events_streaming_headers_update`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/92068) | Triggered when a streaming header for audit events is updated| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.3](https://gitlab.com/gitlab-org/gitlab/-/issues/366350) | Group |
+| [`compliance_framework_added`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/157893) | Triggered when a framework label is added to a project.| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.2](https://gitlab.com/gitlab-org/gitlab/-/issues/464160) | Project |
| [`compliance_framework_deleted`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65343) | Triggered when a framework gets removed from a project| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [14.1](https://gitlab.com/gitlab-org/gitlab/-/issues/329362) | Project |
| [`compliance_framework_id_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/94711) | audit when compliance framework ID is updated| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.7](https://gitlab.com/gitlab-org/gitlab/-/issues/369310) | Project |
+| [`compliance_framework_removed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/157893) | Triggered when a framework label is removed from a project.| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.2](https://gitlab.com/gitlab-org/gitlab/-/issues/464160) | Project |
| [`create_compliance_framework`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74292) | Triggered on successful compliance framework creation| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [14.6](https://gitlab.com/gitlab-org/gitlab/-/issues/340649) | Group |
| [`create_status_check`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84624) | Event triggered when an external status check is created| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/355805) | Project |
| [`delete_status_check`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84624) | Event triggered when an external status check is deleted| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/355805) | Project |
diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb
index 4974f9a66642c2f5fb4fbb29ce973a368391f784..0a41d135908924b484d836634d30825488cb50a5 100644
--- a/ee/app/graphql/ee/types/mutation_type.rb
+++ b/ee/app/graphql/ee/types/mutation_type.rb
@@ -215,6 +215,7 @@ def self.authorization_scopes
mount_mutation ::Mutations::Ai::SelfHostedModels::Delete,
alpha: { milestone: '17.2' }
mount_mutation ::Mutations::MergeTrains::Cars::Delete, alpha: { milestone: '17.2' }
+ mount_mutation ::Mutations::Projects::UpdateComplianceFrameworks
prepend(Types::DeprecatedMutations)
end
diff --git a/ee/app/graphql/mutations/projects/update_compliance_frameworks.rb b/ee/app/graphql/mutations/projects/update_compliance_frameworks.rb
new file mode 100644
index 0000000000000000000000000000000000000000..76207d31ea94ddf2c925abfdca7272e43ccc2d61
--- /dev/null
+++ b/ee/app/graphql/mutations/projects/update_compliance_frameworks.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Projects
+ class UpdateComplianceFrameworks < BaseMutation
+ graphql_name 'ProjectUpdateComplianceFrameworks'
+ description 'Update compliance frameworks for a project.'
+
+ MAX_FRAMEWORKS = 10
+
+ authorize :admin_compliance_framework
+
+ argument :project_id, Types::GlobalIDType[::Project],
+ required: true,
+ description: 'ID of the project to change the compliance framework of.'
+
+ argument :compliance_framework_ids, [Types::GlobalIDType[::ComplianceManagement::Framework]],
+ required: true,
+ description: 'IDs of the compliance framework to update for the project.'
+
+ field :project,
+ Types::ProjectType,
+ null: true,
+ description: "Project after mutation."
+
+ def ready?(**args)
+ if args[:compliance_framework_ids].size > MAX_FRAMEWORKS
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ format(
+ _('No more than %{max_frameworks} compliance frameworks can be updated at the same time.'),
+ max_frameworks: MAX_FRAMEWORKS
+ )
+ end
+
+ super
+ end
+
+ def resolve(project_id:, compliance_framework_ids:)
+ project = GitlabSchema.find_by_gid(project_id).sync
+
+ authorize!(project)
+
+ compliance_frameworks = compliance_frameworks(compliance_framework_ids)
+
+ service_response = ::ComplianceManagement::Frameworks::UpdateProjectService
+ .new(project, current_user, compliance_frameworks)
+ .execute
+
+ { project: project, errors: errors_on_object(project) + service_response.errors }
+ end
+
+ private
+
+ def compliance_frameworks(compliance_framework_ids)
+ ids = GitlabSchema.parse_gids(compliance_framework_ids).map(&:model_id).map(&:to_i).uniq
+ frameworks = ::ComplianceManagement::Framework.id_in(ids)
+
+ if frameworks.length != ids.length
+ raise Gitlab::Graphql::Errors::ArgumentError, format(_("Framework id(s) %{missing_ids} are invalid."),
+ missing_ids: (ids - frameworks.pluck(:id))) # rubocop: disable CodeReuse/ActiveRecord -- Using pluck only
+ end
+
+ frameworks
+ end
+ end
+ end
+end
diff --git a/ee/app/models/compliance_management/compliance_framework/project_settings.rb b/ee/app/models/compliance_management/compliance_framework/project_settings.rb
index d0e8f1937c3bfd505c7aeedd5eea61ad293c4624..8919facd6d9f55dd3f66ef79ffcec6052ca04958 100644
--- a/ee/app/models/compliance_management/compliance_framework/project_settings.rb
+++ b/ee/app/models/compliance_management/compliance_framework/project_settings.rb
@@ -4,7 +4,6 @@ module ComplianceManagement
module ComplianceFramework
class ProjectSettings < ApplicationRecord
self.table_name = 'project_compliance_framework_settings'
- self.primary_key = :project_id
belongs_to :project
belongs_to :compliance_management_framework, class_name: "ComplianceManagement::Framework", foreign_key: :framework_id
@@ -13,6 +12,8 @@ class ProjectSettings < ApplicationRecord
delegate :full_path, to: :project
+ scope :by_framework_and_project, ->(project_id, framework_id) { where(project_id: project_id, framework_id: framework_id) }
+
def self.find_or_create_by_project(project, framework)
find_or_initialize_by(project: project).tap do |setting|
setting.framework_id = framework.id
diff --git a/ee/app/services/compliance_management/frameworks/update_project_service.rb b/ee/app/services/compliance_management/frameworks/update_project_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9a8afcaef0ed62d2388c43eea401657326dc08b8
--- /dev/null
+++ b/ee/app/services/compliance_management/frameworks/update_project_service.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+module ComplianceManagement
+ module Frameworks
+ class UpdateProjectService < BaseService
+ def initialize(project, current_user, frameworks)
+ @project = project
+ @current_user = current_user
+ @frameworks = frameworks
+ end
+
+ def execute
+ return error unless permitted?
+
+ old_frameworks = project.compliance_management_frameworks
+
+ frameworks_to_be_added = frameworks - old_frameworks
+ frameworks_to_be_removed = old_frameworks - frameworks
+
+ frameworks_to_be_added.each do |framework|
+ response = add_framework_setting(framework)
+ return response if response.respond_to?(:error?) && response.error?
+ end
+
+ frameworks_to_be_removed.each do |framework|
+ response = remove_framework_setting(framework)
+ return response if response.respond_to?(:error?) && response.error?
+ end
+
+ success
+ end
+
+ private
+
+ attr_reader :project, :current_user, :frameworks
+
+ def add_framework_setting(framework)
+ return unless project.root_namespace.self_and_descendants_ids.include?(framework.namespace_id)
+
+ framework_project_setting = ComplianceManagement::ComplianceFramework::ProjectSettings.new(project: project,
+ compliance_management_framework: framework)
+
+ unless framework_project_setting.save
+ error_message = "Error while adding framework #{framework.name}. Errors: " \
+ "#{framework_project_setting.errors.full_messages.to_sentence}"
+
+ return error(error_message)
+ end
+
+ track_event(::Projects::ComplianceFrameworkChangedEvent::EVENT_TYPES[:added], framework)
+ end
+
+ def remove_framework_setting(framework)
+ framework_project_setting = ComplianceManagement::ComplianceFramework::ProjectSettings
+ .by_framework_and_project(project.id, framework.id).first
+
+ unless framework_project_setting.destroy
+ error_message = "Error while removing framework #{framework.name}. Errors: " \
+ "#{framework_project_setting.errors.full_messages.to_sentence}"
+ return error(error_message)
+ end
+
+ track_event(::Projects::ComplianceFrameworkChangedEvent::EVENT_TYPES[:removed], framework)
+ end
+
+ def permitted?
+ can?(current_user, :admin_compliance_framework, project)
+ end
+
+ def error(message = "Failed to assign the framework to the project")
+ ServiceResponse.error(message: _(message))
+ end
+
+ def success
+ ServiceResponse.success
+ end
+
+ def track_event(event_type, framework)
+ publish_event(event_type, framework)
+ audit_event(event_type, framework)
+ end
+
+ def audit_event(event_type, framework)
+ audit_context = {
+ name: "compliance_framework_#{event_type}",
+ author: current_user,
+ scope: project,
+ target: framework,
+ message: "#{event_type.capitalize} framework label #{framework.name}",
+ additional_details: {
+ framework: {
+ id: framework.id,
+ name: framework.name
+ }
+ }
+ }
+
+ ::Gitlab::Audit::Auditor.audit(audit_context)
+ end
+
+ def publish_event(event_type, framework)
+ event = ::Projects::ComplianceFrameworkChangedEvent.new(data: {
+ project_id: project.id,
+ compliance_framework_id: framework.id,
+ event_type: event_type
+ })
+
+ ::Gitlab::EventStore.publish(event)
+ end
+ end
+ end
+end
diff --git a/ee/config/audit_events/types/compliance_framework_added.yml b/ee/config/audit_events/types/compliance_framework_added.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ec18d4f6d49a269b27bde2a887b4038ccb4f8d75
--- /dev/null
+++ b/ee/config/audit_events/types/compliance_framework_added.yml
@@ -0,0 +1,10 @@
+---
+name: compliance_framework_added
+description: Triggered when a framework label is added to a project.
+introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/464160
+introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/157893
+milestone: '17.2'
+feature_category: compliance_management
+saved_to_database: true
+streamed: true
+scope: [Project]
diff --git a/ee/config/audit_events/types/compliance_framework_removed.yml b/ee/config/audit_events/types/compliance_framework_removed.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cc1a168c75c0f06a4e811c9d78229c591bbad315
--- /dev/null
+++ b/ee/config/audit_events/types/compliance_framework_removed.yml
@@ -0,0 +1,10 @@
+---
+name: compliance_framework_removed
+description: Triggered when a framework label is removed from a project.
+introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/464160
+introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/157893
+milestone: '17.2'
+feature_category: compliance_management
+saved_to_database: true
+streamed: true
+scope: [Project]
diff --git a/ee/spec/graphql/mutations/projects/update_compliance_frameworks_spec.rb b/ee/spec/graphql/mutations/projects/update_compliance_frameworks_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..75b06bc7ebb0caa05a67c0eabe805951dfa45415
--- /dev/null
+++ b/ee/spec/graphql/mutations/projects/update_compliance_frameworks_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Projects::UpdateComplianceFrameworks, feature_category: :compliance_management do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:framework) { create(:compliance_framework, :sox, namespace: group) }
+ let_it_be(:project) { create(:project, :repository, :with_compliance_framework, group: group) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:existing_framework) { project.compliance_management_frameworks.first }
+
+ let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
+
+ subject do
+ mutation.resolve(project_id: GitlabSchema.id_from_object(project),
+ compliance_framework_ids: [GitlabSchema.id_from_object(existing_framework),
+ GitlabSchema.id_from_object(framework)])
+ end
+
+ shared_examples "the user cannot update the project's compliance framework" do
+ it 'raises an exception' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ shared_examples "the user can update compliance frameworks of the project" do
+ it 'updates the compliance frameworks to the project' do
+ expect { subject }.to change { project.reload.compliance_management_frameworks }
+ .from([existing_framework]).to([framework, existing_framework])
+ end
+
+ it 'returns the project that was updated' do
+ expect(subject).to include(project: project)
+ end
+ end
+
+ describe '#resolve' do
+ context 'when feature is licensed' do
+ before do
+ stub_licensed_features(compliance_framework: true)
+ end
+
+ context 'when current_user is a project maintainer' do
+ before_all do
+ project.add_maintainer(current_user)
+ end
+
+ it_behaves_like "the user cannot update the project's compliance framework"
+ end
+
+ context 'when current_user is a project owner' do
+ before_all do
+ group.add_owner(current_user)
+ project.add_owner(current_user)
+ end
+
+ it_behaves_like "the user can update compliance frameworks of the project"
+
+ context 'when framework id is invalid' do
+ subject(:resolve) do
+ mutation.resolve(project_id: GitlabSchema.id_from_object(project),
+ compliance_framework_ids: ["gid://gitlab/ComplianceManagement::Framework/#{non_existing_record_id}"])
+ end
+
+ it 'returns Argument error' do
+ expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ArgumentError,
+ format(_("Framework id(s) [%{record_id}] are invalid."), record_id: non_existing_record_id))
+ end
+ end
+ end
+ end
+
+ context 'when feature is unlicensed' do
+ before do
+ stub_licensed_features(compliance_framework: false)
+ end
+
+ it_behaves_like "the user cannot update the project's compliance framework"
+ end
+ end
+end
diff --git a/ee/spec/models/compliance_management/compliance_framework/project_settings_spec.rb b/ee/spec/models/compliance_management/compliance_framework/project_settings_spec.rb
index b8eb96d66c71635fe51493be5c492bfcb841f1cd..799189636e47afefce515fc9942c9404d2fbc2f9 100644
--- a/ee/spec/models/compliance_management/compliance_framework/project_settings_spec.rb
+++ b/ee/spec/models/compliance_management/compliance_framework/project_settings_spec.rb
@@ -40,6 +40,21 @@
end
end
+ describe '.by_framework_and_project' do
+ let_it_be(:framework1) do
+ create(:compliance_framework, namespace: project.group.root_ancestor, name: 'framework1')
+ end
+
+ let_it_be(:setting) do
+ create(:compliance_framework_project_setting, project: project, compliance_management_framework: framework1)
+ end
+
+ it 'returns the setting' do
+ expect(described_class.by_framework_and_project(project.id, framework1.id))
+ .to eq([setting])
+ end
+ end
+
describe '.find_or_create_by_project' do
let_it_be(:framework) { create(:compliance_framework, namespace: project.group.root_ancestor) }
diff --git a/ee/spec/requests/api/graphql/mutations/projects/update_compliance_frameworks_spec.rb b/ee/spec/requests/api/graphql/mutations/projects/update_compliance_frameworks_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a5caef75b7e1cdf51e5019e99999d98376773489
--- /dev/null
+++ b/ee/spec/requests/api/graphql/mutations/projects/update_compliance_frameworks_spec.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Update project compliance framework', feature_category: :compliance_management do
+ include GraphqlHelpers
+
+ let_it_be(:namespace) { create(:group) }
+ let_it_be(:project) { create(:project, namespace: namespace) }
+ let_it_be(:framework1) { create(:compliance_framework, namespace: namespace) }
+ let_it_be(:framework2) { create(:compliance_framework, :sox, namespace: namespace) }
+ let_it_be(:current_user) { create(:user, owner_of: namespace) }
+
+ let(:variables) do
+ {
+ project_id: GitlabSchema.id_from_object(project).to_s,
+ compliance_framework_ids: [
+ GitlabSchema.id_from_object(framework1).to_s,
+ GitlabSchema.id_from_object(framework2).to_s,
+ GitlabSchema.id_from_object(framework1).to_s # Adding same framework twice for checking it only gets added once
+ ]
+ }
+ end
+
+ let(:mutation) do
+ graphql_mutation(:project_update_compliance_frameworks, variables) do
+ <<~QL
+ errors
+ project {
+ complianceFrameworks {
+ nodes {
+ name
+ }
+ }
+ }
+ QL
+ end
+ end
+
+ def mutation_response
+ graphql_mutation_response(:project_update_compliance_frameworks)
+ end
+
+ describe '#resolve' do
+ context 'when feature is not available' do
+ before do
+ stub_licensed_features(compliance_framework: false)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors',
+ errors: ['The resource that you are attempting to access does not exist ' \
+ 'or you don\'t have permission to perform this action']
+ end
+
+ context 'when feature is available' do
+ before do
+ stub_licensed_features(compliance_framework: true)
+ end
+
+ context 'when there is no framework associated with the project' do
+ it_behaves_like 'a working GraphQL mutation'
+
+ it 'adds the frameworks' do
+ expect { post_graphql_mutation(mutation, current_user: current_user) }.to change {
+ project.reload.compliance_management_frameworks
+ }.from([]).to([framework1, framework2])
+ end
+ end
+
+ context 'when there is a framework associated with the project' do
+ let_it_be(:existing_framework) { create(:compliance_framework, namespace: namespace, name: 'framework3') }
+
+ before do
+ create(:compliance_framework_project_setting, project: project,
+ compliance_management_framework: existing_framework)
+ end
+
+ it 'adds the new frameworks' do
+ expect { post_graphql_mutation(mutation, current_user: current_user) }.to change {
+ project.reload.compliance_management_frameworks
+ }.from([existing_framework]).to([framework1, framework2])
+ end
+ end
+
+ context 'when there are more than 20 frameworks' do
+ let(:framework_ids) { (1..21).map { |num| "gid://gitlab/ComplianceManagement::Framework/#{num}" } }
+
+ let(:variables) do
+ {
+ project_id: GitlabSchema.id_from_object(project).to_s,
+ compliance_framework_ids: framework_ids
+ }
+ end
+
+ it 'return error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect_graphql_errors_to_include('No more than 10 compliance frameworks can be updated at the same time.')
+ end
+ end
+ end
+ end
+end
diff --git a/ee/spec/services/compliance_management/frameworks/update_project_service_spec.rb b/ee/spec/services/compliance_management/frameworks/update_project_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3a2a58d10db11000a1dc1196c7b198cc2e292eb7
--- /dev/null
+++ b/ee/spec/services/compliance_management/frameworks/update_project_service_spec.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ComplianceManagement::Frameworks::UpdateProjectService, feature_category: :compliance_management do
+ describe '#execute' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be_with_reload(:project) { create(:project, group: group) }
+ let_it_be(:framework1) { create(:compliance_framework, name: 'framework1', namespace: group) }
+ let_it_be(:framework2) { create(:compliance_framework, name: 'framework2', namespace: group) }
+ let_it_be(:framework3) { create(:compliance_framework, name: 'framework3', namespace: group) }
+ let_it_be(:framework4) { create(:compliance_framework, name: 'framework4', namespace: group) }
+
+ let(:frameworks) { [framework1, framework2] }
+
+ let(:service) { described_class.new(project, user, frameworks) }
+
+ subject(:update_framework) { service.execute }
+
+ context 'when compliance framework feature is available' do
+ before do
+ allow(service).to receive(:can?).with(user, :admin_compliance_framework, project).and_return(true)
+ end
+
+ context 'when the input parameters are correct' do
+ context 'when project has no framework associated with it' do
+ it 'adds the framework association' do
+ expect { update_framework }.to change {
+ project.reload.compliance_management_frameworks
+ }.from([]).to([framework1, framework2])
+ end
+
+ it 'logs audit events' do
+ expect { update_framework }.to change {
+ AuditEvent.where("details LIKE ?", "%compliance_framework_added%").count
+ }.by(2)
+ end
+
+ it 'publishes Projects::ComplianceFrameworkChangedEvent' do
+ expect(::Gitlab::EventStore).to receive(:publish)
+ .with(an_instance_of(::Projects::ComplianceFrameworkChangedEvent)).twice
+
+ update_framework
+ end
+ end
+
+ context 'when project already has some frameworks associated with it' do
+ before do
+ create(:compliance_framework_project_setting, project: project, compliance_management_framework: framework2)
+ create(:compliance_framework_project_setting, project: project, compliance_management_framework: framework3)
+ create(:compliance_framework_project_setting, project: project, compliance_management_framework: framework4)
+ end
+
+ it 'adds and removes framework associations' do
+ expect { update_framework }.to change {
+ project.reload.compliance_management_frameworks
+ }.from([framework2, framework3, framework4]).to([framework1, framework2])
+ end
+
+ it 'logs audit events' do
+ expect { update_framework }.to change {
+ AuditEvent.where("details LIKE ?", "%compliance_framework_added%").count
+ }.by(1).and change {
+ AuditEvent.where("details LIKE ?", "%compliance_framework_removed%").count
+ }.by(2)
+ end
+
+ it 'publishes Projects::ComplianceFrameworkChangedEvent' do
+ expect(::Gitlab::EventStore).to receive(:publish)
+ .with(an_instance_of(::Projects::ComplianceFrameworkChangedEvent)).exactly(3).times
+
+ update_framework
+ end
+ end
+
+ context 'when there is an error while saving framework project setting' do
+ it 'returns error' do
+ save_error_message = 'Not able to save project settings for compliance framework'
+ error_message = "Error while adding framework #{frameworks.first.name}. Errors: #{save_error_message}"
+
+ allow_next_instance_of(ComplianceManagement::ComplianceFramework::ProjectSettings) do |instance|
+ allow(instance).to receive(:save).and_return(false)
+
+ errors = ActiveModel::Errors.new(instance).tap { |e| e.add(:base, save_error_message) }
+ allow(instance).to receive(:errors).and_return(errors)
+ end
+
+ expect(update_framework.errors).to eq([error_message])
+ end
+ end
+
+ context 'when there is an error while deleting a framework project setting' do
+ before do
+ create(:compliance_framework_project_setting, project: project, compliance_management_framework: framework3)
+ end
+
+ let(:frameworks) { [] }
+
+ it 'returns error' do
+ save_error_message = 'Not able to delete project settings for compliance framework'
+ error_message = "Error while removing framework framework3. Errors: #{save_error_message}"
+
+ allow_next_found_instance_of(ComplianceManagement::ComplianceFramework::ProjectSettings) do |instance|
+ allow(instance).to receive(:destroy).and_return(false)
+
+ errors = ActiveModel::Errors.new(instance).tap { |e| e.add(:base, save_error_message) }
+ allow(instance).to receive(:errors).and_return(errors)
+ end
+
+ expect(update_framework.errors).to eq([error_message])
+ end
+ end
+ end
+ end
+
+ context 'when compliance framework feature is unavailable' do
+ before do
+ stub_licensed_features(compliance_framework: false)
+ end
+
+ before_all do
+ group.add_owner(user)
+ end
+
+ it 'returns an error response' do
+ response = update_framework
+
+ expect(response).to be_error
+ expect(response.message).to eq('Failed to assign the framework to the project')
+ end
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 8655c2c9c327a67e5911d5b215e668a36e32606c..ca15b383fd59cea44dd0d15e0c75b70e687f6c91 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -22887,6 +22887,9 @@ msgstr ""
msgid "ForksDivergence|View merge request"
msgstr ""
+msgid "Framework id(s) %{missing_ids} are invalid."
+msgstr ""
+
msgid "Framework successfully deleted"
msgstr ""
@@ -34972,6 +34975,9 @@ msgstr ""
msgid "No more seats in subscription"
msgstr ""
+msgid "No more than %{max_frameworks} compliance frameworks can be updated at the same time."
+msgstr ""
+
msgid "No more than %{max_issues} issues can be updated at the same time"
msgstr ""