From bdcf4a8172cfb68fde6e0343c7c367104b969237 Mon Sep 17 00:00:00 2001 From: Vasilii Iakliushin Date: Wed, 14 Jul 2021 15:16:09 +0200 Subject: [PATCH] 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 --- doc/api/graphql/reference/index.md | 21 +++ ee/app/graphql/ee/types/mutation_type.rb | 1 + .../graphql/mutations/projects/set_locked.rb | 103 +++++++++++ .../mutations/projects/set_locked_spec.rb | 174 ++++++++++++++++++ .../mutations/projects/lock_path_spec.rb | 68 +++++++ 5 files changed, 367 insertions(+) create mode 100644 ee/app/graphql/mutations/projects/set_locked.rb create mode 100644 ee/spec/graphql/mutations/projects/set_locked_spec.rb create mode 100644 ee/spec/requests/api/graphql/mutations/projects/lock_path_spec.rb diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index d4f9743c6339ef..c1a7d36ae44020 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -3407,6 +3407,27 @@ Input type: `ProjectSetComplianceFrameworkInput` | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | `project` | [`Project`](#project) | Project after mutation. | +### `Mutation.projectSetLocked` + +Input type: `ProjectSetLockedInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `filePath` | [`String!`](#string) | Full path to the file. | +| `lock` | [`Boolean!`](#boolean) | Whether or not to lock the file path. | +| `projectPath` | [`ID!`](#id) | Full path of the project to mutate. | + +#### 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/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index c5b76077a532ef..7de4261bfdae6c 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -23,6 +23,7 @@ module MutationType 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 diff --git a/ee/app/graphql/mutations/projects/set_locked.rb b/ee/app/graphql/mutations/projects/set_locked.rb new file mode 100644 index 00000000000000..ee509f3060acb6 --- /dev/null +++ b/ee/app/graphql/mutations/projects/set_locked.rb @@ -0,0 +1,103 @@ +# 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 diff --git a/ee/spec/graphql/mutations/projects/set_locked_spec.rb b/ee/spec/graphql/mutations/projects/set_locked_spec.rb new file mode 100644 index 00000000000000..29ef097956ff68 --- /dev/null +++ b/ee/spec/graphql/mutations/projects/set_locked_spec.rb @@ -0,0 +1,174 @@ +# 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 diff --git a/ee/spec/requests/api/graphql/mutations/projects/lock_path_spec.rb b/ee/spec/requests/api/graphql/mutations/projects/lock_path_spec.rb new file mode 100644 index 00000000000000..73826bb83e7ec5 --- /dev/null +++ b/ee/spec/requests/api/graphql/mutations/projects/lock_path_spec.rb @@ -0,0 +1,68 @@ +# 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 -- GitLab