Commit 335090fa authored by Sean Arnold's avatar Sean Arnold Committed by Alessio Caiazza

Add metric image uploading to incidents via REST API

Workhorse version bump to v8.55.0 to support direct_upload on the new API endpoint
parent b3d243da
......@@ -27,6 +27,10 @@ class UploadsController < ApplicationController
feature_category :not_owned
def self.model_classes
MODEL_CLASSES
end
def uploader_class
PersonalFileUploader
end
......@@ -99,7 +103,7 @@ class UploadsController < ApplicationController
end
def upload_model_class
MODEL_CLASSES[params[:model]] || raise(UnknownUploadModelError)
self.class.model_classes[params[:model]] || raise(UnknownUploadModelError)
end
def upload_model_class_has_mounts?
......@@ -112,3 +116,5 @@ class UploadsController < ApplicationController
upload_model_class.uploader_options.has_key?(upload_mount)
end
end
UploadsController.prepend_if_ee('EE::UploadsController')
......@@ -27,3 +27,5 @@ class IssuablePolicy < BasePolicy
prevent :award_emoji
end
end
IssuablePolicy.prepend_if_ee('EE::IssuablePolicy')
---
title: Add metric image uploading to incidents via REST API
merge_request: 46845
author:
type: added
# frozen_string_literal: true
class AddIssuableMetricImages < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
unless table_exists?(:issuable_metric_images)
with_lock_retries do
create_table :issuable_metric_images do |t|
t.references :issue, null: false, index: true, foreign_key: { on_delete: :cascade }
t.timestamps_with_timezone
t.integer :file_store, limit: 2
t.text :file, null: false
t.text :url
end
end
end
add_text_limit(:issuable_metric_images, :url, 255)
add_text_limit(:issuable_metric_images, :file, 255)
end
def down
with_lock_retries do
drop_table :issuable_metric_images
end
end
end
0172b71564e3d3e30c543890a4672b5a118f8053324b177fbbd9e83357ddf3a8
\ No newline at end of file
......@@ -12926,6 +12926,27 @@ CREATE SEQUENCE ip_restrictions_id_seq
ALTER SEQUENCE ip_restrictions_id_seq OWNED BY ip_restrictions.id;
CREATE TABLE issuable_metric_images (
id bigint NOT NULL,
issue_id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
file_store smallint,
file text NOT NULL,
url text,
CONSTRAINT check_5b3011e234 CHECK ((char_length(url) <= 255)),
CONSTRAINT check_7ed527062f CHECK ((char_length(file) <= 255))
);
CREATE SEQUENCE issuable_metric_images_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE issuable_metric_images_id_seq OWNED BY issuable_metric_images.id;
CREATE TABLE issuable_severities (
id bigint NOT NULL,
issue_id bigint NOT NULL,
......@@ -17999,6 +18020,8 @@ ALTER TABLE ONLY internal_ids ALTER COLUMN id SET DEFAULT nextval('internal_ids_
ALTER TABLE ONLY ip_restrictions ALTER COLUMN id SET DEFAULT nextval('ip_restrictions_id_seq'::regclass);
ALTER TABLE ONLY issuable_metric_images ALTER COLUMN id SET DEFAULT nextval('issuable_metric_images_id_seq'::regclass);
ALTER TABLE ONLY issuable_severities ALTER COLUMN id SET DEFAULT nextval('issuable_severities_id_seq'::regclass);
ALTER TABLE ONLY issuable_slas ALTER COLUMN id SET DEFAULT nextval('issuable_slas_id_seq'::regclass);
......@@ -19183,6 +19206,9 @@ ALTER TABLE ONLY internal_ids
ALTER TABLE ONLY ip_restrictions
ADD CONSTRAINT ip_restrictions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY issuable_metric_images
ADD CONSTRAINT issuable_metric_images_pkey PRIMARY KEY (id);
ALTER TABLE ONLY issuable_severities
ADD CONSTRAINT issuable_severities_pkey PRIMARY KEY (id);
......@@ -21077,6 +21103,8 @@ CREATE UNIQUE INDEX index_internal_ids_on_usage_and_project_id ON internal_ids U
CREATE INDEX index_ip_restrictions_on_group_id ON ip_restrictions USING btree (group_id);
CREATE INDEX index_issuable_metric_images_on_issue_id ON issuable_metric_images USING btree (issue_id);
CREATE UNIQUE INDEX index_issuable_severities_on_issue_id ON issuable_severities USING btree (issue_id);
CREATE UNIQUE INDEX index_issuable_slas_on_issue_id ON issuable_slas USING btree (issue_id);
......@@ -23838,6 +23866,9 @@ ALTER TABLE ONLY clusters_applications_knative
ALTER TABLE ONLY terraform_states
ADD CONSTRAINT fk_rails_558901b030 FOREIGN KEY (locked_by_user_id) REFERENCES users(id);
ALTER TABLE ONLY issuable_metric_images
ADD CONSTRAINT fk_rails_56417a5a7f FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE;
ALTER TABLE ONLY group_deploy_keys
ADD CONSTRAINT fk_rails_5682fc07f8 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;
......
......@@ -7910,6 +7910,11 @@ type EpicIssue implements CurrentUserTodos & Noteable {
last: Int
): LabelConnection
"""
Metric images associated to the issue.
"""
metricImages: [MetricImage!]
"""
Milestone of the issue
"""
......@@ -10522,6 +10527,11 @@ type Issue implements CurrentUserTodos & Noteable {
last: Int
): LabelConnection
"""
Metric images associated to the issue.
"""
metricImages: [MetricImage!]
"""
Milestone of the issue
"""
......@@ -13489,6 +13499,36 @@ type Metadata {
version: String!
}
"""
Represents a metric image upload
"""
type MetricImage {
"""
File name of the metric image
"""
fileName: String
"""
File path of the metric image
"""
filePath: String
"""
ID of the metric upload
"""
id: ID!
"""
Internal ID of the metric upload
"""
iid: ID!
"""
URL of the metric source
"""
url: String!
}
type MetricsDashboard {
"""
Annotations added to the dashboard
......
......@@ -21941,6 +21941,28 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "metricImages",
"description": "Metric images associated to the issue.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "MetricImage",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "milestone",
"description": "Milestone of the issue",
......@@ -28796,6 +28818,28 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "metricImages",
"description": "Metric images associated to the issue.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "MetricImage",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "milestone",
"description": "Milestone of the issue",
......@@ -37045,6 +37089,101 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "MetricImage",
"description": "Represents a metric image upload",
"fields": [
{
"name": "fileName",
"description": "File name of the metric image",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filePath",
"description": "File path of the metric image",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID of the metric upload",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "iid",
"description": "Internal ID of the metric upload",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "url",
"description": "URL of the metric source",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "MetricsDashboard",
......@@ -1324,6 +1324,7 @@ Relationship between an epic and an issue.
| `iid` | ID! | Internal ID of the issue |
| `iteration` | Iteration | Iteration of the issue |
| `labels` | LabelConnection | Labels of the issue |
| `metricImages` | MetricImage! => Array | Metric images associated to the issue. |
| `milestone` | Milestone | Milestone of the issue |
| `moved` | Boolean | Indicates if issue got moved from other project |
| `movedTo` | Issue | Updated Issue after it got moved to another project |
......@@ -1610,6 +1611,7 @@ Represents a recorded measurement (object count) for the Admins.
| `iid` | ID! | Internal ID of the issue |
| `iteration` | Iteration | Iteration of the issue |
| `labels` | LabelConnection | Labels of the issue |
| `metricImages` | MetricImage! => Array | Metric images associated to the issue. |
| `milestone` | Milestone | Milestone of the issue |
| `moved` | Boolean | Indicates if issue got moved from other project |
| `movedTo` | Issue | Updated Issue after it got moved to another project |
......@@ -2076,6 +2078,18 @@ Autogenerated return type of MergeRequestUpdate.
| `revision` | String! | Revision |
| `version` | String! | Version |
### MetricImage
Represents a metric image upload.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `fileName` | String | File name of the metric image |
| `filePath` | String | File path of the metric image |
| `id` | ID! | ID of the metric upload |
| `iid` | ID! | Internal ID of the metric upload |
| `url` | String! | URL of the metric source |
### MetricsDashboard
| Field | Type | Description |
......
......@@ -2085,3 +2085,73 @@ Example response:
To track which state was set, who did it, and when it happened, check out
[Resource state events API](resource_state_events.md#issues).
## Upload metric image
Available only for Incident issues.
```plaintext
POST /projects/:id/issues/:issue_iid/metric_images
```
| Attribute | Type | Required | Description |
|-------------|---------|----------|--------------------------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `issue_iid` | integer | yes | The internal ID of a project's issue |
| `file` | file | yes | The image file to be uploaded |
| `url` | string | no | The URL to view more metric info |
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" --form 'file=@/path/to/file.png' \
--form 'url=http://example.com' "https://gitlab.example.com/api/v4/projects/5/issues/93/metric_images"
```
Example response:
```json
{
"id": 23,
"created_at": "2020-11-13T00:06:18.084Z",
"filename": "file.png",
"file_path": "/uploads/-/system/issuable_metric_image/file/23/file.png",
"url": "http://example.com"
}
```
## List metric images
Available only for Incident issues.
```plaintext
GET /projects/:id/issues/:issue_iid/metric_images
```
| Attribute | Type | Required | Description |
|-------------|---------|----------|--------------------------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `issue_iid` | integer | yes | The internal ID of a project's issue |
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/93/metric_images"
```
Example response:
```json
[
{
"id": 17,
"created_at": "2020-11-12T20:07:58.156Z",
"filename": "sample_2054",
"file_path": "/uploads/-/system/issuable_metric_image/file/17/sample_2054.png",
"url": "example.com/metric"
},
{
"id": 18,
"created_at": "2020-11-12T20:14:26.441Z",
"filename": "sample_2054",
"file_path": "/uploads/-/system/issuable_metric_image/file/18/sample_2054.png",
"url": "example.com/metric"
}
]
```
......@@ -48,6 +48,7 @@ they are still not 100% standardized. You can see them below:
| CI Artifacts (CE) | yes | `shared/artifacts/:disk_hash[0..1]/:disk_hash[2..3]/:disk_hash/:year_:month_:date/:job_id/:job_artifact_id` (`:disk_hash` is SHA256 digest of `project_id`) | `JobArtifactUploader` | Ci::JobArtifact |
| LFS Objects (CE) | yes | `shared/lfs-objects/:hex/:hex/:object_hash` | `LfsObjectUploader` | LfsObject |
| External merge request diffs | yes | `shared/external-diffs/merge_request_diffs/mr-:parent_id/diff-:id` | `ExternalDiffUploader` | MergeRequestDiff |
| Issuable metric images | yes | `uploads/-/system/issuable_metric_image/file/:id/:filename` | `IssuableMetricImageUploader` | IssuableMetricImage |
CI Artifacts and LFS Objects behave differently in CE and EE. In CE they inherit the `GitlabUploader`
while in EE they inherit the `ObjectStorage` and store files in and S3 API compatible object store.
......
# frozen_string_literal: true
module EE
module UploadsController
extend ActiveSupport::Concern
EE_MODEL_CLASSES = {
'issuable_metric_image' => IssuableMetricImage
}.freeze
class_methods do
extend ::Gitlab::Utils::Override
override :model_classes
def model_classes
super.merge(EE_MODEL_CLASSES)
end
end
end
end
......@@ -22,7 +22,8 @@ module EE
def preloads
super.merge(
{
sla_due_at: [:issuable_sla]
sla_due_at: [:issuable_sla],
metric_images: [:metric_images]
}
)
end
......
......@@ -42,6 +42,9 @@ module EE
field :sla_due_at, ::Types::TimeType, null: true,
description: 'Timestamp of when the issue SLA expires.'
field :metric_images, [::Types::MetricImageType], null: true,
description: 'Metric images associated to the issue.'
end
end
end
......
# frozen_string_literal: true
module Types
class MetricImageType < BaseObject
graphql_name 'MetricImage'
description 'Represents a metric image upload'
authorize :read_issuable_metric_image
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the metric upload'
field :iid, GraphQL::ID_TYPE, null: false,
description: 'Internal ID of the metric upload'
field :url, GraphQL::STRING_TYPE, null: false,
description: 'URL of the metric source'
field :file_name, GraphQL::STRING_TYPE, null: true,
description: 'File name of the metric image',
method: :filename
field :file_path, GraphQL::STRING_TYPE, null: true,
description: 'File path of the metric image'
end
end
......@@ -27,8 +27,18 @@ module EE
supports_sla?
end
def metric_images_available?
return false unless IssuableMetricImage.available_for?(project)
supports_metric_images?
end
def supports_sla?
incident?
end
def supports_metric_images?
incident?
end
end
end
......@@ -55,6 +55,7 @@ module EE
has_one :status_page_published_incident, class_name: 'StatusPage::PublishedIncident', inverse_of: :issue
has_one :issuable_sla
has_many :metric_images, class_name: 'IssuableMetricImage'
has_many :vulnerability_links, class_name: 'Vulnerabilities::IssueLink', inverse_of: :issue
has_many :related_vulnerabilities, through: :vulnerability_links, source: :vulnerability
......
# frozen_string_literal: true
class IssuableMetricImage < ApplicationRecord
include Gitlab::FileTypeDetection
include FileStoreMounter
include WithUploads
belongs_to :issue, class_name: 'Issue', foreign_key: 'issue_id', inverse_of: :metric_images
attribute :file_store, :integer, default: -> { IssuableMetricImageUploader.default_store }
mount_file_store_uploader IssuableMetricImageUploader
validates :issue, presence: true
validates :file, presence: true
validate :validate_file_is_image
validates :url, length: { maximum: 255 }, public_url: { allow_blank: true }
MAX_FILE_SIZE = 1.megabyte.freeze
def self.available_for?(project)
project&.feature_available?(:incident_metric_upload)
end
def filename
@filename ||= file&.filename
end
def file_path
@file_path ||= begin
return file&.url unless file&.upload
# If we're using a CDN, we need to use the full URL
asset_host = ActionController::Base.asset_host
local_path = Gitlab::Routing.url_helpers.issuable_metric_image_upload_path(
filename: file.filename,
id: file.upload.model_id,
model: self.class.name.underscore,
mounted_as: 'file'
)
Gitlab::Utils.append_path(asset_host, local_path)
end
end
private
def valid_file_extensions
SAFE_IMAGE_EXT
end
def validate_file_is_image
unless image?
message = _('does not have a supported extension. Only %{extension_list} are supported') % {
extension_list: valid_file_extensions.to_sentence
}
errors.add(:file, message)
end
end
end
......@@ -93,6 +93,7 @@ class License < ApplicationRecord
group_saml
group_wikis
incident_sla
incident_metric_upload
ide_schema_config
issues_analytics
jira_issues_integration
......
# frozen_string_literal: true
module EE
module IssuablePolicy
extend ActiveSupport::Concern
prepended do
rule { can?(:read_issue) }.policy do
enable :read_issuable_metric_image
end
rule { can?(:create_issue) & can?(:update_issue) }.policy do
enable :upload_issuable_metric_image
end
end
end
end
# frozen_string_literal: true
class IssuableMetricImagePolicy < BasePolicy
delegate { @subject.issue }
end
# frozen_string_literal: true
module IncidentManagement
module Incidents
class UploadMetricService < BaseService
def initialize(issuable, current_user, params = {})
super
@issuable = issuable
@project = issuable&.project
@file = params.fetch(:file)
@url = params.fetch(:url, nil)
end
def execute
return ServiceResponse.error(message: "Not allowed!") unless issuable.metric_images_available? && can_upload_metrics?
upload_metric
ServiceResponse.success(payload: { metric: metric, issuable: issuable })
rescue ::ActiveRecord::RecordInvalid => e
ServiceResponse.error(message: e.message)
end
attr_reader :issuable, :project, :file, :url, :metric
private
def upload_metric
@metric = IssuableMetricImage.create!(
issue: issuable,
file: file,
url: url
)
end
def can_upload_metrics?
current_user&.can?(:upload_issuable_metric_image, issuable)
end
end
end
end
# frozen_string_literal: true
class IssuableMetricImageUploader < GitlabUploader
include RecordsUploads::Concern
include ObjectStorage::Concern
prepend ObjectStorage::Extension::RecordsUploads
include UploaderHelper
private
def dynamic_segment
File.join(model.class.underscore, mounted_as.to_s, model.id.to_s)
end
class << self
def default_store
object_store_enabled? ? ObjectStorage::Store::REMOTE : ObjectStorage::Store::LOCAL
end
end
end
# frozen_string_literal: true
scope path: :uploads do
# Issuable Metric Images
get "-/system/:model/:mounted_as/:id/:filename",
to: "uploads#show",
constraints: { model: /issuable_metric_image/, mounted_as: /file/, filename: %r{[^/]+} },
as: 'issuable_metric_image_upload'
end
# frozen_string_literal: true
module EE
module API
module Entities
class IssuableMetricImage < Grape::Entity
expose :id, :created_at
expose :filename, :file_path, :url
end
end
end
end
# frozen_string_literal: true
module EE
module API
module Issues
extend ActiveSupport::Concern
prepended do
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
namespace ':id/issues/:issue_iid/metric_images' do
post 'authorize' do
authorize!(:upload_issuable_metric_image, find_project_issue(request.params[:issue_iid]))
require_gitlab_workhorse!
::Gitlab::Workhorse.verify_api_request!(request.headers)
status 200
content_type ::Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE
params = {
has_length: false,
maximum_size: ::IssuableMetricImage::MAX_FILE_SIZE.to_i
}
::IssuableMetricImageUploader.workhorse_authorize(**params)
end
desc 'Upload a metric image for an issue' do
success Entities::IssuableMetricImage
end
params do
requires :file, type: ::API::Validations::Types::WorkhorseFile, desc: 'The image file to be uploaded'
optional :url, type: String, desc: 'The url to view more metric info'
end
post do
require_gitlab_workhorse!
bad_request!('File is too large') if max_file_size_exceeded?
issue = find_project_issue(params[:issue_iid])
upload = ::IncidentManagement::Incidents::UploadMetricService.new(
issue,
current_user,
params.slice(:file, :url)
).execute
if upload.success?
present upload.payload[:metric], with: Entities::IssuableMetricImage, current_user: current_user, project: user_project
else
render_api_error!(upload.message, 403)
end
end
desc 'Metric Images for issue'
get do
issue = find_project_issue(params[:issue_iid])
if can?(current_user, :read_issuable_metric_image, issue)
issue = ::IssuesFinder.new(
current_user,
project_id: user_project.id,
iids: [params[:issue_iid]]
).execute.first
present issue.metric_images, with: Entities::IssuableMetricImage
else
render_api_error!('Issue not found', 404)
end
end
end
end
helpers do
include ::API::Helpers::Packages::BasicAuthHelpers
def project
authorized_user_project
end
def max_file_size_exceeded?
params[:file].size > ::IssuableMetricImage::MAX_FILE_SIZE
end
end
end
end
end
end
# frozen_string_literal: true
module EE
module Gitlab
module Uploads
module MigrationHelper
extend ActiveSupport::Concern
EE_CATEGORIES = [%w(IssuableMetricImageUploader IssuableMetricImage :file)].freeze
class_methods do
extend ::Gitlab::Utils::Override
override :categories
def categories
super + EE_CATEGORIES
end
end
end
end
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :issuable_metric_image, class: 'IssuableMetricImage' do
association :issue, factory: :incident
url { generate(:url) }
trait :local do
file_store { ObjectStorage::Store::LOCAL }
end
after(:build) do |issuable_metric_image|
issuable_metric_image.file = fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg')
end
end
end
......@@ -10,6 +10,7 @@ RSpec.describe GitlabSchema.types['Issue'] do
it { expect(described_class).to have_graphql_field(:blocked) }
it { expect(described_class).to have_graphql_field(:blocked_by_count) }
it { expect(described_class).to have_graphql_field(:sla_due_at) }
it { expect(described_class).to have_graphql_field(:metric_images) }
context 'N+1 queries' do
let_it_be(:user) { create(:user) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['MetricImage'] do
it { expect(described_class.graphql_name).to eq('MetricImage') }
it { expect(described_class).to require_graphql_authorizations(:read_issuable_metric_image) }
it 'has the expected fields' do
expected_fields = %w[
id iid url file_name file_path
]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe IssuableMetricImage do
subject { build(:issuable_metric_image) }
describe 'associations' do
it { is_expected.to belong_to(:issue) }
end
describe 'validation' do
let(:txt_file) { fixture_file_upload('spec/fixtures/doc_sample.txt', 'text/plain') }
let(:img_file) { fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg') }
it { is_expected.not_to allow_value(txt_file).for(:file) }
it { is_expected.to allow_value(img_file).for(:file) }
describe 'url' do
it { is_expected.not_to allow_value('test').for(:url) }
it { is_expected.not_to allow_value('www.gitlab.com').for(:url) }
it { is_expected.to allow_value('').for(:url) }
it { is_expected.to allow_value('http://www.gitlab.com').for(:url) }
it { is_expected.to allow_value('https://www.gitlab.com').for(:url) }
end
end
end
......@@ -13,6 +13,7 @@ RSpec.describe Issue do
it { is_expected.to have_many(:resource_weight_events) }
it { is_expected.to have_many(:resource_iteration_events) }
it { is_expected.to have_one(:issuable_sla) }
it { is_expected.to have_many(:metric_images) }
end
describe 'modules' do
......
......@@ -498,6 +498,170 @@ RSpec.describe API::Issues, :mailer do
end
end
describe 'POST /projects/:id/issues/:issue_id/metric_images' do
include WorkhorseHelpers
using RSpec::Parameterized::TableSyntax
let(:issue) { create(:incident, project: project) }
let(:file) { fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg') }
let(:file_name) { 'rails_sample.jpg' }
let(:url) { 'http://gitlab.com' }
let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') }
let(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } }
let(:params) { { url: url } }
subject do
workhorse_finalize(
api("/projects/#{project.id}/issues/#{issue.iid}/metric_images", user2),
method: :post,
file_key: :file,
params: params.merge(file: file),
headers: workhorse_header,
send_rewritten_field: true
)
end
shared_examples 'can_upload_metric_image' do
it 'creates a new metric image' do
subject
expect(response).to have_gitlab_http_status(:created)
expect(json_response['filename']).to eq(file_name)
expect(json_response['url']).to eq(url)
expect(json_response['file_path']).to match(%r{/uploads/-/system/issuable_metric_image/file/[\d+]/#{file_name}})
expect(json_response['created_at']).not_to be_nil
expect(json_response['id']).not_to be_nil
end
end
shared_examples 'unauthorized_upload' do
it 'disallows the upload' do
subject
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response['message']).to eq('Not allowed!')
end
end
where(:user_role, :own_issue, :expected_status) do
:guest | true | :can_upload_metric_image
:guest | false | :unauthorized_upload
:reporter | true | :can_upload_metric_image
:reporter | false | :can_upload_metric_image
end
with_them do
before do
# Local storage
stub_uploads_object_storage(IssuableMetricImageUploader, enabled: false)
allow_any_instance_of(IssuableMetricImageUploader).to receive(:file_storage?).and_return(true)
stub_licensed_features(incident_metric_upload: true)
project.send("add_#{user_role}", user2)
own_issue ? issue.update!(author: user2) : issue.update!(author: user)
end
it_behaves_like "#{params[:expected_status]}"
end
context 'file size too large' do
before do
stub_licensed_features(incident_metric_upload: true)
allow_next_instance_of(UploadedFile) do |upload_file|
allow(upload_file).to receive(:size).and_return(IssuableMetricImage::MAX_FILE_SIZE + 1)
end
end
it 'returns an error' do
subject
expect(response).to have_gitlab_http_status(:bad_request)
expect(response.body).to match(/File is too large/)
end
end
context 'object storage enabled' do
before do
# Object storage
stub_licensed_features(incident_metric_upload: true)
stub_uploads_object_storage(IssuableMetricImageUploader)
allow_any_instance_of(IssuableMetricImageUploader).to receive(:file_storage?).and_return(false)
project.add_developer(user2)
end
it_behaves_like 'can_upload_metric_image'
it 'uploads to remote storage' do
subject
last_upload = IssuableMetricImage.last.uploads.last
expect(last_upload.store).to eq(::ObjectStorage::Store::REMOTE)
end
end
end
describe 'GET /projects/:id/issues/:issue_id/metric_images' do
using RSpec::Parameterized::TableSyntax
let_it_be(:project) do
create(:project, :private, creator_id: user.id, namespace: user.namespace)
end
let_it_be(:issue) { create(:incident, project: project) }
let!(:image) { create(:issuable_metric_image, issue: issue) }
subject { get api("/projects/#{project.id}/issues/#{issue.iid}/metric_images", user2) }
shared_examples 'can_read_metric_image' do
it 'can read the metric images' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.first).to match(
{
id: image.id,
created_at: image.created_at.strftime('%Y-%m-%eT%H:%M:%S.%LZ'),
filename: image.filename,
file_path: image.file_path,
url: image.url
}.with_indifferent_access
)
end
end
shared_examples 'unauthorized_read' do
it 'cannot read the metric images' do
subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
where(:user_role, :own_issue, :issue_confidential, :expected_status) do
:not_member | false | false | :unauthorized_read
:guest | false | true | :unauthorized_read
:guest | true | false | :can_read_metric_image
:guest | false | false | :can_read_metric_image
:reporter | true | false | :can_read_metric_image
:reporter | false | false | :can_read_metric_image
end
with_them do
before do
stub_licensed_features(incident_metric_upload: true)
project.send("add_#{user_role}", user2) unless user_role == :not_member
issue.update!(confidential: true) if issue_confidential
own_issue ? issue.update!(author: user2) : issue.update!(author: user)
end
it_behaves_like "#{params[:expected_status]}"
end
end
private
def epic_issue_response_for(epic_issue)
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe IncidentManagement::Incidents::UploadMetricService do
subject(:service) { described_class.new(issuable, current_user, params) }
let_it_be_with_refind(:project) { create(:project) }
let_it_be_with_refind(:issuable) { create(:incident, project: project) }
let_it_be_with_refind(:current_user) { create(:user) }
let(:params) do
{
file: fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg'),
url: 'https://www.gitlab.com'
}
end
describe '#execute' do
subject { service.execute }
shared_examples 'uploads the metric' do
it 'uploads the metric and returns a success' do
expect { subject }.to change(IssuableMetricImage, :count).by(1)
expect(subject.success?).to eq(true)
expect(subject.payload).to match({ metric: instance_of(IssuableMetricImage), issuable: issuable })
end
end
shared_examples 'no metric saved, an error given' do |message|
it 'returns an error and does not upload', :aggregate_failures do
expect(subject.success?).to eq(false)
expect(subject.message).to match(a_string_matching(message))
expect(IssuableMetricImage.count).to eq(0)
end
end
context 'user does not have permissions' do
it_behaves_like 'no metric saved, an error given', 'Not allowed!'
end
context 'user has permissions' do
before_all do
project.add_developer(current_user)
end
it_behaves_like 'no metric saved, an error given', 'Not allowed!'
context 'with license' do
before do
stub_licensed_features(incident_metric_upload: true)
end
it_behaves_like 'uploads the metric'
context 'no url given' do
let(:params) do
{
file: fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg')
}
end
it_behaves_like 'uploads the metric'
end
context 'record invalid' do
let(:params) do
{
file: fixture_file_upload('spec/fixtures/doc_sample.txt', 'text/plain'),
url: 'https://www.gitlab.com'
}
end
it_behaves_like 'no metric saved, an error given', /Validation failed/
end
context 'user is guest' do
before_all do
project.add_guest(current_user)
end
it_behaves_like 'no metric saved, an error given', 'Not allowed!'
context 'guest is author of issuable' do
let(:issuable) { create(:incident, project: project, author: current_user) }
it_behaves_like 'uploads the metric'
end
end
end
end
end
end
# frozen_string_literal: true
require 'rake_helper'
RSpec.describe 'gitlab:uploads:migrate and migrate_to_local rake tasks' do
let(:model_class) { nil }
let(:uploader_class) { nil }
let(:mounted_as) { nil }
let(:batch_size) { 3 }
before do
stub_env('MIGRATION_BATCH_SIZE', batch_size.to_s)
stub_uploads_object_storage(uploader_class)
Rake.application.rake_require 'tasks/gitlab/uploads/migrate'
allow(ObjectStorage::MigrateUploadsWorker).to receive(:perform_async)
end
context 'for IssuableMetricImageUploader' do
let(:uploader_class) { IssuableMetricImageUploader }
let(:model_class) { IssuableMetricImage }
let(:mounted_as) { :file }
before do
issue = create(:issue)
create_list(:issuable_metric_image, 10, :local, issue: issue)
end
it_behaves_like 'enqueue upload migration jobs in batch', batch: 4
end
end
......@@ -435,3 +435,5 @@ module API
end
end
end
API::Issues.prepend_if_ee('EE::API::Issues')
......@@ -75,3 +75,5 @@ module Gitlab
end
end
end
Gitlab::Uploads::MigrationHelper.prepend_if_ee('EE::Gitlab::Uploads::MigrationHelper')
......@@ -28,6 +28,7 @@ issues:
- events
- merge_requests_closing_issues
- metrics
- metric_images
- timelogs
- issuable_severity
- issuable_sla
......
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