Commit 3da460b8 authored by Ash McKenzie's avatar Ash McKenzie

Merge branch 'add-ability-to-update-dast-site-profile-227252' into 'master'

Add ability to  update DastSiteProfile with GraphQL

See merge request gitlab-org/gitlab!38662
parents 63a50648 07dfdd9d
......@@ -2907,6 +2907,56 @@ type DastSiteProfilePermissions {
createOnDemandDastScan: Boolean!
}
"""
Autogenerated input type of DastSiteProfileUpdate
"""
input DastSiteProfileUpdateInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The project the site profile belongs to.
"""
fullPath: ID!
"""
ID of the site profile to be updated.
"""
id: DastSiteProfileID!
"""
The name of the site profile.
"""
profileName: String!
"""
The URL of the target to be scanned.
"""
targetUrl: String
}
"""
Autogenerated return type of DastSiteProfileUpdate
"""
type DastSiteProfileUpdatePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
ID of the site profile.
"""
id: DastSiteProfileID
}
enum DastSiteProfileValidationStatusEnum {
"""
Site validation process finished but failed
......@@ -9349,6 +9399,7 @@ type Mutation {
dastScannerProfileCreate(input: DastScannerProfileCreateInput!): DastScannerProfileCreatePayload
dastSiteProfileCreate(input: DastSiteProfileCreateInput!): DastSiteProfileCreatePayload
dastSiteProfileDelete(input: DastSiteProfileDeleteInput!): DastSiteProfileDeletePayload
dastSiteProfileUpdate(input: DastSiteProfileUpdateInput!): DastSiteProfileUpdatePayload
deleteAnnotation(input: DeleteAnnotationInput!): DeleteAnnotationPayload
designManagementDelete(input: DesignManagementDeleteInput!): DesignManagementDeletePayload
designManagementMove(input: DesignManagementMoveInput!): DesignManagementMovePayload
......
......@@ -7876,6 +7876,146 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "DastSiteProfileUpdateInput",
"description": "Autogenerated input type of DastSiteProfileUpdate",
"fields": null,
"inputFields": [
{
"name": "fullPath",
"description": "The project the site profile belongs to.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "id",
"description": "ID of the site profile to be updated.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DastSiteProfileID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "profileName",
"description": "The name of the site profile.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "targetUrl",
"description": "The URL of the target to be scanned.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DastSiteProfileUpdatePayload",
"description": "Autogenerated return type of DastSiteProfileUpdate",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID of the site profile.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "DastSiteProfileID",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "DastSiteProfileValidationStatusEnum",
......@@ -26800,6 +26940,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "dastSiteProfileUpdate",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "DastSiteProfileUpdateInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DastSiteProfileUpdatePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deleteAnnotation",
"description": null,
......@@ -487,6 +487,16 @@ Check permissions for the current user on site profile
| --- | ---- | ---------- |
| `createOnDemandDastScan` | Boolean! | Indicates the user can perform `create_on_demand_dast_scan` on this resource |
## DastSiteProfileUpdatePayload
Autogenerated return type of DastSiteProfileUpdate
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `id` | DastSiteProfileID | ID of the site profile. |
## DeleteAnnotationPayload
Autogenerated return type of DeleteAnnotation
......
# frozen_string_literal: true
class DastSiteProfilesFinder
def initialize(params = {})
@params = params
end
def execute
relation = DastSiteProfile.with_dast_site
relation = by_id(relation)
relation = by_project(relation)
relation
end
private
attr_reader :params
# rubocop: disable CodeReuse/ActiveRecord
def by_id(relation)
return relation if params[:id].nil?
relation.where(id: params[:id])
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def by_project(relation)
return relation if params[:project_id].nil?
relation.where(project_id: params[:project_id])
end
# rubocop: enable CodeReuse/ActiveRecord
end
......@@ -26,6 +26,7 @@ module EE
mount_mutation ::Mutations::Pipelines::RunDastScan
mount_mutation ::Mutations::DastOnDemandScans::Create
mount_mutation ::Mutations::DastSiteProfiles::Create
mount_mutation ::Mutations::DastSiteProfiles::Update
mount_mutation ::Mutations::DastSiteProfiles::Delete
mount_mutation ::Mutations::DastScannerProfiles::Create
mount_mutation ::Mutations::Security::CiConfiguration::ConfigureSast
......
# frozen_string_literal: true
module Mutations
module DastSiteProfiles
class Update < BaseMutation
include ResolvesProject
graphql_name 'DastSiteProfileUpdate'
field :id, ::Types::GlobalIDType[::DastSiteProfile],
null: true,
description: 'ID of the site profile.'
argument :full_path, GraphQL::ID_TYPE,
required: true,
description: 'The project the site profile belongs to.'
argument :id, ::Types::GlobalIDType[::DastSiteProfile],
required: true,
description: 'ID of the site profile to be updated.'
argument :profile_name, GraphQL::STRING_TYPE,
required: true,
description: 'The name of the site profile.'
argument :target_url, GraphQL::STRING_TYPE,
required: false,
description: 'The URL of the target to be scanned.'
authorize :run_ondemand_dast_scan
def resolve(full_path:, **service_args)
project = authorized_find!(full_path: full_path)
raise_resource_not_available_error! unless Feature.enabled?(:security_on_demand_scans_feature_flag, project)
service = ::DastSiteProfiles::UpdateService.new(project, current_user)
result = service.execute(service_args)
if result.success?
{ id: result.payload.to_global_id, errors: [] }
else
{ errors: result.errors }
end
end
private
def find_object(full_path:)
resolve_project(full_path: full_path)
end
end
end
end
# frozen_string_literal: true
module DastSiteProfiles
class UpdateService < BaseService
def execute(id:, profile_name:, target_url:)
return ServiceResponse.error(message: 'Insufficient permissions') unless allowed?
ActiveRecord::Base.transaction do
dast_site_profile = find_dast_site_profile!(id)
service = DastSites::FindOrCreateService.new(project, current_user)
dast_site = service.execute!(url: target_url)
dast_site_profile.update!(name: profile_name, dast_site: dast_site)
ServiceResponse.success(payload: dast_site_profile)
end
rescue ActiveRecord::RecordNotFound => err
ServiceResponse.error(message: "#{err.model} not found")
rescue ActiveRecord::RecordInvalid => err
ServiceResponse.error(message: err.record.errors.full_messages)
end
private
def allowed?
Ability.allowed?(current_user, :run_ondemand_dast_scan, project)
end
# rubocop: disable CodeReuse/ActiveRecord
def find_dast_site_profile!(id)
DastSiteProfilesFinder.new(project_id: project.id, id: id.model_id).execute.first!
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe DastSiteProfilesFinder do
let!(:current_user) { create(:user) }
let!(:dast_site_profile1) { create(:dast_site_profile) }
let!(:project1) { dast_site_profile1.project }
let!(:dast_site_profile2) { create(:dast_site_profile) }
let!(:project2) { dast_site_profile2.project }
let!(:dast_site_profile3) { create(:dast_site_profile, project: project1) }
let(:params) { {} }
subject do
described_class.new(params).execute
end
describe '#execute' do
it 'returns all dast_site_profiles' do
expect(subject).to contain_exactly(dast_site_profile1, dast_site_profile2, dast_site_profile3)
end
it 'eager loads the dast_site association' do
dast_site_profile1 = subject.first!
recorder = ActiveRecord::QueryRecorder.new do
dast_site_profile1.dast_site
end
expect(recorder.count).to be_zero
end
context 'filtering by id' do
let(:params) { { id: dast_site_profile1.id } }
it 'returns a single dast_site_profile' do
expect(subject).to contain_exactly(dast_site_profile1)
end
end
context 'when the dast_site_profile1 does not exist' do
let(:params) { { id: 0 } }
it 'returns an empty relation' do
expect(subject).to be_empty
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::DastSiteProfiles::Update do
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
let(:user) { create(:user) }
let(:full_path) { project.full_path }
let!(:dast_site_profile) { create(:dast_site_profile, project: project) }
let(:new_profile_name) { SecureRandom.hex }
let(:new_target_url) { FFaker::Internet.uri(:https) }
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
describe '#resolve' do
subject do
mutation.resolve(
full_path: full_path,
id: dast_site_profile.to_global_id,
profile_name: new_profile_name,
target_url: new_target_url
)
end
context 'when on demand scan feature is enabled' do
context 'when the project does not exist' do
let(:full_path) { SecureRandom.hex }
it 'raises an exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user is not associated with the project' do
it 'raises an exception' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user is an owner' do
it 'has no errors' do
group.add_owner(user)
expect(subject[:errors]).to be_empty
end
end
context 'when the user is a maintainer' do
it 'has no errors' do
project.add_maintainer(user)
expect(subject[:errors]).to be_empty
end
end
context 'when the user is a developer' do
it 'has no errors' do
project.add_developer(user)
expect(subject[:errors]).to be_empty
end
end
context 'when the user is a reporter' do
it 'raises an exception' do
project.add_reporter(user)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user is a guest' do
it 'raises an exception' do
project.add_guest(user)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when the user can run a dast scan' do
before do
project.add_developer(user)
end
it 'updates the dast_site_profile' do
dast_site_profile = subject[:id].find
aggregate_failures do
expect(dast_site_profile.name).to eq(new_profile_name)
expect(dast_site_profile.dast_site.url).to eq(new_target_url)
end
end
context 'when on demand scan feature is not enabled' do
it 'raises an exception' do
stub_feature_flags(security_on_demand_scans_feature_flag: false)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Creating a DAST Site Profile' do
include GraphqlHelpers
let(:project) { create(:project) }
let(:current_user) { create(:user) }
let(:full_path) { project.full_path }
let!(:dast_site_profile) { create(:dast_site_profile, project: project) }
let(:new_profile_name) { SecureRandom.hex }
let(:new_target_url) { FFaker::Internet.uri(:https) }
let(:mutation) do
graphql_mutation(
:dast_site_profile_update,
full_path: full_path,
id: dast_site_profile.to_global_id.to_s,
profile_name: new_profile_name,
target_url: new_target_url
)
end
def mutation_response
graphql_mutation_response(:dast_site_profile_update)
end
subject { post_graphql_mutation(mutation, current_user: current_user) }
context 'when a user does not have access to the project' do
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 a user does not have access to run a dast scan on the project' do
before do
project.add_guest(current_user)
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 a user has access to run a dast scan on the project' do
before do
project.add_developer(current_user)
end
it 'returns an empty errors array' do
subject
expect(mutation_response["errors"]).to be_empty
end
it 'updates the dast_site_profile' do
subject
dast_site_profile = GlobalID.parse(mutation_response['id']).find
aggregate_failures do
expect(dast_site_profile.name).to eq(new_profile_name)
expect(dast_site_profile.dast_site.url).to eq(new_target_url)
end
end
context 'when there is an issue updating the dast_site_profile' do
let(:new_target_url) { 'http://localhost:3000' }
it_behaves_like 'a mutation that returns errors in the response', errors: ['Url is blocked: Requests to localhost are not allowed']
end
context 'when the dast_site_profile does not exist' do
before do
dast_site_profile.destroy!
end
it_behaves_like 'a mutation that returns errors in the response', errors: ['DastSiteProfile not found']
end
context 'when wrong type of global id is passed' do
let(:mutation) do
graphql_mutation(
:dast_site_profile_update,
full_path: full_path,
id: dast_site_profile.dast_site.to_global_id.to_s,
profile_name: new_profile_name,
target_url: new_target_url
)
end
it 'returns a top-level error' do
subject
expect(graphql_errors.dig(0, 'message')).to include('does not represent an instance of DastSiteProfile')
end
end
context 'when the dast_site_profile belongs to a different project' do
let(:mutation) do
graphql_mutation(
:dast_site_profile_update,
full_path: create(:project).full_path,
id: dast_site_profile.to_global_id.to_s,
profile_name: new_profile_name,
target_url: new_target_url
)
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
end
context 'when on demand scan feature is disabled' do
before do
stub_feature_flags(security_on_demand_scans_feature_flag: 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
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe DastSiteProfiles::UpdateService do
let(:project) { dast_site_profile.project }
let(:user) { create(:user) }
let(:dast_site_profile) { create(:dast_site_profile) }
let(:new_profile_name) { SecureRandom.hex }
let(:new_target_url) { FFaker::Internet.uri(:https) }
describe '#execute' do
subject do
described_class.new(project, user).execute(
id: dast_site_profile.to_global_id,
profile_name: new_profile_name,
target_url: new_target_url
)
end
let(:status) { subject.status }
let(:message) { subject.message }
let(:errors) { subject.errors }
let(:payload) { subject.payload }
context 'when the user does not have permission to run a dast scan' do
it 'returns an error status' do
expect(status).to eq(:error)
end
it 'populates message' do
expect(message).to eq('Insufficient permissions')
end
end
context 'when the user can run a dast scan' do
before do
project.add_developer(user)
end
it 'returns a success status' do
expect(status).to eq(:success)
end
it 'updates the dast_site_profile' do
updated_dast_site_profile = payload.reload
aggregate_failures do
expect(updated_dast_site_profile.name).to eq(new_profile_name)
expect(updated_dast_site_profile.dast_site.url).to eq(new_target_url)
end
end
it 'returns a dast_site_profile payload' do
expect(payload).to be_a(DastSiteProfile)
end
context 'when the target url is localhost' do
let(:new_target_url) { 'http://localhost:3000/hello-world' }
it 'returns an error status' do
expect(status).to eq(:error)
end
it 'populates errors' do
expect(errors).to include('Url is blocked: Requests to localhost are not allowed')
end
end
context 'when the dast_site_profile doesn\'t exist' do
before do
dast_site_profile.destroy!
end
it 'returns an error status' do
expect(status).to eq(:error)
end
it 'populates message' do
expect(message).to eq('DastSiteProfile not found')
end
end
end
end
end
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