Commit bdcf4a81 authored by Vasilii Iakliushin's avatar Vasilii Iakliushin

Add mutation to lock/unlock project paths

Contributes to https://gitlab.com/gitlab-org/gitlab/-/issues/330406

* Support both PathLocks and LFS locks

Changelog: added
EE: true
parent bd5d0ea9
......@@ -3407,6 +3407,27 @@ Input type: `ProjectSetComplianceFrameworkInput`
| <a id="mutationprojectsetcomplianceframeworkerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationprojectsetcomplianceframeworkproject"></a>`project` | [`Project`](#project) | Project after mutation. |
### `Mutation.projectSetLocked`
Input type: `ProjectSetLockedInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationprojectsetlockedclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationprojectsetlockedfilepath"></a>`filePath` | [`String!`](#string) | Full path to the file. |
| <a id="mutationprojectsetlockedlock"></a>`lock` | [`Boolean!`](#boolean) | Whether or not to lock the file path. |
| <a id="mutationprojectsetlockedprojectpath"></a>`projectPath` | [`ID!`](#id) | Full path of the project to mutate. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationprojectsetlockedclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationprojectsetlockederrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationprojectsetlockedproject"></a>`project` | [`Project`](#project) | Project after mutation. |
### `Mutation.prometheusIntegrationCreate`
Input type: `PrometheusIntegrationCreateInput`
......
......@@ -23,6 +23,7 @@ module EE
mount_mutation ::Mutations::Epics::SetSubscription
mount_mutation ::Mutations::Epics::AddIssue
mount_mutation ::Mutations::GitlabSubscriptions::Activate
mount_mutation ::Mutations::Projects::SetLocked
mount_mutation ::Mutations::Iterations::Create
mount_mutation ::Mutations::Iterations::Update
mount_mutation ::Mutations::Iterations::Delete
......
# frozen_string_literal: true
module Mutations
module Projects
class SetLocked < BaseMutation
include FindsProject
graphql_name 'ProjectSetLocked'
authorize :push_code
argument :project_path, GraphQL::Types::ID,
required: true,
description: 'Full path of the project to mutate.'
argument :file_path, GraphQL::Types::String,
required: true,
description: 'Full path to the file.'
argument :lock,
GraphQL::Types::Boolean,
required: true,
description: 'Whether or not to lock the file path.'
field :project, Types::ProjectType, null: true,
description: 'Project after mutation.'
attr_reader :project
def resolve(project_path:, file_path:, lock:)
@project = authorized_find!(project_path)
unless allowed?
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'FileLocks feature disabled'
end
if lock
lock_path(file_path)
else
unlock_path(file_path)
end
{
project: project,
errors: []
}
rescue PathLocks::UnlockService::AccessDenied => e
{
project: nil,
errors: [e.message]
}
end
private
delegate :repository, to: :project
def fetch_path_lock(file_path)
project.path_locks.find_by(path: file_path) # rubocop: disable CodeReuse/ActiveRecord
end
def lock_path(file_path)
return if fetch_path_lock(file_path)
path_lock = PathLocks::LockService.new(project, current_user).execute(file_path)
if path_lock.persisted? && sync_with_lfs?(file_path)
Lfs::LockFileService.new(project, current_user, path: file_path, create_path_lock: false).execute
end
end
def unlock_path(file_path)
path_lock = fetch_path_lock(file_path)
return unless path_lock
PathLocks::UnlockService.new(project, current_user).execute(path_lock)
if sync_with_lfs?(file_path)
Lfs::UnlockFileService.new(project, current_user, path: file_path, force: true).execute
end
end
def sync_with_lfs?(file_path)
project.lfs_enabled? && lfs_file?(file_path)
end
def lfs_file?(file_path)
blob = repository.blob_at_branch(repository.root_ref, file_path)
return false unless blob
lfs_blob_ids = LfsPointersFinder.new(repository, file_path).execute
lfs_blob_ids.include?(blob.id)
end
def allowed?
project.licensed_feature_available?(:file_locks)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Projects::SetLocked do
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
describe '#resolve' do
subject { mutation.resolve(project_path: project.full_path, file_path: file_path, lock: lock) }
let(:file_path) { 'README.md' }
let(:lock) { true }
let(:mutated_path_locks) { subject[:project].path_locks }
it 'raises an error if the resource is not accessible to the user' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
context 'when the user can lock the file' do
let(:lock) { true }
before do
project.add_developer(user)
end
context 'when file_locks feature is not available' do
before do
stub_licensed_features(file_locks: false)
end
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when file is not locked' do
it 'sets path locks for the project' do
expect { subject }.to change { project.path_locks.count }.by(1)
expect(mutated_path_locks.first).to have_attributes(path: file_path, user: user)
end
end
context 'when file is already locked' do
before do
create(:path_lock, project: project, path: file_path)
end
it 'does not change the lock' do
expect { subject }.not_to change { project.path_locks.count }
end
end
context 'when LFS is enabled' do
let(:file_path) { 'files/lfs/lfs_object.iso' }
before do
allow_next_found_instance_of(Project) do |project|
allow(project).to receive(:lfs_enabled?) { true }
end
end
it 'locks the file in LFS' do
expect { subject }.to change { project.lfs_file_locks.count }.by(1)
end
context 'when file is not tracked in LFS' do
let(:file_path) { 'README.md' }
it 'does not lock the file' do
expect { subject }.not_to change { project.lfs_file_locks.count }
end
end
context 'when locking a directory' do
let(:file_path) { 'lfs/' }
it 'locks the directory' do
expect { subject }.to change { project.path_locks.count }.by(1)
end
it 'does not locks the directory through LFS' do
expect { subject }.not_to change { project.lfs_file_locks.count }
end
end
end
end
context 'when the user can unlock the file' do
let(:lock) { false }
before do
project.add_developer(user)
end
context 'when file is already locked by the same user' do
before do
create(:path_lock, project: project, path: file_path, user: user)
end
it 'unlocks the file' do
expect { subject }.to change { project.path_locks.count }.by(-1)
expect(mutated_path_locks).to be_empty
end
end
context 'when file is already locked by somebody else' do
before do
create(:path_lock, project: project, path: file_path)
end
it 'returns an error' do
expect(subject[:errors]).to eq(['You have no permissions'])
end
end
context 'when file is not locked' do
it 'does nothing' do
expect { subject }.not_to change { project.path_locks.count }
expect(mutated_path_locks).to be_empty
end
end
context 'when LFS is enabled' do
let(:file_path) { 'files/lfs/lfs_object.iso' }
before do
allow_next_found_instance_of(Project) do |project|
allow(project).to receive(:lfs_enabled?) { true }
end
end
context 'when file is locked' do
before do
create(:lfs_file_lock, project: project, path: file_path, user: user)
create(:path_lock, project: project, path: file_path, user: user)
end
it 'unlocks the file' do
expect { subject }.to change { project.path_locks.count }.by(-1)
end
it 'unlocks the file in LFS' do
expect { subject }.to change { project.lfs_file_locks.count }.by(-1)
end
context 'when file is not tracked in LFS' do
let(:file_path) { 'README.md' }
it 'does not unlock the file' do
expect { subject }.not_to change { project.lfs_file_locks.count }
end
end
context 'when unlocking a directory' do
let(:file_path) { 'lfs/' }
it 'unlocks the directory' do
expect { subject }.to change { project.path_locks.count }.by(-1)
end
it 'does not call the LFS unlock service' do
expect(Lfs::UnlockFileService).not_to receive(:new)
subject
end
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe "Lock/unlock project's file path" do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let(:attributes) { { file_path: file_path, lock: lock } }
let(:file_path) { 'README.md' }
let(:lock) { true }
let(:mutation) do
params = { project_path: project.full_path }.merge(attributes)
graphql_mutation(:project_set_locked, params) do
<<-QL.strip_heredoc
project {
id
pathLocks {
nodes {
path
}
}
}
errors
QL
end
end
def mutation_response
graphql_mutation_response(:project_set_locked)
end
context 'when the user does not have permission' do
it_behaves_like 'a mutation that returns a top-level access error'
it 'does not create requirement' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change { project.path_locks.count }
end
end
context 'when the user has permission' do
before do
project.add_developer(current_user)
end
it 'creates the path lock' do
post_graphql_mutation(mutation, current_user: current_user)
project_hash = mutation_response['project']
expect(project_hash.dig('pathLocks', 'nodes', 0, 'path')).to eq(file_path)
end
context 'when there are validation errors' do
let(:lock) { false }
before do
create(:path_lock, project: project, path: file_path)
end
it_behaves_like 'a mutation that returns errors in the response',
errors: ['You have no permissions']
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