Commit f57052ac authored by Lin Jen-Shin's avatar Lin Jen-Shin

Merge branch '24080-prevent-unmodified-Designs-from-being-versioned' into 'master'

Prevent unmodified designs from being versioned

See merge request gitlab-org/gitlab!16429
parents 394a9207 95c8b31d
...@@ -15,6 +15,11 @@ module Mutations ...@@ -15,6 +15,11 @@ module Mutations
null: false, null: false,
description: "The designs that were uploaded by the mutation" description: "The designs that were uploaded by the mutation"
field :skipped_designs, [Types::DesignManagement::DesignType],
null: false,
description: "Any designs that were skipped from the upload due to there " \
"being no change to their content since their last version"
def resolve(project_path:, iid:, files:) def resolve(project_path:, iid:, files:)
issue = authorized_find!(project_path: project_path, iid: iid) issue = authorized_find!(project_path: project_path, iid: iid)
project = issue.project project = issue.project
...@@ -24,6 +29,7 @@ module Mutations ...@@ -24,6 +29,7 @@ module Mutations
{ {
designs: Array.wrap(result[:designs]), designs: Array.wrap(result[:designs]),
skipped_designs: Array.wrap(result[:skipped_designs]),
errors: Array.wrap(result[:message]) errors: Array.wrap(result[:message])
} }
end end
......
...@@ -2,9 +2,15 @@ ...@@ -2,9 +2,15 @@
module DesignManagement module DesignManagement
module RunsDesignActions module RunsDesignActions
NoActions = Class.new(StandardError)
# this concern requires the following methods to be implemented: # this concern requires the following methods to be implemented:
# current_user, target_branch, repository, commit_message # current_user, target_branch, repository, commit_message
#
# @raise [NoActions] if actions are empty
def run_actions(actions) def run_actions(actions)
raise NoActions if actions.empty?
repository.create_if_not_exists repository.create_if_not_exists
sha = repository.multi_action(current_user, sha = repository.multi_action(current_user,
branch_name: target_branch, branch_name: target_branch,
......
...@@ -17,10 +17,12 @@ module DesignManagement ...@@ -17,10 +17,12 @@ module DesignManagement
return error("Not allowed!") unless can_create_designs? return error("Not allowed!") unless can_create_designs?
return error("Only #{MAX_FILES} files are allowed simultaneously") if files.size > MAX_FILES return error("Only #{MAX_FILES} files are allowed simultaneously") if files.size > MAX_FILES
actions = build_actions repository.create_if_not_exists
run_actions(actions)
uploaded_designs = upload_designs!
skipped_designs = designs - uploaded_designs
success({ designs: actions.map(&:design) }) success({ designs: uploaded_designs, skipped_designs: skipped_designs })
rescue ::ActiveRecord::RecordInvalid => e rescue ::ActiveRecord::RecordInvalid => e
error(e.message) error(e.message)
end end
...@@ -28,31 +30,44 @@ module DesignManagement ...@@ -28,31 +30,44 @@ module DesignManagement
private private
attr_reader :files attr_reader :files
attr_accessor :paths_in_repo
def build_actions def upload_designs!
repository.create_if_not_exists actions = build_actions
run_actions(actions) unless actions.empty?
designs = files.map do |file| actions.map(&:design)
collection.find_or_create_design!(filename: file.original_filename)
end end
# Needs to be called before any call to build_design_action # Returns `Design` instances that correspond with `files`.
cache_existence(designs) # New `Design`s will be created where a file name does not match
# an existing `Design`
def designs
@designs ||= files.map do |file|
collection.find_or_create_design!(filename: file.original_filename)
end
end
files.zip(designs).map do |(file, design)| def build_actions
build_design_action(file, design) files.zip(designs).flat_map do |(file, design)|
Array.wrap(build_design_action(file, design))
end end
end end
def build_design_action(file, design) def build_design_action(file, design)
action = new_file?(design) ? :create : :update
content = file_content(file, design.full_path) content = file_content(file, design.full_path)
return if design_unchanged?(design, content)
action = new_file?(design) ? :create : :update
on_success { ::Gitlab::UsageCounters::DesignsCounter.count(action) } on_success { ::Gitlab::UsageCounters::DesignsCounter.count(action) }
DesignManagement::DesignAction.new(design, action, content) DesignManagement::DesignAction.new(design, action, content)
end end
# Returns true if the design file is the same as its latest version
def design_unchanged?(design, content)
content == existing_blobs[design]&.data
end
def commit_message def commit_message
<<~MSG <<~MSG
Updated #{files.size} #{'designs'.pluralize(files.size)} Updated #{files.size} #{'designs'.pluralize(files.size)}
...@@ -74,11 +89,7 @@ module DesignManagement ...@@ -74,11 +89,7 @@ module DesignManagement
end end
def new_file?(design) def new_file?(design)
design.new_design? && !on_disk?(design) !existing_blobs[design]
end
def on_disk?(design)
paths_in_repo === design.full_path
end end
def file_content(file, full_path) def file_content(file, full_path)
...@@ -86,9 +97,17 @@ module DesignManagement ...@@ -86,9 +97,17 @@ module DesignManagement
transformer.new_file(full_path, file.to_io).content transformer.new_file(full_path, file.to_io).content
end end
def cache_existence(designs) # Returns the latest blobs for the designs as a Hash of `{ Design => Blob }`
paths = designs.map(&:full_path) def existing_blobs
self.paths_in_repo = repository.blobs_metadata(paths).map(&:path).to_set @existing_blobs ||= begin
items = designs.map { |d| ['HEAD', d.full_path] }
repository.blobs_at(items).each_with_object({}) do |blob, h|
design = designs.find { |d| d.full_path == blob.path }
h[design] = blob
end
end
end end
end end
end end
...@@ -9,7 +9,7 @@ describe "uploading designs" do ...@@ -9,7 +9,7 @@ describe "uploading designs" do
let(:current_user) { create(:user) } let(:current_user) { create(:user) }
let(:issue) { create(:issue) } let(:issue) { create(:issue) }
let(:project) { issue.project } let(:project) { issue.project }
let(:files) { [fixture_file_upload('spec/fixtures/dk.png')] } let(:files) { [fixture_file_upload("spec/fixtures/dk.png")] }
let(:variables) { {} } let(:variables) { {} }
let(:mutation) do let(:mutation) do
...@@ -35,7 +35,7 @@ describe "uploading designs" do ...@@ -35,7 +35,7 @@ describe "uploading designs" do
expect(graphql_errors).to be_present expect(graphql_errors).to be_present
end end
it 'succeeds (backward compatibility)' do it "succeeds (backward compatibility)" do
post_graphql_mutation(mutation, current_user: current_user) post_graphql_mutation(mutation, current_user: current_user)
expect(graphql_errors).not_to be_present expect(graphql_errors).not_to be_present
...@@ -57,8 +57,21 @@ describe "uploading designs" do ...@@ -57,8 +57,21 @@ describe "uploading designs" do
post_graphql_mutation(mutation, current_user: current_user) post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response).to include( expect(mutation_response).to include(
'designs' => a_collection_containing_exactly( "designs" => a_collection_containing_exactly(
a_hash_including('filename' => 'dk.png') a_hash_including("filename" => "dk.png")
)
)
end
it "can respond with skipped designs" do
2.times do
post_graphql_mutation(mutation, current_user: current_user)
files.each(&:rewind)
end
expect(mutation_response).to include(
"skippedDesigns" => a_collection_containing_exactly(
a_hash_including("filename" => "dk.png")
) )
) )
end end
......
...@@ -21,12 +21,26 @@ describe DesignManagement::SaveDesignsService do ...@@ -21,12 +21,26 @@ describe DesignManagement::SaveDesignsService do
end end
def run_service(files_to_upload = nil) def run_service(files_to_upload = nil)
design_files = files_to_upload || files
design_files.each(&:rewind)
service = described_class.new(project, user, service = described_class.new(project, user,
issue: issue, issue: issue,
files: files_to_upload || files) files: design_files)
service.execute service.execute
end end
# Randomly alter the content of files.
# This allows the files to be updated by the service, as unmodified
# files are rejected.
def touch_files(files_to_touch = nil)
design_files = files_to_touch || files
design_files.each do |f|
f.tempfile.write(SecureRandom.random_bytes)
end
end
let(:response) { run_service } let(:response) { run_service }
shared_examples 'a service error' do shared_examples 'a service error' do
...@@ -126,11 +140,10 @@ describe DesignManagement::SaveDesignsService do ...@@ -126,11 +140,10 @@ describe DesignManagement::SaveDesignsService do
end end
end end
context 'when a design already exists' do context 'when a design is being updated' do
before do before do
# This makes sure the file is created in the repository.
# otherwise we'd have a database & repository that are not in sync.
run_service run_service
touch_files
end end
it 'creates a new version for the existing design and updates the file' do it 'creates a new version for the existing design and updates the file' do
...@@ -161,12 +174,30 @@ describe DesignManagement::SaveDesignsService do ...@@ -161,12 +174,30 @@ describe DesignManagement::SaveDesignsService do
end end
end end
context 'when a design has not changed since its previous version' do
before do
run_service
end
it 'does not create a new version' do
expect { run_service }.not_to change { issue.design_versions.count }
end
it 'returns the design in `skipped_designs` instead of `designs`' do
response = run_service
expect(response[:designs]).to be_empty
expect(response[:skipped_designs].size).to eq(1)
end
end
context 'when doing a mixture of updates and creations' do context 'when doing a mixture of updates and creations' do
let(:files) { [rails_sample, dk_png] } let(:files) { [rails_sample, dk_png] }
before do before do
# Create just the first one, which we will later update. # Create just the first one, which we will later update.
run_service([files.first]) run_service([files.first])
touch_files([files.first])
end end
it 'counts one creation and one update' do it 'counts one creation and one update' do
......
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