diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index b62d46b4a039cedfd14e3015ebd06c3ff10ce50f..ea40b5b1d50e563adec5bd25117c73e7cd0c7f37 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -1034,6 +1034,27 @@ Input type: `ConfigureSecretDetectionInput`
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `successPath` | [`String`](#string) | Redirect path to use when the response is successful. |
+### `Mutation.corpusCreate`
+
+Available only when feature flag `corpus_management` is enabled. This flag is disabled by default, because the feature is experimental and is subject to change without notice.
+
+Input type: `CorpusCreateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `fullPath` | [`ID!`](#id) | Project the corpus belongs to. |
+| `packageId` | [`PackagesPackageID!`](#packagespackageid) | ID of the corpus package. |
+
+#### 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. |
+
### `Mutation.createAlertIssue`
Input type: `CreateAlertIssueInput`
diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb
index aa875576b7c4e1a296649d5323f701cabf522beb..de99f6289cde9571f5ba2f8b0d83f8cd444bbed9 100644
--- a/ee/app/graphql/ee/types/mutation_type.rb
+++ b/ee/app/graphql/ee/types/mutation_type.rb
@@ -78,6 +78,7 @@ module MutationType
mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Update
mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Destroy
mount_mutation ::Mutations::AppSec::Fuzzing::API::CiConfiguration::Create
+ mount_mutation ::Mutations::AppSec::Fuzzing::Coverage::Corpus::Create, feature_flag: :corpus_management
mount_mutation ::Mutations::Projects::SetComplianceFramework
mount_mutation ::Mutations::SecurityPolicy::CommitScanExecutionPolicy
mount_mutation ::Mutations::SecurityPolicy::AssignSecurityPolicyProject
diff --git a/ee/app/graphql/mutations/app_sec/fuzzing/coverage/corpus/create.rb b/ee/app/graphql/mutations/app_sec/fuzzing/coverage/corpus/create.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ad5c8229c6d7b724e8f7260e386ece78a038000d
--- /dev/null
+++ b/ee/app/graphql/mutations/app_sec/fuzzing/coverage/corpus/create.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AppSec::Fuzzing::Coverage
+ module Corpus
+ class Create < BaseMutation
+ include FindsProject
+
+ graphql_name 'CorpusCreate'
+
+ authorize :create_coverage_fuzzing_corpus
+
+ argument :package_id, Types::GlobalIDType[::Packages::Package],
+ required: true,
+ description: 'ID of the corpus package.'
+
+ argument :full_path, GraphQL::Types::ID,
+ required: true,
+ description: 'Project the corpus belongs to.'
+
+ def resolve(full_path:, package_id:)
+ project = authorized_find!(full_path)
+
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless allowed?(project)
+
+ response = ::AppSec::Fuzzing::Coverage::Corpuses::CreateService.new(
+ project: project,
+ current_user: current_user,
+ params: {
+ package_id: Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(package_id))&.id
+ }
+ ).execute
+
+ return { errors: response.errors } if response.error?
+
+ build_response(response.payload)
+ end
+
+ private
+
+ def allowed?(project)
+ Feature.enabled?(:corpus_management, project, default_enabled: :yaml)
+ end
+
+ def build_response(payload)
+ {
+ errors: [],
+ corpus: payload.fetch(:corpus)
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/ee/app/models/app_sec/fuzzing/coverage/corpus.rb b/ee/app/models/app_sec/fuzzing/coverage/corpus.rb
index bea6ca907cadcea9555e0e6518463a5d46b02f5d..04cdf4cb2d553b3a071df197736c8acf7a1e248d 100644
--- a/ee/app/models/app_sec/fuzzing/coverage/corpus.rb
+++ b/ee/app/models/app_sec/fuzzing/coverage/corpus.rb
@@ -9,6 +9,20 @@ class Corpus < ApplicationRecord
belongs_to :package, class_name: 'Packages::Package'
belongs_to :user, optional: true
belongs_to :project
+
+ validate :project_same_as_package_project
+
+ def audit_details
+ user&.name
+ end
+
+ private
+
+ def project_same_as_package_project
+ if package && package.project_id != project_id
+ errors.add(:package_id, 'should belong to the associated project')
+ end
+ end
end
end
end
diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb
index 8856a545351f09b43ca0b8c4b248894b41f501be..6f0a10c3bedcfe4916e9bc48865593462e439427 100644
--- a/ee/app/policies/ee/project_policy.rb
+++ b/ee/app/policies/ee/project_policy.rb
@@ -210,6 +210,7 @@ module ProjectPolicy
rule { coverage_fuzzing_enabled & can?(:developer_access) }.policy do
enable :read_coverage_fuzzing
+ enable :create_coverage_fuzzing_corpus
end
rule { on_demand_scans_enabled & can?(:developer_access) }.policy do
diff --git a/ee/app/services/app_sec/fuzzing/coverage/corpuses/create_service.rb b/ee/app/services/app_sec/fuzzing/coverage/corpuses/create_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a29f7ef61458d762b184c39566a165260097dd3a
--- /dev/null
+++ b/ee/app/services/app_sec/fuzzing/coverage/corpuses/create_service.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module AppSec
+ module Fuzzing
+ module Coverage
+ module Corpuses
+ class CreateService < BaseProjectService
+ def execute
+ return ServiceResponse.error(message: 'Insufficient permissions') unless allowed?
+
+ corpus = AppSec::Fuzzing::Coverage::Corpus.new(
+ project: project,
+ user: current_user,
+ package_id: params.fetch(:package_id)
+ )
+
+ if corpus.save
+ create_audit_event(corpus)
+
+ return ServiceResponse.success(
+ payload: {
+ corpus: corpus
+ }
+ )
+ end
+
+ ServiceResponse.error(message: corpus.errors.full_messages)
+ rescue KeyError => err
+ ServiceResponse.error(message: err.message.capitalize)
+ end
+
+ private
+
+ def allowed?
+ project.licensed_feature_available?(:coverage_fuzzing)
+ end
+
+ def create_audit_event(corpus)
+ ::Gitlab::Audit::Auditor.audit(
+ name: 'coverage_fuzzing_corpus_create',
+ author: current_user,
+ scope: project,
+ target: corpus,
+ message: 'Added Coverage Fuzzing Corpus'
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/spec/factories/app_sec/fuzzing/coverage/corpuses.rb b/ee/spec/factories/app_sec/fuzzing/coverage/corpuses.rb
index 9fad8aa946fd5283f9812ee9204ed50f51699b2c..90bb819ff117402ce249aa1722fd44a279da00ab 100644
--- a/ee/spec/factories/app_sec/fuzzing/coverage/corpuses.rb
+++ b/ee/spec/factories/app_sec/fuzzing/coverage/corpuses.rb
@@ -3,7 +3,7 @@
FactoryBot.define do
factory :corpus, class: 'AppSec::Fuzzing::Coverage::Corpus' do
user
- package
project
+ package { association :package, project: project }
end
end
diff --git a/ee/spec/graphql/mutations/app_sec/fuzzing/coverage/corpus/create_spec.rb b/ee/spec/graphql/mutations/app_sec/fuzzing/coverage/corpus/create_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..56baedc84a3fb25daec88a882f988a2b30e83bd1
--- /dev/null
+++ b/ee/spec/graphql/mutations/app_sec/fuzzing/coverage/corpus/create_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::AppSec::Fuzzing::Coverage::Corpus::Create do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user, developer_projects: [project] ) }
+ let_it_be(:package) { create(:package, project: project, creator: developer) }
+
+ let(:corpus) { AppSec::Fuzzing::Coverage::Corpus.find_by(user: developer, project: project) }
+
+ let(:mutation) { described_class.new(object: nil, context: { current_user: developer }, field: nil) }
+
+ before do
+ stub_licensed_features(coverage_fuzzing: true)
+ end
+
+ specify { expect(described_class).to require_graphql_authorizations(:create_coverage_fuzzing_corpus) }
+
+ describe '#resolve' do
+ subject(:resolve) do
+ mutation.resolve(
+ full_path: project.full_path,
+ package_id: package.to_global_id
+ )
+ end
+
+ context 'when the feature is licensed' do
+ context 'when the user can create a corpus' do
+ context 'when corpus_management feature is enabled' do
+ before do
+ stub_feature_flags(corpus_management: true)
+ end
+
+ it 'returns the corpus' do
+ expect(resolve[:corpus]).to eq(corpus)
+ end
+ end
+
+ context 'when corpus_management feature is disabled' do
+ before do
+ stub_feature_flags(corpus_management: false)
+ end
+
+ it 'raises the resource not available error' do
+ expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/spec/models/app_sec/fuzzing/coverage/corpus_spec.rb b/ee/spec/models/app_sec/fuzzing/coverage/corpus_spec.rb
index 79b7e34c489b96455bc7284cc29d51c59af9c1c5..512e124aa84f83b986dc6f776086d45f67e6d2c1 100644
--- a/ee/spec/models/app_sec/fuzzing/coverage/corpus_spec.rb
+++ b/ee/spec/models/app_sec/fuzzing/coverage/corpus_spec.rb
@@ -12,4 +12,16 @@
it { is_expected.to belong_to(:user).optional }
it { is_expected.to belong_to(:project) }
end
+
+ describe 'validate' do
+ describe 'project_same_as_package_project' do
+ let(:package_2) { create(:package) }
+
+ subject(:corpus) { build(:corpus, package: package_2) }
+
+ it 'raises the error on adding the package of a different project' do
+ expect { corpus.save! }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Package should belong to the associated project')
+ end
+ end
+ end
end
diff --git a/ee/spec/services/app_sec/fuzzing/coverage/corpuses/create_service_spec.rb b/ee/spec/services/app_sec/fuzzing/coverage/corpuses/create_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f4f8f9d833f1c3498e6ede233c6ac425de17ae4a
--- /dev/null
+++ b/ee/spec/services/app_sec/fuzzing/coverage/corpuses/create_service_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AppSec::Fuzzing::Coverage::Corpuses::CreateService do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user, developer_projects: [project] ) }
+ let_it_be(:package) { create(:package, project: project, creator: developer) }
+
+ let_it_be(:default_params) do
+ {
+ package_id: package.id
+ }
+ end
+
+ let(:params) { default_params }
+
+ subject(:service_result) { described_class.new(project: project, current_user: developer, params: params).execute }
+
+ describe 'execute' do
+ before do
+ stub_licensed_features(coverage_fuzzing: coverage_fuzzing_enabled?)
+ end
+
+ context 'when the feature coverage_fuzzing is not available' do
+ let(:coverage_fuzzing_enabled?) { false }
+
+ it 'communicates failure', :aggregate_failures do
+ expect(service_result.status).to eq(:error)
+ expect(service_result.message).to eq('Insufficient permissions')
+ end
+ end
+
+ context 'when the feature coverage_fuzzing is enabled' do
+ let(:coverage_fuzzing_enabled?) { true }
+
+ it 'communicates success' do
+ expect(service_result.status).to eq(:success)
+ end
+
+ it 'creates a corpus' do
+ expect { service_result }.to change { AppSec::Fuzzing::Coverage::Corpus.count }.by(1)
+ end
+
+ it 'audits the creation', :aggregate_failures do
+ corpus = service_result.payload[:corpus]
+
+ audit_event = AuditEvent.find_by(target_id: corpus.id)
+
+ expect(audit_event.author).to eq(developer)
+ expect(audit_event.entity).to eq(project)
+ expect(audit_event.target_id).to eq(corpus.id)
+ expect(audit_event.target_type).to eq('AppSec::Fuzzing::Coverage::Corpus')
+ expect(audit_event.target_details).to eq(developer.name)
+ expect(audit_event.details).to eq({
+ author_name: developer.name,
+ custom_message: 'Added Coverage Fuzzing Corpus',
+ target_id: corpus.id,
+ target_type: 'AppSec::Fuzzing::Coverage::Corpus',
+ target_details: developer.name
+ })
+ end
+
+ context 'when a param is missing' do
+ let(:params) { default_params.except(:package_id) }
+
+ it 'communicates failure', :aggregate_failures do
+ expect(service_result.status).to eq(:error)
+ expect(service_result.message).to eq('Key not found: :package_id')
+ end
+ end
+
+ context 'when a param is incorrect' do
+ let(:package_2) { create(:package) }
+ let(:params) { { package_id: package_2.id } }
+
+ it 'communicates failure', :aggregate_failures do
+ allow_next_instance_of(AppSec::Fuzzing::Coverage::Corpus) do |service|
+ allow(service).to receive(:save).and_return(false)
+ allow(service).to receive_message_chain(:errors, :full_messages)
+ .and_return(['error message'])
+ end
+
+ expect(service_result.status).to eq(:error)
+ expect(service_result.message).to match_array(['error message'])
+ end
+ end
+ end
+ end
+end