Commit 68b0e357 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Upload Versioned designs through GraphQL

This allows uploading designs through GraphQL.

The mutation requires these arguments:

- `fullPath`: The path in which we can find the issue for creating
  designs
- `iid`: The iid of the issue in which to upload designs
- `files`: An array of files. We expect these to be in the format
  used by apollo-upload-client[0] which uses this spec[1] for
  multipart fileupload.

The middleware used for handling the multipart upload an translating
the variables is apollo_upload_server[2]

When a file is uploaded, the basename is used for creating a design,
when a design for the specified issue did not exist for the filename,
a new one is created. Otherwise the existing one is used.

When uploading a new file, a new version is created an linked to the
design being updated or created.

The files are stored in a repository that lives next to the project
repository with the path `@hashed/[hash]/[to]/[repo].design.git. The
files are not yet stored in LFS, but they should be.

0: https://github.com/jaydenseric/apollo-upload-client
1: https://github.com/jaydenseric/graphql-multipart-request-spec
2: https://github.com/jetruby/apollo_upload_server-ruby
parent 97ac93fc
......@@ -82,6 +82,7 @@ gem 'rack-cors', '~> 1.0.0', require: 'rack/cors'
# GraphQL API
gem 'graphql', '~> 1.8.0'
gem 'graphiql-rails', '~> 1.4.10'
gem 'apollo_upload_server', '~> 2.0.0.beta3'
# Disable strong_params so that Mash does not respond to :permitted?
gem 'hashie-forbidden_attributes'
......
......@@ -52,6 +52,9 @@ GEM
public_suffix (>= 2.0.2, < 4.0)
aes_key_wrap (1.0.1)
akismet (2.0.0)
apollo_upload_server (2.0.0.beta.3)
graphql (>= 1.8)
rails (>= 4.2)
arel (8.0.0)
asana (0.8.1)
faraday (~> 0.9)
......@@ -1021,6 +1024,7 @@ DEPENDENCIES
acts-as-taggable-on (~> 6.0)
addressable (~> 2.5.2)
akismet (~> 2.0)
apollo_upload_server (~> 2.0.0.beta3)
asana (~> 0.8.1)
asciidoctor (~> 1.5.8)
asciidoctor-plantuml (= 0.0.8)
......
......@@ -44,6 +44,12 @@ module Resolvers
alias_method :project, :object
def resolve(**args)
# The project could have been loaded in batch by `BatchLoader`.
# At this point we need the `id` of the project to query for issues, so
# make sure it's loaded and not `nil` before continueing.
project.sync if project.respond_to?(:sync)
return Issue.none if project.nil?
# Will need to be be made group & namespace aware with
# https://gitlab.com/gitlab-org/gitlab-ce/issues/54520
args[:project_id] = project.id
......
......@@ -43,3 +43,5 @@ module Types
field :updated_at, Types::TimeType, null: false
end
end
Types::IssueType.prepend(::EE::Types::IssueType)
......@@ -9,3 +9,5 @@ module Types
mount_mutation Mutations::MergeRequests::SetWip
end
end
::Types::MutationType.prepend(::EE::Types::MutationType)
......@@ -1065,6 +1065,19 @@ class Repository
blob.data
end
def create_if_not_exists
return if exists?
raw.create_repository
after_create
end
def blobs_metadata(paths, ref = 'HEAD')
references = Array.wrap(paths).map { |path| [ref, path] }
Gitlab::Git::Blob.batch_metadata(raw, references).map { |raw_blob| Blob.decorate(raw_blob) }
end
private
# TODO Generice finder, later split this on finders by Ref or Oid
......
......@@ -21,8 +21,10 @@ class PostReceive
if repo_type.wiki?
process_wiki_changes(post_received)
else
elsif repo_type.project?
process_project_changes(post_received)
else
# Other repos don't have hooks for now
end
end
......
# frozen_string_literal: true
module EE
module Types
module IssueType
extend ActiveSupport::Concern
prepended do
field :designs, ::Types::DesignManagement::DesignCollectionType,
null: true, method: :design_collection
end
end
end
end
# frozen_string_literal: true
module EE
module Types
module MutationType
extend ActiveSupport::Concern
prepended do
mount_mutation ::Mutations::DesignManagement::Upload
end
end
end
end
# frozen_string_literal: true
module Mutations
module DesignManagement
class Base < ::Mutations::BaseMutation
include Gitlab::Graphql::Authorize::AuthorizeResource
include Mutations::ResolvesProject
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: "The project where the issue is to upload designs for"
argument :iid, GraphQL::ID_TYPE,
required: true,
description: "The iid of the issue to modify designs for"
field :designs, [Types::DesignManagement::DesignType],
null: false,
description: "The designs that were updated by the mutation"
private
def find_object(project_path:, iid:)
project = resolve_project(full_path: project_path)
Resolvers::IssuesResolver.single.new(object: project, context: context)
.resolve(iid: iid)
end
end
end
end
# frozen_string_literal: true
module Mutations
module DesignManagement
class Upload < Base
graphql_name "DesignManagementUpload"
argument :files, [ApolloUploadServer::Upload],
required: true,
description: "The files to upload"
authorize :create_design
def resolve(project_path:, iid:, files:)
issue = authorized_find!(project_path: project_path, iid: iid)
project = issue.project
result = ::DesignManagement::SaveDesignsService.new(project, current_user, issue: issue, files: files)
.execute
{
designs: Array.wrap(result[:designs]),
errors: Array.wrap(result[:message])
}
end
end
end
end
# frozen_string_literal: true
module Resolvers
module DesignManagement
class VersionResolver < BaseResolver
type Types::DesignManagement::VersionType.connection_type, null: false
alias_method :design_or_collection, :object
def resolve(*_args)
unless Ability.allowed?(context[:current_user], :read_design, design_or_collection)
return ::DesignManagement::Version.none
end
design_or_collection.versions.ordered
end
end
end
end
# frozen_string_literal: true
module Types
module DesignManagement
class DesignCollectionType < BaseObject
graphql_name 'DesignCollection'
authorize :read_design
field :project, Types::ProjectType, null: false
field :issue, Types::IssueType, null: false
field :designs,
Types::DesignManagement::DesignType.connection_type,
null: false,
description: "All visible designs for this collection"
# TODO: allow getting a single design by filename
# TODO: when we allow hiding designs, we will also expose a relation
# exposing all designs
field :versions,
Types::DesignManagement::VersionType.connection_type,
resolver: Resolvers::DesignManagement::VersionResolver,
description: "All versions related to all designs ordered newest first"
end
end
end
# frozen_string_literal: true
module Types
module DesignManagement
class DesignType < BaseObject
graphql_name 'Design'
authorize :read_design
field :id, GraphQL::ID_TYPE, null: false
field :project, Types::ProjectType, null: false
field :issue, Types::IssueType, null: false
field :filename, GraphQL::STRING_TYPE, null: false
field :versions,
Types::DesignManagement::VersionType.connection_type,
resolver: Resolvers::DesignManagement::VersionResolver,
description: "All versions related to this design ordered newest first"
end
end
end
# frozen_string_literal: true
module Types
module DesignManagement
class VersionType < BaseObject
# Just `Version` might be a bit to general to expose globally so adding
# a `Design` prefix to specify the class exposed in GraphQL
graphql_name 'DesignVersion'
authorize :read_design
field :sha, GraphQL::ID_TYPE, null: false
field :designs,
Types::DesignManagement::DesignType.connection_type,
null: false,
description: "All designs that were changed in this version"
end
end
end
# frozen_string_literal: true
module DesignManagement
def self.designs_directory
'designs'
end
def self.table_name_prefix
'design_management_'
end
......
......@@ -2,11 +2,35 @@
module DesignManagement
class Design < ApplicationRecord
include Gitlab::FileTypeDetection
belongs_to :project
belongs_to :issue
has_and_belongs_to_many :versions, class_name: 'DesignManagement::Version', inverse_of: :designs
has_many :design_versions
has_many :versions, through: :design_versions, class_name: 'DesignManagement::Version', inverse_of: :designs
validates :project, :issue, :filename, presence: true
validates :filename, uniqueness: { scope: :issue_id }
validate :validate_file_is_image
def new_design?
versions.none?
end
def full_path
@full_path ||= File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", filename)
end
private
def validate_file_is_image
unless image?
message = _("Only these extensions are supported: %{extension_list}") % {
extension_list: Gitlab::FileTypeDetection::IMAGE_EXT.join(", ")
}
errors.add(:filename, message)
end
end
end
end
# frozen_string_literal: true
module DesignManagement
class DesignCollection
attr_reader :issue
delegate :designs, :project, to: :issue
def initialize(issue)
@issue = issue
end
def find_or_create_design!(filename:)
designs.find { |design| design.filename == filename } ||
designs.safe_find_or_create_by!(project: project, filename: filename)
end
def versions
@versions ||= DesignManagement::Version.for_designs(designs)
end
def repository
@repository ||= ::DesignManagement::Repository.new(project)
end
end
end
# frozen_string_literal: true
module DesignManagement
class DesignVersion < ApplicationRecord
self.table_name = "#{DesignManagement.table_name_prefix}designs_versions"
belongs_to :design, class_name: "DesignManagement::Design", inverse_of: :design_versions
belongs_to :version, class_name: "DesignManagement::Version", inverse_of: :design_versions
end
end
# frozen_string_literal: true
module DesignManagement
class Repository < ::Repository
def initialize(project)
full_path = project.full_path + EE::Gitlab::GlRepository::DESIGN.path_suffix
disk_path = project.disk_path + EE::Gitlab::GlRepository::DESIGN.path_suffix
super(full_path, project, disk_path: disk_path, repo_type: EE::Gitlab::GlRepository::DESIGN)
end
end
end
......@@ -4,9 +4,12 @@ module DesignManagement
class Version < ApplicationRecord
include ShaAttribute
has_and_belongs_to_many :designs,
class_name: "DesignManagement::Design",
inverse_of: :versions
has_many :design_versions
has_many :designs,
through: :design_versions,
class_name: "DesignManagement::Design",
source: :design,
inverse_of: :versions
# This is a polymorphic association, so we can't count on FK's to delete the
# data
......@@ -16,5 +19,27 @@ module DesignManagement
validates :sha, uniqueness: { case_sensitive: false }
sha_attribute :sha
scope :for_designs, -> (designs) do
where(id: DesignVersion.where(design_id: designs).select(:version_id)).distinct
end
scope :ordered, -> { order(id: :desc) }
def self.create_for_designs(designs, sha)
version = safe_find_or_create_by!(sha: sha)
rows = designs.map do |design|
{ design_id: design.id, version_id: version.id }
end
Gitlab::Database.bulk_insert(DesignVersion.table_name, rows)
version
end
def issue
designs.take.issue
end
end
end
......@@ -18,6 +18,7 @@ module EE
has_one :epic_issue
has_one :epic, through: :epic_issue
has_many :designs, class_name: "DesignManagement::Design", inverse_of: :issue
validates :weight, allow_nil: true, numericality: { greater_than_or_equal_to: 0 }
end
......@@ -91,6 +92,10 @@ module EE
@group ||= project.group
end
def design_collection
@design_collection ||= ::DesignManagement::DesignCollection.new(self)
end
class_methods do
# override
def sort_by_attribute(method, excluded_labels: [])
......
# frozen_string_literal: true
module DesignManagement
class DesignCollectionPolicy < DesignPolicy
# Delegates everything to the `issue` just like the `DesignPolicy`
end
end
# frozen_string_literal: true
module DesignManagement
class VersionPolicy < ::BasePolicy
# The IssuePolicy will delegate to the ProjectPolicy
delegate { @subject.issue }
end
end
......@@ -74,10 +74,6 @@ module EE
rule { admin }.enable :change_repository_storage
rule { can?(:public_access) }.policy do
enable :read_design
end
rule { support_bot }.enable :guest_access
rule { support_bot & ~service_desk_enabled }.policy do
prevent :create_note
......@@ -96,7 +92,10 @@ module EE
prevent :admin_issue_link
end
rule { can?(:read_issue) }.enable :read_issue_link
rule { can?(:read_issue) }.policy do
enable :read_issue_link
enable :read_design
end
rule { can?(:reporter_access) }.policy do
enable :admin_board
......
# frozen_string_literal: true
module DesignManagement
class SaveDesignsService < ::BaseService
MAX_FILES = 10
def initialize(project, user, params = {})
super
@issue = params.fetch(:issue)
@files = params.fetch(:files)
end
def execute
return error("Not allowed!") unless can_create_designs?
return error("Only #{MAX_FILES} files are allowed simultaneously") if files.size > MAX_FILES
save_designs!
success({ designs: updated_designs })
rescue Gitlab::Git::BaseError, ActiveRecord::RecordInvalid => e
error(e.message)
end
private
attr_reader :files, :issue
def save_designs!
commit_sha = create_and_commit_designs!
DesignManagement::Version.create_for_designs(updated_designs, commit_sha)
end
def create_and_commit_designs!
repository.create_if_not_exists
repository_actions = files.map do |file|
design = collection.find_or_create_design!(filename: file.original_filename)
build_repository_action(file, design)
end
repository.multi_action(current_user,
branch_name: target_branch,
message: commit_message,
actions: repository_actions)
end
def build_repository_action(file, design)
{
action: new_file?(design) ? :create : :update,
file_path: design.full_path,
content: file.to_io
}
end
def collection
issue.design_collection
end
def repository
collection.repository
end
def project
issue.project
end
def target_branch
repository.root_ref || "master"
end
def commit_message
<<~MSG
Updated #{files.size} #{'designs'.pluralize(files.size)}
#{formatted_file_list}
MSG
end
def formatted_file_list
filenames.map { |name| "- #{name}" }.join("\n")
end
def filenames
@filenames ||= files.map(&:original_filename)
end
def updated_designs
@updated_designs ||= collection.designs.select { |design| filenames.include?(design.filename) }
end
def can_create_designs?
Ability.allowed?(current_user, :create_design, issue)
end
def new_file?(design)
design.new_design? && existing_metadata.none? { |blob| blob.path == design.full_path }
end
def existing_metadata
@existing_metadata ||= begin
paths = updated_designs.map(&:full_path)
repository.blobs_metadata(paths)
end
end
end
end
# frozen_string_literal: true
module EE
module Gitlab
module GlRepository
extend ActiveSupport::Concern
DESIGN = ::Gitlab::GlRepository::RepoType.new(
name: :design,
access_checker_class: ::Gitlab::GitAccessDesign,
repository_accessor: -> (project) { ::DesignManagement::Repository.new(project) }
)
EE_TYPES = {
DESIGN.name.to_s => DESIGN
}.freeze
class_methods do
def types
super.merge(EE_TYPES)
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
class GitAccessDesign < GitAccess
def check(cmd, _changes)
check_protocol!
check_can_create_design!
success_result(cmd)
end
private
def check_protocol!
if protocol != 'web'
raise ::Gitlab::GitAccess::UnauthorizedError, "Designs are only accessible using the web interface"
end
end
def check_can_create_design!
unless user&.can?(:create_design, project)
raise ::Gitlab::GitAccess::UnauthorizedError, "You are not allowed to manage designs of this project"
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Mutations::DesignManagement::Upload do
let(:issue) { create(:issue) }
let(:user) { issue.author }
let(:project) { issue.project }
subject(:mutation) do
described_class.new(object: nil, context: { current_user: user })
end
describe "#resolve" do
let(:files) { [fixture_file_upload('spec/fixtures/dk.png')] }
subject(:resolve) do
mutation.resolve(project_path: project.full_path, iid: issue.iid, files: files)
end
shared_examples "resource not available" do
it "raises an error" do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context "when the feature is not available" do
before do
stub_licensed_features(design_management: false)
end
it_behaves_like "resource not available"
end
context "when the feature is available" do
before do
stub_licensed_features(design_management: true)
end
context "when the user is not allowed to upload designs" do
let(:user) { create(:user) }
it_behaves_like "resource not available"
end
context "a valid design" do
it "returns the updated designs" do
expect(resolve[:errors]).to eq []
expect(resolve[:designs].map(&:filename)).to contain_exactly("dk.png")
end
end
context "context when passing an invalid project" do
let(:project) { build(:project) }
it_behaves_like "resource not available"
end
context "context when passing an invalid issue" do
let(:issue) { build(:issue) }
it_behaves_like "resource not available"
end
context "when creating designs causes errors" do
before do
fake_service = double(::DesignManagement::SaveDesignsService)
allow(fake_service).to receive(:execute).and_return(status: :error, message: "Something failed")
allow(::DesignManagement::SaveDesignsService).to receive(:new).and_return(fake_service)
end
it "wraps the errors" do
expect(resolve[:errors]).to eq(["Something failed"])
expect(resolve[:designs]).to eq([])
end
end
end
end
end
require 'spec_helper'
describe Resolvers::DesignManagement::VersionResolver do
include GraphqlHelpers
before do
stub_licensed_features(design_management: true)
end
describe "#resolve" do
set(:issue) { create(:issue) }
set(:project) { issue.project }
set(:first_version) { create(:design_version) }
set(:first_design) { create(:design, issue: issue, versions: [first_version]) }
let(:current_user) { create(:user) }
before do
project.add_developer(current_user)
end
context "for a design collection" do
let(:collection) { DesignManagement::DesignCollection.new(issue) }
it "returns the ordered versions" do
second_version = create(:design_version)
create(:design, issue: issue, versions: [second_version])
expect(resolve_versions(collection)).to eq([second_version, first_version])
end
end
context "for a design" do
it "returns the versions" do
expect(resolve_versions(first_design)).to eq([first_version])
end
end
context "when the is anonymous" do
let(:current_user) { nil }
it "returns nothing" do
expect(resolve_versions(first_design)).to be_empty
end
end
context "when the is cannot see designs" do
it "returns nothing" do
expect(resolve_versions(first_design, {}, current_user: create(:user))).to be_empty
end
end
end
def resolve_versions(obj, args = {}, context = { current_user: current_user })
resolve(described_class, obj: obj, args: args, ctx: context)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['DesignCollection'] do
it { expect(described_class).to require_graphql_authorizations(:read_design) }
it { expect(described_class).to have_graphql_fields(:project, :issue, :designs, :versions) }
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['Design'] do
it { expect(described_class).to require_graphql_authorizations(:read_design) }
it { expect(described_class).to have_graphql_fields(:id, :project, :issue, :filename, :versions) }
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['DesignVersion'] do
it { expect(described_class).to require_graphql_authorizations(:read_design) }
it { expect(described_class).to have_graphql_fields(:sha, :designs) }
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['Issue'] do
it { expect(described_class).to have_graphql_field(:designs) }
end
# frozen_string_literal: true
require 'spec_helper'
describe ::EE::Gitlab::GlRepository do
describe "DESIGN" do
it "uses the design access checker" do
expect(described_class::DESIGN.access_checker_class).to eq(::Gitlab::GitAccessDesign)
end
it "builds a design repository" do
expect(described_class::DESIGN.repository_accessor.call(create(:project)))
.to be_a(::DesignManagement::Repository)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::GitAccessDesign do
set(:project) { create(:project) }
set(:user) { project.owner }
let(:protocol) { 'web' }
subject(:access) do
described_class.new(user, project, protocol, authentication_abilities: [:read_project, :download_code, :push_code])
end
describe "#check!" do
subject { access.check('git-receive-pack', ::Gitlab::GitAccess::ANY) }
before do
stub_licensed_features(design_management: true)
stub_feature_flags(design_managment: true)
end
context "when the user is allowed to manage designs" do
it { is_expected.to be_a(::Gitlab::GitAccessResult::Success) }
end
context "when the user is not allowed to manage designs" do
set(:user) { create(:user) }
it "raises an error " do
expect { subject }.to raise_error(::Gitlab::GitAccess::UnauthorizedError)
end
end
context "when the protocol is not web" do
let(:protocol) { 'https' }
it "raises an error " do
expect { subject }.to raise_error(::Gitlab::GitAccess::UnauthorizedError)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::GlRepository do
describe '.parse' do
set(:project) { create(:project, :repository) }
it 'parses a design gl_repository' do
expect(described_class.parse("design-#{project.id}")).to eq([project, EE::Gitlab::GlRepository::DESIGN])
end
end
end
......@@ -2,6 +2,7 @@
issues:
- epic_issue
- epic
- designs
milestone:
- boards
merge_requests:
......
# frozen_string_literal: true
require 'spec_helper'
describe DesignManagement::DesignCollection do
let(:issue) { create(:issue) }
subject(:collection) { described_class.new(issue) }
describe ".find_or_create_design!" do
it "finds an existing design" do
design = create(:design, issue: issue, filename: 'world.png')
expect(collection.find_or_create_design!(filename: 'world.png')).to eq(design)
end
it "creates a new design if one didn't exist" do
expect(issue.designs.size).to eq(0)
new_design = collection.find_or_create_design!(filename: 'world.png')
expect(issue.designs.size).to eq(1)
expect(new_design.filename).to eq('world.png')
expect(new_design.issue).to eq(issue)
end
it "only queries the designs once" do
create(:design, issue: issue, filename: 'hello.png')
create(:design, issue: issue, filename: 'world.jpg')
expect do
collection.find_or_create_design!(filename: 'hello.png')
collection.find_or_create_design!(filename: 'world.jpg')
end.not_to exceed_query_limit(1)
end
end
describe "#versions" do
it "includes versions for all designs" do
version_1 = create(:design_version)
version_2 = create(:design_version)
other_version = create(:design_version)
create(:design, issue: issue, versions: [version_1])
create(:design, issue: issue, versions: [version_2])
create(:design, versions: [other_version])
expect(collection.versions).to contain_exactly(version_1, version_2)
end
end
describe "#repository" do
it "builds a design repository" do
expect(collection.repository).to be_a(DesignManagement::Repository)
end
end
end
......@@ -6,7 +6,8 @@ describe DesignManagement::Design do
describe 'relations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:issue) }
it { is_expected.to have_and_belong_to_many(:versions) }
it { is_expected.to have_many(:design_versions) }
it { is_expected.to have_many(:versions) }
end
describe 'validations' do
......@@ -17,5 +18,47 @@ describe DesignManagement::Design do
it { is_expected.to validate_presence_of(:issue) }
it { is_expected.to validate_presence_of(:filename) }
it { is_expected.to validate_uniqueness_of(:filename).scoped_to(:issue_id) }
it "validates that the file is an image" do
design.filename = "thing.txt"
expect(design).not_to be_valid
expect(design.errors[:filename].first)
.to match /Only these extensions are supported/
end
end
describe "#new_design?" do
set(:versions) { create(:design_version) }
set(:design) { create(:design, versions: [versions]) }
it "is false when there are versions" do
expect(design.new_design?).to be_falsy
end
it "is true when there are no versions" do
expect(build(:design).new_design?).to be_truthy
end
it "does not cause extra queries when versions are loaded" do
design.versions.map(&:id)
expect { design.new_design? }.not_to exceed_query_limit(0)
end
it "causes a single query when there versions are not loaded" do
design.reload
expect { design.new_design? }.not_to exceed_query_limit(1)
end
end
describe "#full_path" do
it "builds the full path for a design" do
design = build(:design, filename: "hello.jpg")
expected_path = "#{DesignManagement.designs_directory}/issue-#{design.issue.iid}/hello.jpg"
expect(design.full_path).to eq(expected_path)
end
end
end
# frozen_string_literal: true
require 'rails_helper'
describe DesignManagement::DesignVersion do
describe 'relations' do
it { is_expected.to belong_to(:design) }
it { is_expected.to belong_to(:version) }
end
end
......@@ -3,7 +3,8 @@ require 'rails_helper'
describe DesignManagement::Version do
describe 'relations' do
it { is_expected.to have_and_belong_to_many(:designs) }
it { is_expected.to have_many(:design_versions) }
it { is_expected.to have_many(:designs).through(:design_versions) }
it 'constrains the designs relation correctly' do
design = create(:design)
......@@ -32,4 +33,38 @@ describe DesignManagement::Version do
it { is_expected.to validate_presence_of(:sha) }
it { is_expected.to validate_uniqueness_of(:sha).case_insensitive }
end
describe "scopes" do
describe ".for_designs" do
it "only returns versions related to the specified designs" do
version_1 = create(:design_version)
version_2 = create(:design_version)
_other_version = create(:design_version)
designs = [create(:design, versions: [version_1]),
create(:design, versions: [version_2])]
expect(described_class.for_designs(designs))
.to contain_exactly(version_1, version_2)
end
end
end
describe ".bulk_create" do
it "creates a version and links it to multiple designs" do
designs = create_list(:design, 2)
version = described_class.create_for_designs(designs, "abc")
expect(version.designs).to contain_exactly(*designs)
end
end
describe "#issue" do
it "gets the issue for the linked design" do
version = create(:design_version)
design = create(:design, versions: [version])
expect(version.issue).to eq(design.issue)
end
end
end
......@@ -28,6 +28,10 @@ describe Issue do
end
end
describe "relations" do
it { is_expected.to have_many(:designs) }
end
it_behaves_like 'an editable mentionable with EE-specific mentions' do
subject { create(:issue, project: create(:project, :repository)) }
......@@ -354,4 +358,14 @@ describe Issue do
end
end
end
describe "#design_collection" do
it "returns a design collection" do
issue = build(:issue)
collection = issue.design_collection
expect(collection).to be_a(DesignManagement::DesignCollection)
expect(collection.issue).to eq(issue)
end
end
end
......@@ -118,14 +118,15 @@ describe DesignManagement::DesignPolicy do
it_behaves_like "design abilities available for members"
context "for guests" do
context "for guests in private projects" do
let(:project) { create(:project, :private) }
let(:current_user) { guest }
it { is_expected.to be_allowed(*guest_design_abilities) }
it { is_expected.to be_disallowed(*developer_design_abilities) }
end
context "for anonymous users" do
context "for anonymous users in public projects" do
let(:current_user) { nil }
it { is_expected.to be_allowed(*guest_design_abilities) }
......
# frozen_string_literal: true
require "spec_helper"
describe "uploading designs" do
include GraphqlHelpers
let(:current_user) { create(:user) }
let(:issue) { create(:issue) }
let(:project) { issue.project }
let(:files) { [fixture_file_upload('spec/fixtures/dk.png')] }
let(:variables) { {} }
let(:mutation) do
input = {
project_path: project.full_path,
iid: issue.iid,
files: files
}.merge(variables)
graphql_mutation(:design_management_upload, input)
end
let(:mutation_response) { graphql_mutation_response(:design_management_upload) }
before do
stub_feature_flags(design_mangement: true)
stub_licensed_features(design_management: true)
project.add_developer(current_user)
end
it "returns an error if the user is not allowed to upload designs" do
post_graphql_mutation(mutation, current_user: create(:user))
expect(graphql_errors).not_to be_empty
end
it "responds with the created designs" do
post_graphql_mutation(mutation, current_user: current_user)
designs = mutation_response["designs"]
expect(designs.size).to eq(1)
expect(designs.first["filename"]).to eq("dk.png")
end
context "when the issue does not exist" do
let(:variables) { { iid: "123" } }
it "returns an error" do
post_graphql_mutation(mutation, current_user: create(:user))
expect(graphql_errors).not_to be_empty
end
end
context "when saving the designs raises an error" do
it "responds with errors" do
expect_next_instance_of(::DesignManagement::SaveDesignsService) do |service|
expect(service).to receive(:execute).and_return({ status: :error, message: "Something went wrong" })
end
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response["errors"].first).to eq("Something went wrong")
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe "Getting designs related to an issue" do
include GraphqlHelpers
set(:design) { create(:design) }
set(:current_user) { design.project.owner }
let(:query) do
design_node = <<~NODE
designs {
edges {
node {
filename
versions {
edges {
node {
sha
}
}
}
}
}
}
NODE
graphql_query_for(
"project",
{ "fullPath" => design.project.full_path },
query_graphql_field(
"issue",
{ iid: design.issue.iid },
query_graphql_field(
"designs", {}, design_node
)
)
)
end
let(:design_collection) do
graphql_data["project"]["issue"]["designs"]
end
let(:design_response) do
design_collection["designs"]["edges"].first["node"]
end
context "when the feature is not available" do
before do
stub_licensed_features(design_management: false)
stub_feature_flags(design_managment: false)
end
it_behaves_like "a working graphql query" do
before do
post_graphql(query, current_user: current_user)
end
end
it "returns no designs" do
post_graphql(query, current_user: current_user)
expect(design_collection).to be_nil
end
end
context "when the feature is available" do
before do
stub_licensed_features(design_management: true)
stub_feature_flags(deesign_managment: true)
end
it "returns the design filename" do
post_graphql(query, current_user: current_user)
expect(design_response["filename"]).to eq(design.filename)
end
context "with versions" do
let(:version) { create(:design_version) }
before do
design.versions << version
end
it "includes the version" do
post_graphql(query, current_user: current_user)
version_sha = design_response["versions"]["edges"].first["node"]["sha"]
expect(version_sha).to eq(version.sha)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe API::Internal do
describe "POST /internal/allowed" do
context "for design repositories" do
set(:user) { create(:user) }
set(:project) { create(:project) }
set(:key) { create(:key, user: user) }
let(:secret_token) { Gitlab::Shell.secret_token }
let(:gl_repository) { EE::Gitlab::GlRepository::DESIGN.identifier_for_subject(project) }
it "does not allow access" do
post(api("/internal/allowed"),
params: {
key_id: key.id,
project: project.full_path,
gl_repository: gl_repository,
secret_token: secret_token,
protocol: 'ssh'
})
expect(response).to have_gitlab_http_status(401)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe DesignManagement::SaveDesignsService do
let(:issue) { create(:issue, project: project) }
let(:project) { create(:project) }
let(:user) { project.owner }
let(:files) { [fixture_file_upload('spec/fixtures/rails_sample.jpg')] }
let(:design_repository) { EE::Gitlab::GlRepository::DESIGN.repository_accessor.call(project) }
let(:design_collection) { DesignManagement::DesignCollection.new(issue) }
subject(:service) { described_class.new(project, user, issue: issue, files: files) }
shared_examples 'a service error' do
it 'returns an error', :aggregate_failures do
expect(service.execute).to match(a_hash_including(status: :error))
end
end
describe "#execute" do
context "when the feature is not available" do
before do
stub_licensed_features(design_management: false)
end
it_behaves_like "a service error"
end
context "when the feature is available" do
before do
stub_licensed_features(design_management: true)
end
it "creates a design repository when it didn't exist" do
repository_exists = -> do
# Expire the memoized value as the service creates it's own instance
design_repository.expire_exists_cache
design_repository.exists?
end
expect { service.execute }.to change { repository_exists.call }.from(false).to(true)
end
it "creates a nice commit in the repository" do
service.execute
commit = design_repository.commit # Get the HEAD
expect(commit).not_to be_nil
expect(commit.author).to eq(user)
expect(commit.message).to include('rails_sample.jpg')
end
it 'creates a design & a version for the filename if it did not exist' do
expect(issue.designs.size).to eq(0)
updated_designs = service.execute[:designs]
expect(updated_designs.size).to eq(1)
expect(updated_designs.first.versions.size).to eq(1)
end
context 'when a design already exists' 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.
service.execute
end
it 'creates a new version for the existing design and updates the file' do
expect(issue.designs.size).to eq(1)
expect(DesignManagement::Version.for_designs(issue.designs).size).to eq(1)
updated_designs = service.execute[:designs]
expect(updated_designs.size).to eq(1)
expect(updated_designs.first.versions.size).to eq(2)
end
context 'when uploading a new design' do
it 'does not link the new version to the existing design' do
existing_design = issue.designs.first
updated_designs = described_class.new(project, user, issue: issue, files: [fixture_file_upload('spec/fixtures/dk.png')])
.execute[:designs]
expect(existing_design.versions.reload.size).to eq(1)
expect(updated_designs.size).to eq(1)
expect(updated_designs.first.versions.size).to eq(1)
end
end
end
context 'when uploading multiple files' do
let(:files) do
[
fixture_file_upload('spec/fixtures/rails_sample.jpg'),
fixture_file_upload('spec/fixtures/dk.png')
]
end
it 'creates 2 designs with a single version' do
expect { service.execute }.to change { issue.designs.count }.from(0).to(2)
expect(DesignManagement::Version.for_designs(issue.designs).size).to eq(1)
end
it 'creates a single commit' do
commit_count = -> do
design_repository.expire_all_method_caches
design_repository.commit_count
end
expect { service.execute }.to change { commit_count.call }.by(1)
end
it 'only does 3 gitaly calls', :request_store do
# Some unrelated calls that are usually cached or happen only once
service.__send__(:repository).create_if_not_exists
service.__send__(:repository).has_visible_content?
# An exists?, a check for existing blobs, default branch, and the creation of commits
expect { service.execute }.to change { Gitlab::GitalyClient.get_request_count }.by(4)
end
context 'when uploading too many files' do
let(:files) { Array.new(11) { fixture_file_upload('spec/fixtures/dk.png') } }
it "returns the correct error" do
expect(service.execute[:message]).to match(/only \d+ files are allowed simultaneously/i)
end
end
end
context 'when the user is not allowed to upload designs' do
let(:user) { create(:user) }
it_behaves_like "a service error"
end
context 'when creating the commit fails' do
before do
expect(service).to receive(:save_designs!).and_raise(Gitlab::Git::BaseError)
end
it_behaves_like "a service error"
end
context 'when creating the versions fails' do
before do
expect(service).to receive(:save_designs!).and_raise(ActiveRecord::RecordInvalid)
end
it_behaves_like "a service error"
end
context "when a design already existed in the repo but we didn't know about it in the database" do
before do
path = File.join(build(:design, issue: issue, filename: "rails_sample.jpg").full_path)
design_repository.create_if_not_exists
design_repository.create_file(user, path, "something fake",
branch_name: "master",
message: "Somehow created without being tracked in db")
end
it "creates the design and a new version for it" do
updated_designs = service.execute[:designs]
expect(updated_designs.first.filename).to eq("rails_sample.jpg")
expect(updated_designs.first.versions.size).to eq(1)
end
end
end
end
end
......@@ -127,4 +127,17 @@ describe PostReceive do
end
end
end
describe 'processing design changes' do
let(:gl_repository) { "design-#{project.id}" }
it 'does not do anything' do
worker = described_class.new
expect(worker).not_to receive(:process_wiki_changes)
expect(worker).not_to receive(:process_project_changes)
described_class.new.perform(gl_repository, key_id, base64_changes)
end
end
end
......@@ -118,6 +118,12 @@ module Gitlab
gitaly_repository_client.exists?
end
def create_repository
wrapped_gitaly_errors do
gitaly_repository_client.create_repository
end
end
# Returns an Array of branch names
# sorted by name ASC
def branch_names
......
......@@ -85,7 +85,7 @@ module Gitlab
check_push_access!
end
::Gitlab::GitAccessResult::Success.new(console_messages: check_for_console_messages(cmd))
success_result(cmd)
end
def guest_can_download_code?
......@@ -365,6 +365,10 @@ module Gitlab
protected
def success_result(cmd)
::Gitlab::GitAccessResult::Success.new(console_messages: check_for_console_messages(cmd))
end
def changes_list
@changes_list ||= Gitlab::ChangesList.new(changes == ANY ? [] : changes)
end
......
......@@ -41,3 +41,5 @@ module Gitlab
end
end
end
::Gitlab::GlRepository.prepend(::EE::Gitlab::GlRepository)
......@@ -8223,6 +8223,9 @@ msgstr ""
msgid "Only project members will be imported. Group members will be skipped."
msgstr ""
msgid "Only these extensions are supported: %{extension_list}"
msgstr ""
msgid "Oops, are you sure?"
msgstr ""
......
......@@ -4,104 +4,119 @@ describe Resolvers::IssuesResolver do
include GraphqlHelpers
let(:current_user) { create(:user) }
set(:project) { create(:project) }
set(:issue1) { create(:issue, project: project, state: :opened, created_at: 3.hours.ago, updated_at: 3.hours.ago) }
set(:issue2) { create(:issue, project: project, state: :closed, title: 'foo', created_at: 1.hour.ago, updated_at: 1.hour.ago, closed_at: 1.hour.ago) }
set(:label1) { create(:label, project: project) }
set(:label2) { create(:label, project: project) }
before do
project.add_developer(current_user)
create(:label_link, label: label1, target: issue1)
create(:label_link, label: label1, target: issue2)
create(:label_link, label: label2, target: issue2)
end
describe '#resolve' do
it 'finds all issues' do
expect(resolve_issues).to contain_exactly(issue1, issue2)
end
it 'filters by state' do
expect(resolve_issues(state: 'opened')).to contain_exactly(issue1)
expect(resolve_issues(state: 'closed')).to contain_exactly(issue2)
context "with a project" do
set(:project) { create(:project) }
set(:issue1) { create(:issue, project: project, state: :opened, created_at: 3.hours.ago, updated_at: 3.hours.ago) }
set(:issue2) { create(:issue, project: project, state: :closed, title: 'foo', created_at: 1.hour.ago, updated_at: 1.hour.ago, closed_at: 1.hour.ago) }
set(:label1) { create(:label, project: project) }
set(:label2) { create(:label, project: project) }
before do
project.add_developer(current_user)
create(:label_link, label: label1, target: issue1)
create(:label_link, label: label1, target: issue2)
create(:label_link, label: label2, target: issue2)
end
it 'filters by labels' do
expect(resolve_issues(label_name: [label1.title])).to contain_exactly(issue1, issue2)
expect(resolve_issues(label_name: [label1.title, label2.title])).to contain_exactly(issue2)
end
describe '#resolve' do
it 'finds all issues' do
expect(resolve_issues).to contain_exactly(issue1, issue2)
end
describe 'filters by created_at' do
it 'filters by created_before' do
expect(resolve_issues(created_before: 2.hours.ago)).to contain_exactly(issue1)
it 'filters by state' do
expect(resolve_issues(state: 'opened')).to contain_exactly(issue1)
expect(resolve_issues(state: 'closed')).to contain_exactly(issue2)
end
it 'filters by created_after' do
expect(resolve_issues(created_after: 2.hours.ago)).to contain_exactly(issue2)
it 'filters by labels' do
expect(resolve_issues(label_name: [label1.title])).to contain_exactly(issue1, issue2)
expect(resolve_issues(label_name: [label1.title, label2.title])).to contain_exactly(issue2)
end
end
describe 'filters by updated_at' do
it 'filters by updated_before' do
expect(resolve_issues(updated_before: 2.hours.ago)).to contain_exactly(issue1)
describe 'filters by created_at' do
it 'filters by created_before' do
expect(resolve_issues(created_before: 2.hours.ago)).to contain_exactly(issue1)
end
it 'filters by created_after' do
expect(resolve_issues(created_after: 2.hours.ago)).to contain_exactly(issue2)
end
end
it 'filters by updated_after' do
expect(resolve_issues(updated_after: 2.hours.ago)).to contain_exactly(issue2)
describe 'filters by updated_at' do
it 'filters by updated_before' do
expect(resolve_issues(updated_before: 2.hours.ago)).to contain_exactly(issue1)
end
it 'filters by updated_after' do
expect(resolve_issues(updated_after: 2.hours.ago)).to contain_exactly(issue2)
end
end
end
describe 'filters by closed_at' do
let!(:issue3) { create(:issue, project: project, state: :closed, closed_at: 3.hours.ago) }
describe 'filters by closed_at' do
let!(:issue3) { create(:issue, project: project, state: :closed, closed_at: 3.hours.ago) }
it 'filters by closed_before' do
expect(resolve_issues(closed_before: 2.hours.ago)).to contain_exactly(issue3)
it 'filters by closed_before' do
expect(resolve_issues(closed_before: 2.hours.ago)).to contain_exactly(issue3)
end
it 'filters by closed_after' do
expect(resolve_issues(closed_after: 2.hours.ago)).to contain_exactly(issue2)
end
end
it 'filters by closed_after' do
expect(resolve_issues(closed_after: 2.hours.ago)).to contain_exactly(issue2)
it 'searches issues' do
expect(resolve_issues(search: 'foo')).to contain_exactly(issue2)
end
end
it 'searches issues' do
expect(resolve_issues(search: 'foo')).to contain_exactly(issue2)
end
it 'sort issues' do
expect(resolve_issues(sort: 'created_desc')).to eq [issue2, issue1]
end
it 'sort issues' do
expect(resolve_issues(sort: 'created_desc')).to eq [issue2, issue1]
end
it 'returns issues user can see' do
project.add_guest(current_user)
it 'returns issues user can see' do
project.add_guest(current_user)
create(:issue, confidential: true)
create(:issue, confidential: true)
expect(resolve_issues).to contain_exactly(issue1, issue2)
end
expect(resolve_issues).to contain_exactly(issue1, issue2)
end
it 'finds a specific issue with iid' do
expect(resolve_issues(iid: issue1.iid)).to contain_exactly(issue1)
end
it 'finds a specific issue with iid' do
expect(resolve_issues(iid: issue1.iid)).to contain_exactly(issue1)
end
it 'finds a specific issue with iids' do
expect(resolve_issues(iids: issue1.iid)).to contain_exactly(issue1)
end
it 'finds a specific issue with iids' do
expect(resolve_issues(iids: issue1.iid)).to contain_exactly(issue1)
end
it 'finds multiple issues with iids' do
expect(resolve_issues(iids: [issue1.iid, issue2.iid]))
.to contain_exactly(issue1, issue2)
end
it 'finds multiple issues with iids' do
expect(resolve_issues(iids: [issue1.iid, issue2.iid]))
.to contain_exactly(issue1, issue2)
end
it 'finds only the issues within the project we are looking at' do
another_project = create(:project)
iids = [issue1, issue2].map(&:iid)
iids.each do |iid|
create(:issue, project: another_project, iid: iid)
end
it 'finds only the issues within the project we are looking at' do
another_project = create(:project)
iids = [issue1, issue2].map(&:iid)
expect(resolve_issues(iids: iids)).to contain_exactly(issue1, issue2)
end
end
end
iids.each do |iid|
create(:issue, project: another_project, iid: iid)
context "when passing a non existent, batch loaded project" do
let(:project) do
BatchLoader.for("non-existent-path").batch do |_fake_paths, loader, _|
loader.call("non-existent-path", nil)
end
end
expect(resolve_issues(iids: iids)).to contain_exactly(issue1, issue2)
it "returns nil without breaking" do
expect(resolve_issues(iids: ["don't", "break"])).to be_empty
end
end
......
......@@ -95,6 +95,12 @@ describe Gitlab::Git::Repository, :seed_helper do
end
end
describe '#create_repository' do
it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RepositoryService, :create_repository do
subject { repository.create_repository }
end
end
describe '#branch_names' do
subject { repository.branch_names }
......
......@@ -2487,4 +2487,69 @@ describe Repository do
repository.merge_base('master', 'fix')
end
end
describe '#create_if_not_exists' do
let(:project) { create(:project) }
let(:repository) { project.repository }
it 'creates the repository if it did not exist' do
expect { repository.create_if_not_exists }.to change { repository.exists? }.from(false).to(true)
end
it 'calls out to the repository client to create a repo' do
expect(repository.raw.gitaly_repository_client).to receive(:create_repository)
repository.create_if_not_exists
end
context 'it does nothing if the repository already existed' do
let(:project) { create(:project, :repository) }
it 'does nothing if the repository already existed' do
expect(repository.raw.gitaly_repository_client).not_to receive(:create_repository)
repository.create_if_not_exists
end
end
context 'when the repository exists but the cache is not up to date' do
let(:project) { create(:project, :repository) }
it 'does not raise errors' do
allow(repository).to receive(:exists?).and_return(false)
expect(repository.raw).to receive(:create_repository).and_call_original
expect { repository.create_if_not_exists }.not_to raise_error
end
end
end
describe "#blobs_metadata" do
set(:project) { create(:project, :repository) }
let(:repository) { project.repository }
def expect_metadata_blob(thing)
expect(thing).to be_a(Blob)
expect(thing.data).to be_empty
end
it "returns blob metadata in batch for HEAD" do
result = repository.blobs_metadata(["bar/branch-test.txt", "README.md", "does/not/exist"])
expect_metadata_blob(result.first)
expect_metadata_blob(result.second)
expect(result.size).to eq(2)
end
it "returns blob metadata for a specified ref" do
result = repository.blobs_metadata(["files/ruby/feature.rb"], "feature")
expect_metadata_blob(result.first)
end
it "performs a single gitaly call", :request_store do
expect { repository.blobs_metadata(["bar/branch-test.txt", "readme.txt", "does/not/exist"]) }
.to change { Gitlab::GitalyClient.get_request_count }.by(1)
end
end
end
......@@ -61,7 +61,14 @@ module GraphqlHelpers
def variables_for_mutation(name, input)
graphql_input = input.map { |name, value| [GraphqlHelpers.fieldnamerize(name), value] }.to_h
{ input_variable_name_for_mutation(name) => graphql_input }.to_json
result = { input_variable_name_for_mutation(name) => graphql_input }
# Avoid trying to serialize multipart data into JSON
if graphql_input.values.none? { |value| io_value?(value) }
result.to_json
else
result
end
end
def input_variable_name_for_mutation(mutation_name)
......@@ -162,6 +169,10 @@ module GraphqlHelpers
field.arguments.values.any? { |argument| argument.type.non_null? }
end
def io_value?(value)
Array.wrap(value).any? { |v| v.respond_to?(:to_io) }
end
def field_type(field)
field_type = field.type
......
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