summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Dangerfile12
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue8
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js31
-rw-r--r--app/assets/javascripts/ide/lib/diff/controller.js9
-rw-r--r--app/assets/javascripts/ide/lib/diff/diff.js9
-rw-r--r--app/assets/javascripts/ide/lib/editor.js5
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js18
-rw-r--r--app/assets/javascripts/ide/utils.js6
-rw-r--r--app/controllers/admin/runners_controller.rb2
-rw-r--r--app/finders/admin/runners_finder.rb71
-rw-r--r--app/finders/ci/runners_finder.rb92
-rw-r--r--app/finders/uploader_finder.rb34
-rw-r--r--app/models/ci/instance_variable.rb42
-rw-r--r--app/models/ci/runner.rb11
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml2
-rw-r--r--app/views/shared/empty_states/_snippets.html.haml4
-rw-r--r--changelogs/unreleased/add-group-runners-finder.yml5
-rw-r--r--doc/user/project/status_page/index.md15
-rw-r--r--lib/api/validations/validators/file_path.rb2
-rw-r--r--lib/gitlab/gfm/uploads_rewriter.rb15
-rw-r--r--lib/gitlab/process_memory_cache/helper.rb51
-rw-r--r--lib/gitlab/utils.rb3
-rw-r--r--qa/qa.rb5
-rw-r--r--qa/qa/page/project/menu.rb7
-rw-r--r--qa/qa/page/project/snippet/new.rb29
-rw-r--r--qa/qa/resource/project_snippet.rb29
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb101
-rw-r--r--spec/finders/admin/runners_finder_spec.rb81
-rw-r--r--spec/finders/ci/runners_finder_spec.rb304
-rw-r--r--spec/finders/uploader_finder_spec.rb55
-rw-r--r--spec/frontend/droplab/plugins/input_setter_spec.js259
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js26
-rw-r--r--spec/frontend/ide/lib/common/model_spec.js72
-rw-r--r--spec/frontend/ide/lib/diff/diff_spec.js8
-rw-r--r--spec/frontend/ide/lib/editor_options_spec.js11
-rw-r--r--spec/frontend/ide/lib/editor_spec.js1
-rw-r--r--spec/frontend/ide/utils_spec.js21
-rw-r--r--spec/javascripts/droplab/plugins/input_setter_spec.js214
-rw-r--r--spec/lib/gitlab/gfm/uploads_rewriter_spec.rb18
-rw-r--r--spec/lib/gitlab/process_memory_cache/helper_spec.rb52
-rw-r--r--spec/models/ci/instance_variable_spec.rb13
41 files changed, 1259 insertions, 494 deletions
diff --git a/Dangerfile b/Dangerfile
index 7879c14b31e..c428b55f33a 100644
--- a/Dangerfile
+++ b/Dangerfile
@@ -7,8 +7,12 @@ danger.import_plugin('danger/plugins/helper.rb')
danger.import_plugin('danger/plugins/roulette.rb')
danger.import_plugin('danger/plugins/changelog.rb')
-unless helper.release_automation?
- GitlabDanger.new(helper.gitlab_helper).rule_names.each do |file|
- danger.import_dangerfile(path: File.join('danger', file))
- end
+return if helper.release_automation?
+
+gitlab_danger = GitlabDanger.new(helper.gitlab_helper)
+
+gitlab_danger.rule_names.each do |file|
+ danger.import_dangerfile(path: File.join('danger', file))
end
+
+markdown("**If needed, you can retry the [`danger-review` job](#{ENV['CI_JOB_URL']}) that generated this comment.**") if gitlab_danger.ci?
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 2a8ed076ae2..55290ba7779 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -14,7 +14,6 @@ import Editor from '../lib/editor';
import FileTemplatesBar from './file_templates/bar.vue';
import { __ } from '~/locale';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
-import { addFinalNewline } from '../utils';
export default {
components: {
@@ -32,7 +31,6 @@ export default {
return {
content: '',
images: {},
- addFinalNewline: true,
};
},
computed: {
@@ -253,10 +251,8 @@ export default {
const monacoModel = model.getModel();
const content = monacoModel.getValue();
- this.changeFileContent({
- path: file.path,
- content: this.addFinalNewline ? addFinalNewline(content, monacoModel.getEOL()) : content,
- });
+ this.changeFileContent({ path: file.path, content });
+ this.setFileEOL({ eol: this.model.eol });
});
// Handle Cursor Position
diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js
index a15f04075d9..4c743076fb2 100644
--- a/app/assets/javascripts/ide/lib/common/model.js
+++ b/app/assets/javascripts/ide/lib/common/model.js
@@ -1,6 +1,8 @@
import { editor as monacoEditor, Uri } from 'monaco-editor';
import Disposable from './disposable';
import eventHub from '../../eventhub';
+import { trimTrailingWhitespace, insertFinalNewline } from '../../utils';
+import { defaultModelOptions } from '../editor_options';
export default class Model {
constructor(file, head = null) {
@@ -8,6 +10,7 @@ export default class Model {
this.file = file;
this.head = head;
this.content = file.content !== '' || file.deleted ? file.content : file.raw;
+ this.options = { ...defaultModelOptions };
this.disposable.add(
(this.originalModel = monacoEditor.createModel(
@@ -94,8 +97,32 @@ export default class Model {
this.getModel().setValue(content);
}
+ updateOptions(obj = {}) {
+ Object.assign(this.options, obj);
+ this.model.updateOptions(obj);
+ this.applyCustomOptions();
+ }
+
+ applyCustomOptions() {
+ this.updateNewContent(
+ Object.entries(this.options).reduce((content, [key, value]) => {
+ switch (key) {
+ case 'endOfLine':
+ this.model.pushEOL(value);
+ return this.model.getValue();
+ case 'insertFinalNewline':
+ return value ? insertFinalNewline(content) : content;
+ case 'trimTrailingWhitespace':
+ return value ? trimTrailingWhitespace(content) : content;
+ default:
+ return content;
+ }
+ }, this.model.getValue()),
+ );
+ }
+
dispose() {
- this.disposable.dispose();
+ if (!this.model.isDisposed()) this.applyCustomOptions();
this.events.forEach(cb => {
if (typeof cb === 'function') cb();
@@ -106,5 +133,7 @@ export default class Model {
eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose);
eventHub.$off(`editor.update.model.content.${this.file.key}`, this.updateContent);
eventHub.$off(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent);
+
+ this.disposable.dispose();
}
}
diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js
index 234a7f903a1..35fcda6a6c5 100644
--- a/app/assets/javascripts/ide/lib/diff/controller.js
+++ b/app/assets/javascripts/ide/lib/diff/controller.js
@@ -50,10 +50,15 @@ export default class DirtyDiffController {
}
computeDiff(model) {
+ const originalModel = model.getOriginalModel();
+ const newModel = model.getModel();
+
+ if (originalModel.isDisposed() || newModel.isDisposed()) return;
+
this.dirtyDiffWorker.postMessage({
path: model.path,
- originalContent: model.getOriginalModel().getValue(),
- newContent: model.getModel().getValue(),
+ originalContent: originalModel.getValue(),
+ newContent: newModel.getValue(),
});
}
diff --git a/app/assets/javascripts/ide/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js
index 29e29d7fcd3..3a456b7c4d6 100644
--- a/app/assets/javascripts/ide/lib/diff/diff.js
+++ b/app/assets/javascripts/ide/lib/diff/diff.js
@@ -1,8 +1,15 @@
import { diffLines } from 'diff';
+import { defaultDiffOptions } from '../editor_options';
+// See: https://gitlab.com/gitlab-org/frontend/rfcs/-/issues/20
// eslint-disable-next-line import/prefer-default-export
export const computeDiff = (originalContent, newContent) => {
- const changes = diffLines(originalContent, newContent);
+ // prevent EOL changes from highlighting the entire file
+ const changes = diffLines(
+ originalContent.replace(/\r\n/g, '\n'),
+ newContent.replace(/\r\n/g, '\n'),
+ defaultDiffOptions,
+ );
let lineNumber = 1;
return changes.reduce((acc, change) => {
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
index 25224abd77c..141efd86884 100644
--- a/app/assets/javascripts/ide/lib/editor.js
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -5,7 +5,7 @@ import DecorationsController from './decorations/controller';
import DirtyDiffController from './diff/controller';
import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
-import editorOptions, { defaultEditorOptions } from './editor_options';
+import { editorOptions, defaultEditorOptions, defaultDiffEditorOptions } from './editor_options';
import { themes } from './themes';
import languages from './languages';
import keymap from './keymap.json';
@@ -73,8 +73,7 @@ export default class Editor {
this.disposable.add(
(this.instance = monacoEditor.createDiffEditor(domElement, {
...this.options,
- quickSuggestions: false,
- occurrencesHighlight: false,
+ ...defaultDiffEditorOptions,
renderSideBySide: Editor.renderSideBySide(domElement),
readOnly,
renderLineHighlight: readOnly ? 'all' : 'none',
diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js
index dac2a8e8b51..422b2b9776c 100644
--- a/app/assets/javascripts/ide/lib/editor_options.js
+++ b/app/assets/javascripts/ide/lib/editor_options.js
@@ -9,7 +9,23 @@ export const defaultEditorOptions = {
wordWrap: 'on',
};
-export default [
+export const defaultDiffOptions = {
+ ignoreWhitespace: false,
+};
+
+export const defaultDiffEditorOptions = {
+ quickSuggestions: false,
+ occurrencesHighlight: false,
+ ignoreTrimWhitespace: false,
+};
+
+export const defaultModelOptions = {
+ endOfLine: 0,
+ insertFinalNewline: true,
+ trimTrailingWhitespace: false,
+};
+
+export const editorOptions = [
{
readOnly: model => Boolean(model.file.file_lock),
quickSuggestions: model => !(model.language === 'markdown'),
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index 64be1f59887..2d1a7c24c59 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -77,7 +77,11 @@ export function registerLanguages(def, ...defs) {
export const otherSide = side => (side === SIDE_RIGHT ? SIDE_LEFT : SIDE_RIGHT);
-export function addFinalNewline(content, eol = '\n') {
+export function trimTrailingWhitespace(content) {
+ return content.replace(/[^\S\r\n]+$/gm, '');
+}
+
+export function insertFinalNewline(content, eol = '\n') {
return content.slice(-eol.length) !== eol ? `${content}${eol}` : content;
}
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 4639d8adfe0..2449fa3128c 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -4,7 +4,7 @@ class Admin::RunnersController < Admin::ApplicationController
before_action :runner, except: [:index, :tag_list]
def index
- finder = Admin::RunnersFinder.new(params: params)
+ finder = Ci::RunnersFinder.new(current_user: current_user, params: params)
@runners = finder.execute
@active_runners_count = Ci::Runner.online.count
@sort = finder.sort_key
diff --git a/app/finders/admin/runners_finder.rb b/app/finders/admin/runners_finder.rb
deleted file mode 100644
index b2799565f57..00000000000
--- a/app/finders/admin/runners_finder.rb
+++ /dev/null
@@ -1,71 +0,0 @@
-# frozen_string_literal: true
-
-class Admin::RunnersFinder < UnionFinder
- NUMBER_OF_RUNNERS_PER_PAGE = 30
-
- def initialize(params:)
- @params = params
- end
-
- def execute
- search!
- filter_by_status!
- filter_by_runner_type!
- filter_by_tag_list!
- sort!
- paginate!
-
- @runners.with_tags
- end
-
- def sort_key
- if @params[:sort] == 'contacted_asc'
- 'contacted_asc'
- else
- 'created_date'
- end
- end
-
- private
-
- def search!
- @runners =
- if @params[:search].present?
- Ci::Runner.search(@params[:search])
- else
- Ci::Runner.all
- end
- end
-
- def filter_by_status!
- filter_by!(:status_status, Ci::Runner::AVAILABLE_STATUSES)
- end
-
- def filter_by_runner_type!
- filter_by!(:type_type, Ci::Runner::AVAILABLE_TYPES)
- end
-
- def filter_by_tag_list!
- tag_list = @params[:tag_name].presence
-
- if tag_list
- @runners = @runners.tagged_with(tag_list)
- end
- end
-
- def sort!
- @runners = @runners.order_by(sort_key)
- end
-
- def paginate!
- @runners = @runners.page(@params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
- end
-
- def filter_by!(scope_name, available_scopes)
- scope = @params[scope_name]
-
- if scope.present? && available_scopes.include?(scope)
- @runners = @runners.public_send(scope) # rubocop:disable GitlabSecurity/PublicSend
- end
- end
-end
diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb
new file mode 100644
index 00000000000..54d9d9522e0
--- /dev/null
+++ b/app/finders/ci/runners_finder.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module Ci
+ class RunnersFinder < UnionFinder
+ include Gitlab::Allowable
+
+ NUMBER_OF_RUNNERS_PER_PAGE = 30
+
+ def initialize(current_user:, group: nil, params:)
+ @params = params
+ @group = group
+ @current_user = current_user
+ end
+
+ def execute
+ search!
+ filter_by_status!
+ filter_by_runner_type!
+ filter_by_tag_list!
+ sort!
+ paginate!
+
+ @runners.with_tags
+
+ rescue Gitlab::Access::AccessDeniedError
+ Ci::Runner.none
+ end
+
+ def sort_key
+ if @params[:sort] == 'contacted_asc'
+ 'contacted_asc'
+ else
+ 'created_date'
+ end
+ end
+
+ private
+
+ def search!
+ @group ? group_runners : all_runners
+
+ @runners = @runners.search(@params[:search]) if @params[:search].present?
+ end
+
+ def all_runners
+ raise Gitlab::Access::AccessDeniedError unless @current_user&.admin?
+
+ @runners = Ci::Runner.all
+ end
+
+ def group_runners
+ raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :admin_group, @group)
+
+ # Getting all runners from the group itself and all its descendants
+ descentant_projects = Project.for_group_and_its_subgroups(@group)
+
+ @runners = Ci::Runner.belonging_to_group_or_project(@group.self_and_descendants, descentant_projects)
+ end
+
+ def filter_by_status!
+ filter_by!(:status_status, Ci::Runner::AVAILABLE_STATUSES)
+ end
+
+ def filter_by_runner_type!
+ filter_by!(:type_type, Ci::Runner::AVAILABLE_TYPES)
+ end
+
+ def filter_by_tag_list!
+ tag_list = @params[:tag_name].presence
+
+ if tag_list
+ @runners = @runners.tagged_with(tag_list)
+ end
+ end
+
+ def sort!
+ @runners = @runners.order_by(sort_key)
+ end
+
+ def paginate!
+ @runners = @runners.page(@params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
+ end
+
+ def filter_by!(scope_name, available_scopes)
+ scope = @params[scope_name]
+
+ if scope.present? && available_scopes.include?(scope)
+ @runners = @runners.public_send(scope) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+end
diff --git a/app/finders/uploader_finder.rb b/app/finders/uploader_finder.rb
new file mode 100644
index 00000000000..0d1de0d56fd
--- /dev/null
+++ b/app/finders/uploader_finder.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class UploaderFinder
+ # Instantiates a a new FileUploader
+ # FileUploader can be opened via .open agnostic of storage type
+ # Arguments correspond to Upload.secret, Upload.model_type and Upload.file_path
+ # Returns a FileUploader with uploaded file retrieved into the object state
+ def initialize(project, secret, file_path)
+ @project = project
+ @secret = secret
+ @file_path = file_path
+ end
+
+ def execute
+ prevent_path_traversal_attack!
+ retrieve_file_state!
+
+ uploader
+ rescue ::Gitlab::Utils::PathTraversalAttackError
+ nil # no-op if for incorrect files
+ end
+
+ def prevent_path_traversal_attack!
+ Gitlab::Utils.check_path_traversal!(@file_path)
+ end
+
+ def retrieve_file_state!
+ uploader.retrieve_from_store!(@file_path)
+ end
+
+ def uploader
+ @uploader ||= FileUploader.new(@project, secret: @secret)
+ end
+end
diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb
index c674f76d229..557f3a63280 100644
--- a/app/models/ci/instance_variable.rb
+++ b/app/models/ci/instance_variable.rb
@@ -3,6 +3,7 @@
module Ci
class InstanceVariable < ApplicationRecord
extend Gitlab::Ci::Model
+ extend Gitlab::ProcessMemoryCache::Helper
include Ci::NewHasVariable
include Ci::Maskable
@@ -13,7 +14,8 @@ module Ci
}
scope :unprotected, -> { where(protected: false) }
- after_commit { self.class.touch_redis_cache_timestamp }
+
+ after_commit { self.class.invalidate_memory_cache(:ci_instance_variable_data) }
class << self
def all_cached
@@ -24,10 +26,6 @@ module Ci
cached_data[:unprotected]
end
- def touch_redis_cache_timestamp(time = Time.current.to_f)
- shared_backend.write(:ci_instance_variable_changed_at, time)
- end
-
private
def cached_data
@@ -37,40 +35,6 @@ module Ci
{ all: all_records, unprotected: all_records.reject(&:protected?) }
end
end
-
- def fetch_memory_cache(key, &payload)
- cache = process_backend.read(key)
-
- if cache && !stale_cache?(cache)
- cache[:data]
- else
- store_cache(key, &payload)
- end
- end
-
- def stale_cache?(cache_info)
- shared_timestamp = shared_backend.read(:ci_instance_variable_changed_at)
- return true unless shared_timestamp
-
- shared_timestamp.to_f > cache_info[:cached_at].to_f
- end
-
- def store_cache(key)
- data = yield
- time = Time.current.to_f
-
- process_backend.write(key, data: data, cached_at: time)
- touch_redis_cache_timestamp(time)
- data
- end
-
- def shared_backend
- Rails.cache
- end
-
- def process_backend
- Gitlab::ProcessMemoryCache.cache_backend
- end
end
end
end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 03ff952b435..30ea383bd73 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -81,6 +81,17 @@ module Ci
joins(:runner_namespaces).where(ci_runner_namespaces: { namespace_id: groups })
}
+ scope :belonging_to_group_or_project, -> (group_id, project_id) {
+ groups = ::Group.where(id: group_id)
+
+ group_runners = joins(:runner_namespaces).where(ci_runner_namespaces: { namespace_id: groups })
+ project_runners = joins(:runner_projects).where(ci_runner_projects: { project_id: project_id })
+
+ union_sql = ::Gitlab::SQL::Union.new([group_runners, project_runners]).to_sql
+
+ from("(#{union_sql}) #{table_name}")
+ }
+
scope :belonging_to_parent_group_of_project, -> (project_id) {
project_groups = ::Group.joins(:projects).where(projects: { id: project_id })
hierarchy_groups = Gitlab::ObjectHierarchy.new(project_groups).base_and_ancestors
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index c738493507f..409e602f306 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -319,7 +319,7 @@
- if project_nav_tab? :snippets
= nav_link(controller: :snippets) do
- = link_to project_snippets_path(@project), class: 'shortcuts-snippets' do
+ = link_to project_snippets_path(@project), class: 'shortcuts-snippets', data: { qa_selector: 'snippets_link' } do
.nav-icon-container
= sprite_icon('snippet')
%span.nav-item-name
diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml
index efd9bceedc5..db8da50d868 100644
--- a/app/views/shared/empty_states/_snippets.html.haml
+++ b/app/views/shared/empty_states/_snippets.html.haml
@@ -3,7 +3,7 @@
.row.empty-state.mt-0
.col-12
.svg-content
- = image_tag 'illustrations/snippets_empty.svg'
+ = image_tag 'illustrations/snippets_empty.svg', data: { qa_selector: 'svg_content' }
.text-content.text-center.pt-0
- if current_user
%h4
@@ -12,7 +12,7 @@
= s_('SnippetsEmptyState|Store, share, and embed small pieces of code and text.')
.mt-2<
- if button_path
- = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link'
+ = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' }
= link_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), class: 'btn btn-default', title: s_('SnippetsEmptyState|Documentation')
- else
%h4.text-center= s_('SnippetsEmptyState|There are no snippets to show.')
diff --git a/changelogs/unreleased/add-group-runners-finder.yml b/changelogs/unreleased/add-group-runners-finder.yml
new file mode 100644
index 00000000000..a90eb2c2e42
--- /dev/null
+++ b/changelogs/unreleased/add-group-runners-finder.yml
@@ -0,0 +1,5 @@
+---
+title: Add finder for group-level runners
+merge_request: 29283
+author: Arthur de Lapertosa Lisboa
+type: added
diff --git a/doc/user/project/status_page/index.md b/doc/user/project/status_page/index.md
index 8ebfb638894..c4d8a71320e 100644
--- a/doc/user/project/status_page/index.md
+++ b/doc/user/project/status_page/index.md
@@ -71,7 +71,8 @@ The incident detail page shows detailed information about a particular incident.
- Status on the incident, including when the incident was last updated.
- The incident title, including any emojis.
-- The description of the incident, including emojis and static images.
+- The description of the incident, including emojis.
+- Any file attachments provided in the incident description or comments with a valid image extension. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/205166) in GitLab 13.1.
- A chronological ordered list of updates to the incident.
![Status Page detail](../img/status_page_detail_v12_10.png)
@@ -108,3 +109,15 @@ Anyone with access to view the Issue can add an Emoji Award to a comment, so you
### Changing the Incident status
To change the incident status from `open` to `closed`, close the incident issue within GitLab. This will then be updated shortly on the Status Page website.
+
+## Attachment storage
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/205166) in GitLab 13.1.
+
+Beginning with GitLab 13.1, files attached to incident issue descriptions or
+comments are published and unpublished to the status page storage as part of
+the [publication flow](#how-it-works).
+
+### Limit
+
+Only 5000 attachments per issue will be transferred to the status page.
diff --git a/lib/api/validations/validators/file_path.rb b/lib/api/validations/validators/file_path.rb
index 93a20e5bf7d..fee71373170 100644
--- a/lib/api/validations/validators/file_path.rb
+++ b/lib/api/validations/validators/file_path.rb
@@ -8,7 +8,7 @@ module API
path = params[attr_name]
Gitlab::Utils.check_path_traversal!(path)
- rescue StandardError
+ rescue ::Gitlab::Utils::PathTraversalAttackError
raise Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)],
message: "should be a valid file path"
end
diff --git a/lib/gitlab/gfm/uploads_rewriter.rb b/lib/gitlab/gfm/uploads_rewriter.rb
index 23af0a9bb18..08321d5fda6 100644
--- a/lib/gitlab/gfm/uploads_rewriter.rb
+++ b/lib/gitlab/gfm/uploads_rewriter.rb
@@ -22,9 +22,10 @@ module Gitlab
return @text unless needs_rewrite?
@text.gsub(@pattern) do |markdown|
- Gitlab::Utils.check_path_traversal!($~[:file])
+ file = find_file($~[:secret], $~[:file])
+ # No file will be returned for a path traversal
+ next if file.nil?
- file = find_file(@source_project, $~[:secret], $~[:file])
break markdown unless file.try(:exists?)
klass = target_parent.is_a?(Namespace) ? NamespaceFileUploader : FileUploader
@@ -47,7 +48,7 @@ module Gitlab
def files
referenced_files = @text.scan(@pattern).map do
- find_file(@source_project, $~[:secret], $~[:file])
+ find_file($~[:secret], $~[:file])
end
referenced_files.compact.select(&:exists?)
@@ -57,12 +58,8 @@ module Gitlab
markdown.starts_with?("!")
end
- private
-
- def find_file(project, secret, file)
- uploader = FileUploader.new(project, secret: secret)
- uploader.retrieve_from_store!(file)
- uploader
+ def find_file(secret, file_name)
+ UploaderFinder.new(@source_project, secret, file_name).execute
end
end
end
diff --git a/lib/gitlab/process_memory_cache/helper.rb b/lib/gitlab/process_memory_cache/helper.rb
new file mode 100644
index 00000000000..ee4b81a9a19
--- /dev/null
+++ b/lib/gitlab/process_memory_cache/helper.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Gitlab
+ class ProcessMemoryCache
+ module Helper
+ def fetch_memory_cache(key, &payload)
+ cache = cache_backend.read(key)
+
+ if cache && !stale_cache?(key, cache)
+ cache[:data]
+ else
+ store_cache(key, &payload)
+ end
+ end
+
+ def invalidate_memory_cache(key)
+ touch_cache_timestamp(key)
+ end
+
+ private
+
+ def touch_cache_timestamp(key, time = Time.current.to_f)
+ shared_backend.write(key, time)
+ end
+
+ def stale_cache?(key, cache_info)
+ shared_timestamp = shared_backend.read(key)
+ return true unless shared_timestamp
+
+ shared_timestamp.to_f > cache_info[:cached_at].to_f
+ end
+
+ def store_cache(key)
+ data = yield
+ time = Time.current.to_f
+
+ cache_backend.write(key, data: data, cached_at: time)
+ touch_cache_timestamp(key, time)
+ data
+ end
+
+ def shared_backend
+ Rails.cache
+ end
+
+ def cache_backend
+ ::Gitlab::ProcessMemoryCache.cache_backend
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb
index d46601fa2e8..5aafcb5bb8a 100644
--- a/lib/gitlab/utils.rb
+++ b/lib/gitlab/utils.rb
@@ -3,6 +3,7 @@
module Gitlab
module Utils
extend self
+ PathTraversalAttackError ||= Class.new(StandardError)
# Ensure that the relative path will not traverse outside the base directory
# We url decode the path to avoid passing invalid paths forward in url encoded format.
@@ -17,7 +18,7 @@ module Gitlab
path.end_with?("#{File::SEPARATOR}..") ||
(!allowed_absolute && Pathname.new(path).absolute?)
- raise StandardError.new("Invalid path")
+ raise PathTraversalAttackError.new('Invalid path')
end
path
diff --git a/qa/qa.rb b/qa/qa.rb
index b905c41a2ce..b754ff5cbcd 100644
--- a/qa/qa.rb
+++ b/qa/qa.rb
@@ -86,6 +86,7 @@ module QA
autoload :Snippet, 'qa/resource/snippet'
autoload :Tag, 'qa/resource/tag'
autoload :ProjectMember, 'qa/resource/project_member'
+ autoload :ProjectSnippet, 'qa/resource/project_snippet'
autoload :UserGPG, 'qa/resource/user_gpg'
autoload :Visibility, 'qa/resource/visibility'
@@ -332,6 +333,10 @@ module QA
module WebIDE
autoload :Edit, 'qa/page/project/web_ide/edit'
end
+
+ module Snippet
+ autoload :New, 'qa/page/project/snippet/new'
+ end
end
module Profile
diff --git a/qa/qa/page/project/menu.rb b/qa/qa/page/project/menu.rb
index 5967213a52b..e2984bf72bd 100644
--- a/qa/qa/page/project/menu.rb
+++ b/qa/qa/page/project/menu.rb
@@ -16,6 +16,7 @@ module QA
element :activity_link
element :merge_requests_link
element :wiki_link
+ element :snippets_link
end
def click_merge_requests
@@ -35,6 +36,12 @@ module QA
click_element(:activity_link)
end
end
+
+ def click_snippets
+ within_sidebar do
+ click_element(:snippets_link)
+ end
+ end
end
end
end
diff --git a/qa/qa/page/project/snippet/new.rb b/qa/qa/page/project/snippet/new.rb
new file mode 100644
index 00000000000..1463dfc2c7f
--- /dev/null
+++ b/qa/qa/page/project/snippet/new.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module QA
+ module Page
+ module Project
+ module Snippet
+ class New < Page::Dashboard::Snippet::New
+ include Component::LazyLoader
+ view 'app/views/shared/empty_states/_snippets.html.haml' do
+ element :create_first_snippet_link
+ element :svg_content
+ end
+
+ def click_create_first_snippet
+ finished_loading?
+ # The svg takes a fraction of a second to load after which the
+ # "New snippet" button shifts up a bit. This can cause
+ # webdriver to miss the hit so we wait for the svg to load before
+ # clicking the button.
+ within_element(:svg_content) do
+ has_element?(:js_lazy_loaded)
+ end
+ click_element(:create_first_snippet_link)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/resource/project_snippet.rb b/qa/qa/resource/project_snippet.rb
new file mode 100644
index 00000000000..ce4be6445f1
--- /dev/null
+++ b/qa/qa/resource/project_snippet.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module QA
+ module Resource
+ class ProjectSnippet < Snippet
+ attribute :project do
+ Project.fabricate_via_api! do |resource|
+ resource.name = 'project-with-snippets'
+ end
+ end
+
+ def fabricate!
+ project.visit!
+
+ Page::Project::Menu.perform { |sidebar| sidebar.click_snippets }
+
+ Page::Project::Snippet::New.perform do |new_snippet|
+ new_snippet.click_create_first_snippet
+ new_snippet.fill_title(@title)
+ new_snippet.fill_description(@description)
+ new_snippet.set_visibility(@visibility)
+ new_snippet.fill_file_name(@file_name)
+ new_snippet.fill_file_content(@file_content)
+ new_snippet.click_create_snippet_button
+ end
+ end
+ end
+ end
+end
diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb
new file mode 100644
index 00000000000..a3011550db8
--- /dev/null
+++ b/qa/qa/specs/features/browser_ui/3_create/snippet/clone_push_pull_project_snippet_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+module QA
+ context 'Create' do
+ describe 'Version control for project snippets' do
+ let(:new_file) { 'new_snippet_file' }
+ let(:changed_content) { 'changes' }
+ let(:commit_message) { 'Changes to snippets' }
+ let(:added_content) { 'updated ' }
+ let(:branch_name) { 'master' }
+
+ let(:snippet) do
+ Resource::ProjectSnippet.fabricate! do |snippet|
+ snippet.file_name = new_file
+ end
+ end
+
+ let(:ssh_key) do
+ Resource::SSHKey.fabricate_via_api! do |resource|
+ resource.title = "my key title #{Time.now.to_f}"
+ end
+ end
+
+ let(:repository_uri_http) do
+ snippet
+ Page::Dashboard::Snippet::Show.perform(&:get_repository_uri_http)
+ end
+
+ let(:repository_uri_ssh) do
+ ssh_key
+ snippet
+ Page::Dashboard::Snippet::Show.perform(&:get_repository_uri_ssh)
+ end
+
+ before do
+ Flow::Login.sign_in
+ end
+
+ it 'clones, pushes, and pulls a project snippet over HTTP, edits via UI' do
+ Resource::Repository::Push.fabricate! do |push|
+ push.repository_http_uri = repository_uri_http
+ push.file_name = new_file
+ push.file_content = changed_content
+ push.commit_message = commit_message
+ push.new_branch = false
+ end
+
+ page.refresh
+ verify_changes_in_ui
+
+ Page::Dashboard::Snippet::Show.perform(&:click_edit_button)
+
+ Page::Dashboard::Snippet::Edit.perform do |snippet|
+ snippet.add_to_file_content(added_content)
+ snippet.save_changes
+ end
+
+ Git::Repository.perform do |repository|
+ repository.init_repository
+ repository.pull(repository_uri_http, branch_name)
+
+ expect(repository.commits.size).to eq 3
+ expect(repository.commits.first).to include 'Update snippet'
+ expect(repository.file_content(new_file)).to include "#{added_content}#{changed_content}"
+ end
+ end
+
+ it 'clones, pushes, and pulls a project snippet over SSH, deletes via UI' do
+ Resource::Repository::Push.fabricate! do |push|
+ push.repository_ssh_uri = repository_uri_ssh
+ push.ssh_key = ssh_key
+ push.file_name = new_file
+ push.file_content = changed_content
+ push.commit_message = commit_message
+ push.new_branch = false
+ end
+
+ page.refresh
+ verify_changes_in_ui
+ Page::Dashboard::Snippet::Show.perform(&:click_delete_button)
+
+ # attempt to pull a deleted snippet, get a missing repository error
+ Git::Repository.perform do |repository|
+ repository.uri = repository_uri_ssh
+ repository.use_ssh_key(ssh_key)
+ repository.init_repository
+
+ expect { repository.pull(repository_uri_ssh, branch_name) }
+ .to raise_error(QA::Git::Repository::RepositoryCommandError, /[fatal: Could not read from remote repository.]+/)
+ end
+ end
+
+ def verify_changes_in_ui
+ Page::Dashboard::Snippet::Show.perform do |snippet|
+ expect(snippet).to have_file_name(new_file)
+ expect(snippet).to have_file_content(changed_content)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/finders/admin/runners_finder_spec.rb b/spec/finders/admin/runners_finder_spec.rb
deleted file mode 100644
index 94ccb398801..00000000000
--- a/spec/finders/admin/runners_finder_spec.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe Admin::RunnersFinder do
- describe '#execute' do
- context 'with empty params' do
- it 'returns all runners' do
- runner1 = create :ci_runner, active: true
- runner2 = create :ci_runner, active: false
-
- expect(described_class.new(params: {}).execute).to match_array [runner1, runner2]
- end
- end
-
- context 'filter by search term' do
- it 'calls Ci::Runner.search' do
- expect(Ci::Runner).to receive(:search).with('term').and_call_original
-
- described_class.new(params: { search: 'term' }).execute
- end
- end
-
- context 'filter by status' do
- it 'calls the corresponding scope on Ci::Runner' do
- expect(Ci::Runner).to receive(:paused).and_call_original
-
- described_class.new(params: { status_status: 'paused' }).execute
- end
- end
-
- context 'filter by runner type' do
- it 'calls the corresponding scope on Ci::Runner' do
- expect(Ci::Runner).to receive(:project_type).and_call_original
-
- described_class.new(params: { type_type: 'project_type' }).execute
- end
- end
-
- context 'filter by tag_name' do
- it 'calls the corresponding scope on Ci::Runner' do
- expect(Ci::Runner).to receive(:tagged_with).with(%w[tag1 tag2]).and_call_original
-
- described_class.new(params: { tag_name: %w[tag1 tag2] }).execute
- end
- end
-
- context 'sort' do
- context 'without sort param' do
- it 'sorts by created_at' do
- runner1 = create :ci_runner, created_at: '2018-07-12 07:00'
- runner2 = create :ci_runner, created_at: '2018-07-12 08:00'
- runner3 = create :ci_runner, created_at: '2018-07-12 09:00'
-
- expect(described_class.new(params: {}).execute).to eq [runner3, runner2, runner1]
- end
- end
-
- context 'with sort param' do
- it 'sorts by specified attribute' do
- runner1 = create :ci_runner, contacted_at: 1.minute.ago
- runner2 = create :ci_runner, contacted_at: 3.minutes.ago
- runner3 = create :ci_runner, contacted_at: 2.minutes.ago
-
- expect(described_class.new(params: { sort: 'contacted_asc' }).execute).to eq [runner2, runner3, runner1]
- end
- end
- end
-
- context 'paginate' do
- it 'returns the runners for the specified page' do
- stub_const('Admin::RunnersFinder::NUMBER_OF_RUNNERS_PER_PAGE', 1)
- runner1 = create :ci_runner, created_at: '2018-07-12 07:00'
- runner2 = create :ci_runner, created_at: '2018-07-12 08:00'
-
- expect(described_class.new(params: { page: 1 }).execute).to eq [runner2]
- expect(described_class.new(params: { page: 2 }).execute).to eq [runner1]
- end
- end
- end
-end
diff --git a/spec/finders/ci/runners_finder_spec.rb b/spec/finders/ci/runners_finder_spec.rb
new file mode 100644
index 00000000000..0c319d8cc0f
--- /dev/null
+++ b/spec/finders/ci/runners_finder_spec.rb
@@ -0,0 +1,304 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::RunnersFinder do
+ context 'admin' do
+ let_it_be(:admin) { create(:user, :admin) }
+
+ describe '#execute' do
+ context 'with empty params' do
+ it 'returns all runners' do
+ runner1 = create :ci_runner, active: true
+ runner2 = create :ci_runner, active: false
+
+ expect(described_class.new(current_user: admin, params: {}).execute).to match_array [runner1, runner2]
+ end
+ end
+
+ context 'filter by search term' do
+ it 'calls Ci::Runner.search' do
+ expect(Ci::Runner).to receive(:search).with('term').and_call_original
+
+ described_class.new(current_user: admin, params: { search: 'term' }).execute
+ end
+ end
+
+ context 'filter by status' do
+ it 'calls the corresponding scope on Ci::Runner' do
+ expect(Ci::Runner).to receive(:paused).and_call_original
+
+ described_class.new(current_user: admin, params: { status_status: 'paused' }).execute
+ end
+ end
+
+ context 'filter by runner type' do
+ it 'calls the corresponding scope on Ci::Runner' do
+ expect(Ci::Runner).to receive(:project_type).and_call_original
+
+ described_class.new(current_user: admin, params: { type_type: 'project_type' }).execute
+ end
+ end
+
+ context 'filter by tag_name' do
+ it 'calls the corresponding scope on Ci::Runner' do
+ expect(Ci::Runner).to receive(:tagged_with).with(%w[tag1 tag2]).and_call_original
+
+ described_class.new(current_user: admin, params: { tag_name: %w[tag1 tag2] }).execute
+ end
+ end
+
+ context 'sort' do
+ context 'without sort param' do
+ it 'sorts by created_at' do
+ runner1 = create :ci_runner, created_at: '2018-07-12 07:00'
+ runner2 = create :ci_runner, created_at: '2018-07-12 08:00'
+ runner3 = create :ci_runner, created_at: '2018-07-12 09:00'
+
+ expect(described_class.new(current_user: admin, params: {}).execute).to eq [runner3, runner2, runner1]
+ end
+ end
+
+ context 'with sort param' do
+ it 'sorts by specified attribute' do
+ runner1 = create :ci_runner, contacted_at: 1.minute.ago
+ runner2 = create :ci_runner, contacted_at: 3.minutes.ago
+ runner3 = create :ci_runner, contacted_at: 2.minutes.ago
+
+ expect(described_class.new(current_user: admin, params: { sort: 'contacted_asc' }).execute).to eq [runner2, runner3, runner1]
+ end
+ end
+ end
+
+ context 'paginate' do
+ it 'returns the runners for the specified page' do
+ stub_const('Ci::RunnersFinder::NUMBER_OF_RUNNERS_PER_PAGE', 1)
+ runner1 = create :ci_runner, created_at: '2018-07-12 07:00'
+ runner2 = create :ci_runner, created_at: '2018-07-12 08:00'
+
+ expect(described_class.new(current_user: admin, params: { page: 1 }).execute).to eq [runner2]
+ expect(described_class.new(current_user: admin, params: { page: 2 }).execute).to eq [runner1]
+ end
+ end
+
+ context 'non admin user' do
+ it 'returns no runners' do
+ user = create :user
+ create :ci_runner, active: true
+ create :ci_runner, active: false
+
+ expect(described_class.new(current_user: user, params: {}).execute).to be_empty
+ end
+ end
+
+ context 'user is nil' do
+ it 'returns no runners' do
+ user = nil
+ create :ci_runner, active: true
+ create :ci_runner, active: false
+
+ expect(described_class.new(current_user: user, params: {}).execute).to be_empty
+ end
+ end
+ end
+ end
+
+ context 'group' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:sub_group_1) { create(:group, parent: group) }
+ let_it_be(:sub_group_2) { create(:group, parent: group) }
+ let_it_be(:sub_group_3) { create(:group, parent: sub_group_1) }
+ let_it_be(:sub_group_4) { create(:group, parent: sub_group_3) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:project_2) { create(:project, group: group) }
+ let_it_be(:project_3) { create(:project, group: sub_group_1) }
+ let_it_be(:project_4) { create(:project, group: sub_group_2) }
+ let_it_be(:project_5) { create(:project, group: sub_group_3) }
+ let_it_be(:project_6) { create(:project, group: sub_group_4) }
+ let_it_be(:runner_group) { create(:ci_runner, :group, contacted_at: 12.minutes.ago) }
+ let_it_be(:runner_sub_group_1) { create(:ci_runner, :group, active: false, contacted_at: 11.minutes.ago) }
+ let_it_be(:runner_sub_group_2) { create(:ci_runner, :group, contacted_at: 10.minutes.ago) }
+ let_it_be(:runner_sub_group_3) { create(:ci_runner, :group, contacted_at: 9.minutes.ago) }
+ let_it_be(:runner_sub_group_4) { create(:ci_runner, :group, contacted_at: 8.minutes.ago) }
+ let_it_be(:runner_project_1) { create(:ci_runner, :project, contacted_at: 7.minutes.ago, projects: [project])}
+ let_it_be(:runner_project_2) { create(:ci_runner, :project, contacted_at: 6.minutes.ago, projects: [project_2])}
+ let_it_be(:runner_project_3) { create(:ci_runner, :project, contacted_at: 5.minutes.ago, description: 'runner_project_search', projects: [project, project_2])}
+ let_it_be(:runner_project_4) { create(:ci_runner, :project, contacted_at: 4.minutes.ago, projects: [project_3])}
+ let_it_be(:runner_project_5) { create(:ci_runner, :project, contacted_at: 3.minutes.ago, tag_list: %w[runner_tag], projects: [project_4])}
+ let_it_be(:runner_project_6) { create(:ci_runner, :project, contacted_at: 2.minutes.ago, projects: [project_5])}
+ let_it_be(:runner_project_7) { create(:ci_runner, :project, contacted_at: 1.minute.ago, projects: [project_6])}
+
+ let(:params) { {} }
+
+ before do
+ group.runners << runner_group
+ sub_group_1.runners << runner_sub_group_1
+ sub_group_2.runners << runner_sub_group_2
+ sub_group_3.runners << runner_sub_group_3
+ sub_group_4.runners << runner_sub_group_4
+ end
+
+ describe '#execute' do
+ subject { described_class.new(current_user: user, group: group, params: params).execute }
+
+ context 'no params' do
+ before do
+ group.add_owner(user)
+ end
+
+ it 'returns all runners' do
+ expect(subject).to eq([runner_project_7, runner_project_6, runner_project_5,
+ runner_project_4, runner_project_3, runner_project_2,
+ runner_project_1, runner_sub_group_4, runner_sub_group_3,
+ runner_sub_group_2, runner_sub_group_1, runner_group])
+ end
+ end
+
+ context 'with sort param' do
+ let(:params) { { sort: 'contacted_asc' } }
+
+ before do
+ group.add_owner(user)
+ end
+
+ it 'sorts by specified attribute' do
+ expect(subject).to eq([runner_group, runner_sub_group_1, runner_sub_group_2,
+ runner_sub_group_3, runner_sub_group_4, runner_project_1,
+ runner_project_2, runner_project_3, runner_project_4,
+ runner_project_5, runner_project_6, runner_project_7])
+ end
+ end
+
+ context 'paginate' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:runners) do
+ [[runner_project_7, runner_project_6, runner_project_5],
+ [runner_project_4, runner_project_3, runner_project_2],
+ [runner_project_1, runner_sub_group_4, runner_sub_group_3],
+ [runner_sub_group_2, runner_sub_group_1, runner_group]]
+ end
+
+ where(:page, :index) do
+ 1 | 0
+ 2 | 1
+ 3 | 2
+ 4 | 3
+ end
+
+ before do
+ stub_const('Ci::RunnersFinder::NUMBER_OF_RUNNERS_PER_PAGE', 3)
+
+ group.add_owner(user)
+ end
+
+ with_them do
+ let(:params) { { page: page } }
+
+ it 'returns the runners for the specified page' do
+ expect(subject).to eq(runners[index])
+ end
+ end
+ end
+
+ context 'filter by search term' do
+ let(:params) { { search: 'runner_project_search' } }
+
+ before do
+ group.add_owner(user)
+ end
+
+ it 'returns correct runner' do
+ expect(subject).to eq([runner_project_3])
+ end
+ end
+
+ context 'filter by status' do
+ let(:params) { { status_status: 'paused' } }
+
+ before do
+ group.add_owner(user)
+ end
+
+ it 'returns correct runner' do
+ expect(subject).to eq([runner_sub_group_1])
+ end
+ end
+
+ context 'filter by tag_name' do
+ let(:params) { { tag_name: %w[runner_tag] } }
+
+ before do
+ group.add_owner(user)
+ end
+
+ it 'returns correct runner' do
+ expect(subject).to eq([runner_project_5])
+ end
+ end
+
+ context 'filter by runner type' do
+ let(:params) { { type_type: 'project_type' } }
+
+ before do
+ group.add_owner(user)
+ end
+
+ it 'returns correct runners' do
+ expect(subject).to eq([runner_project_7, runner_project_6,
+ runner_project_5, runner_project_4,
+ runner_project_3, runner_project_2, runner_project_1])
+ end
+ end
+
+ context 'user has no access to runners' do
+ where(:user_permission) do
+ [:maintainer, :developer, :reporter, :guest]
+ end
+
+ with_them do
+ before do
+ create(:group_member, user_permission, group: group, user: user)
+ end
+
+ it 'returns no runners' do
+ expect(subject).to be_empty
+ end
+ end
+ end
+
+ context 'user with no access' do
+ it 'returns no runners' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'user is nil' do
+ let_it_be(:user) { nil }
+
+ it 'returns no runners' do
+ expect(subject).to be_empty
+ end
+ end
+ end
+
+ describe '#sort_key' do
+ subject { described_class.new(current_user: user, group: group, params: params).sort_key }
+
+ context 'no params' do
+ it 'returns created_date' do
+ expect(subject).to eq('created_date')
+ end
+ end
+
+ context 'with params' do
+ let(:params) { { sort: 'contacted_asc' } }
+
+ it 'returns contacted_asc' do
+ expect(subject).to eq('contacted_asc')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/finders/uploader_finder_spec.rb b/spec/finders/uploader_finder_spec.rb
new file mode 100644
index 00000000000..bb6d83289ef
--- /dev/null
+++ b/spec/finders/uploader_finder_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe UploaderFinder do
+ describe '#execute' do
+ let(:project) { build(:project) }
+ let(:upload) { create(:upload, :issuable_upload, :with_file) }
+ let(:secret) { upload.secret }
+ let(:file_name) { upload.path }
+
+ subject { described_class.new(project, secret, file_name).execute }
+
+ before do
+ upload.save
+ end
+
+ context 'when successful' do
+ before do
+ allow_next_instance_of(FileUploader) do |uploader|
+ allow(uploader).to receive(:retrieve_from_store!).with(upload.path).and_return(uploader)
+ end
+ end
+
+ it 'gets the file-like uploader' do
+ expect(subject).to be_an_instance_of(FileUploader)
+ expect(subject.model).to eq(project)
+ expect(subject.secret).to eq(secret)
+ end
+ end
+
+ context 'when path traversal in file name' do
+ before do
+ upload.path = '/uploads/11111111111111111111111111111111/../../../../../../../../../../../../../../etc/passwd)'
+ upload.save
+ end
+
+ it 'returns nil' do
+ expect(subject).to be(nil)
+ end
+ end
+
+ context 'when unexpected failure' do
+ before do
+ allow_next_instance_of(FileUploader) do |uploader|
+ allow(uploader).to receive(:retrieve_from_store!).and_raise(StandardError)
+ end
+ end
+
+ it 'returns nil when unexpected error is raised' do
+ expect { subject }.to raise_error(StandardError)
+ end
+ end
+ end
+end
diff --git a/spec/frontend/droplab/plugins/input_setter_spec.js b/spec/frontend/droplab/plugins/input_setter_spec.js
new file mode 100644
index 00000000000..eebde018fa1
--- /dev/null
+++ b/spec/frontend/droplab/plugins/input_setter_spec.js
@@ -0,0 +1,259 @@
+import InputSetter from '~/droplab/plugins/input_setter';
+
+describe('InputSetter', () => {
+ let testContext;
+
+ beforeEach(() => {
+ testContext = {};
+ });
+
+ describe('init', () => {
+ beforeEach(() => {
+ testContext.config = { InputSetter: {} };
+ testContext.hook = { config: testContext.config };
+ testContext.inputSetter = {
+ addEvents: jest.fn(),
+ };
+
+ InputSetter.init.call(testContext.inputSetter, testContext.hook);
+ });
+
+ it('should set .hook', () => {
+ expect(testContext.inputSetter.hook).toBe(testContext.hook);
+ });
+
+ it('should set .config', () => {
+ expect(testContext.inputSetter.config).toBe(testContext.config.InputSetter);
+ });
+
+ it('should set .eventWrapper', () => {
+ expect(testContext.inputSetter.eventWrapper).toEqual({});
+ });
+
+ it('should call .addEvents', () => {
+ expect(testContext.inputSetter.addEvents).toHaveBeenCalled();
+ });
+
+ describe('if config.InputSetter is not set', () => {
+ beforeEach(() => {
+ testContext.config = { InputSetter: undefined };
+ testContext.hook = { config: testContext.config };
+
+ InputSetter.init.call(testContext.inputSetter, testContext.hook);
+ });
+
+ it('should set .config to an empty object', () => {
+ expect(testContext.inputSetter.config).toEqual({});
+ });
+
+ it('should set hook.config to an empty object', () => {
+ expect(testContext.hook.config.InputSetter).toEqual({});
+ });
+ });
+ });
+
+ describe('addEvents', () => {
+ beforeEach(() => {
+ testContext.hook = {
+ list: {
+ list: {
+ addEventListener: jest.fn(),
+ },
+ },
+ };
+ testContext.inputSetter = { eventWrapper: {}, hook: testContext.hook, setInputs: () => {} };
+
+ InputSetter.addEvents.call(testContext.inputSetter);
+ });
+
+ it('should set .eventWrapper.setInputs', () => {
+ expect(testContext.inputSetter.eventWrapper.setInputs).toEqual(expect.any(Function));
+ });
+
+ it('should call .addEventListener', () => {
+ expect(testContext.hook.list.list.addEventListener).toHaveBeenCalledWith(
+ 'click.dl',
+ testContext.inputSetter.eventWrapper.setInputs,
+ );
+ });
+ });
+
+ describe('removeEvents', () => {
+ beforeEach(() => {
+ testContext.hook = {
+ list: {
+ list: {
+ removeEventListener: jest.fn(),
+ },
+ },
+ };
+ testContext.eventWrapper = {
+ setInputs: jest.fn(),
+ };
+ testContext.inputSetter = { eventWrapper: testContext.eventWrapper, hook: testContext.hook };
+
+ InputSetter.removeEvents.call(testContext.inputSetter);
+ });
+
+ it('should call .removeEventListener', () => {
+ expect(testContext.hook.list.list.removeEventListener).toHaveBeenCalledWith(
+ 'click.dl',
+ testContext.eventWrapper.setInputs,
+ );
+ });
+ });
+
+ describe('setInputs', () => {
+ beforeEach(() => {
+ testContext.event = { detail: { selected: {} } };
+ testContext.config = [0, 1];
+ testContext.inputSetter = { config: testContext.config, setInput: () => {} };
+
+ jest.spyOn(testContext.inputSetter, 'setInput').mockImplementation(() => {});
+
+ InputSetter.setInputs.call(testContext.inputSetter, testContext.event);
+ });
+
+ it('should call .setInput for each config element', () => {
+ const allArgs = testContext.inputSetter.setInput.mock.calls;
+
+ expect(allArgs.length).toEqual(2);
+
+ allArgs.forEach((args, i) => {
+ expect(args[0]).toBe(testContext.config[i]);
+ expect(args[1]).toBe(testContext.event.detail.selected);
+ });
+ });
+
+ describe('if config isnt an array', () => {
+ beforeEach(() => {
+ testContext.inputSetter = { config: {}, setInput: () => {} };
+
+ InputSetter.setInputs.call(testContext.inputSetter, testContext.event);
+ });
+
+ it('should set .config to an array with .config as the first element', () => {
+ expect(testContext.inputSetter.config).toEqual([{}]);
+ });
+ });
+ });
+
+ describe('setInput', () => {
+ beforeEach(() => {
+ testContext.selectedItem = { getAttribute: () => {} };
+ testContext.input = { value: 'oldValue', tagName: 'INPUT', hasAttribute: () => {} };
+ testContext.config = { valueAttribute: {}, input: testContext.input };
+ testContext.inputSetter = { hook: { trigger: {} } };
+ testContext.newValue = 'newValue';
+
+ jest.spyOn(testContext.selectedItem, 'getAttribute').mockReturnValue(testContext.newValue);
+ jest.spyOn(testContext.input, 'hasAttribute').mockReturnValue(false);
+
+ InputSetter.setInput.call(
+ testContext.inputSetter,
+ testContext.config,
+ testContext.selectedItem,
+ );
+ });
+
+ it('should call .getAttribute', () => {
+ expect(testContext.selectedItem.getAttribute).toHaveBeenCalledWith(
+ testContext.config.valueAttribute,
+ );
+ });
+
+ it('should call .hasAttribute', () => {
+ expect(testContext.input.hasAttribute).toHaveBeenCalledWith(undefined);
+ });
+
+ it('should set the value of the input', () => {
+ expect(testContext.input.value).toBe(testContext.newValue);
+ });
+
+ describe('if no config.input is provided', () => {
+ beforeEach(() => {
+ testContext.config = { valueAttribute: {} };
+ testContext.trigger = { value: 'oldValue', tagName: 'INPUT', hasAttribute: () => {} };
+ testContext.inputSetter = { hook: { trigger: testContext.trigger } };
+
+ InputSetter.setInput.call(
+ testContext.inputSetter,
+ testContext.config,
+ testContext.selectedItem,
+ );
+ });
+
+ it('should set the value of the hook.trigger', () => {
+ expect(testContext.trigger.value).toBe(testContext.newValue);
+ });
+ });
+
+ describe('if the input tag is not INPUT', () => {
+ beforeEach(() => {
+ testContext.input = { textContent: 'oldValue', tagName: 'SPAN', hasAttribute: () => {} };
+ testContext.config = { valueAttribute: {}, input: testContext.input };
+
+ InputSetter.setInput.call(
+ testContext.inputSetter,
+ testContext.config,
+ testContext.selectedItem,
+ );
+ });
+
+ it('should set the textContent of the input', () => {
+ expect(testContext.input.textContent).toBe(testContext.newValue);
+ });
+ });
+
+ describe('if there is an inputAttribute', () => {
+ beforeEach(() => {
+ testContext.selectedItem = { getAttribute: () => {} };
+ testContext.input = { id: 'oldValue', hasAttribute: () => {}, setAttribute: () => {} };
+ testContext.inputSetter = { hook: { trigger: {} } };
+ testContext.newValue = 'newValue';
+ testContext.inputAttribute = 'id';
+ testContext.config = {
+ valueAttribute: {},
+ input: testContext.input,
+ inputAttribute: testContext.inputAttribute,
+ };
+
+ jest.spyOn(testContext.selectedItem, 'getAttribute').mockReturnValue(testContext.newValue);
+ jest.spyOn(testContext.input, 'hasAttribute').mockReturnValue(true);
+ jest.spyOn(testContext.input, 'setAttribute').mockImplementation(() => {});
+
+ InputSetter.setInput.call(
+ testContext.inputSetter,
+ testContext.config,
+ testContext.selectedItem,
+ );
+ });
+
+ it('should call setAttribute', () => {
+ expect(testContext.input.setAttribute).toHaveBeenCalledWith(
+ testContext.inputAttribute,
+ testContext.newValue,
+ );
+ });
+
+ it('should not set the value or textContent of the input', () => {
+ expect(testContext.input.value).not.toBe('newValue');
+ expect(testContext.input.textContent).not.toBe('newValue');
+ });
+ });
+ });
+
+ describe('destroy', () => {
+ beforeEach(() => {
+ testContext.inputSetter = {
+ removeEvents: jest.fn(),
+ };
+
+ InputSetter.destroy.call(testContext.inputSetter);
+ });
+
+ it('should call .removeEvents', () => {
+ expect(testContext.inputSetter.removeEvents).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index 913c4f3747d..cdb107da737 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -283,25 +283,13 @@ describe('RepoEditor', () => {
expect(vm.model.events.size).toBe(2);
});
- it.each`
- insertFinalNewline | input | eol | output
- ${true} | ${'testing 123\n'} | ${'\n'} | ${'testing 123\n'}
- ${true} | ${'testing 123'} | ${'\n'} | ${'testing 123\n'}
- ${false} | ${'testing 123'} | ${'\n'} | ${'testing 123'}
- ${true} | ${'testing 123'} | ${'\r\n'} | ${'testing 123\r\n'}
- ${false} | ${'testing 123'} | ${'\r\n'} | ${'testing 123'}
- `(
- 'updates state with "$output" if `this.insertFinalNewline` is $insertFinalNewline',
- ({ insertFinalNewline, input, eol, output }) => {
- jest.spyOn(vm.model.getModel(), 'getEOL').mockReturnValue(eol);
-
- vm.addFinalNewline = insertFinalNewline;
-
- vm.model.setValue(input);
-
- expect(vm.file.content).toBe(output);
- },
- );
+ it('updates state with the value of the model', () => {
+ vm.model.setValue('testing 1234');
+
+ vm.setupEditor();
+
+ expect(vm.file.content).toBe('testing 1234');
+ });
it('sets head model as staged file', () => {
jest.spyOn(vm.editor, 'createModel');
diff --git a/spec/frontend/ide/lib/common/model_spec.js b/spec/frontend/ide/lib/common/model_spec.js
index 2ef2f0da6da..df46b7774b0 100644
--- a/spec/frontend/ide/lib/common/model_spec.js
+++ b/spec/frontend/ide/lib/common/model_spec.js
@@ -133,5 +133,77 @@ describe('Multi-file editor library model', () => {
expect(disposeSpy).toHaveBeenCalled();
});
+
+ it('applies custom options and triggers onChange callback', () => {
+ const changeSpy = jest.fn();
+ jest.spyOn(model, 'applyCustomOptions');
+
+ model.onChange(changeSpy);
+
+ model.dispose();
+
+ expect(model.applyCustomOptions).toHaveBeenCalled();
+ expect(changeSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('updateOptions', () => {
+ it('sets the options on the options object', () => {
+ model.updateOptions({ insertSpaces: true, someOption: 'some value' });
+
+ expect(model.options).toEqual({
+ endOfLine: 0,
+ insertFinalNewline: true,
+ insertSpaces: true,
+ someOption: 'some value',
+ trimTrailingWhitespace: false,
+ });
+ });
+
+ it.each`
+ option | value
+ ${'insertSpaces'} | ${true}
+ ${'insertSpaces'} | ${false}
+ ${'indentSize'} | ${4}
+ ${'tabSize'} | ${3}
+ `("correctly sets option: $option=$value to Monaco's TextModel", ({ option, value }) => {
+ model.updateOptions({ [option]: value });
+
+ expect(model.getModel().getOptions()).toMatchObject({ [option]: value });
+ });
+
+ it('applies custom options immediately', () => {
+ jest.spyOn(model, 'applyCustomOptions');
+
+ model.updateOptions({ trimTrailingWhitespace: true, someOption: 'some value' });
+
+ expect(model.applyCustomOptions).toHaveBeenCalled();
+ });
+ });
+
+ describe('applyCustomOptions', () => {
+ it.each`
+ option | value | contentBefore | contentAfter
+ ${'endOfLine'} | ${0} | ${'hello\nworld\n'} | ${'hello\nworld\n'}
+ ${'endOfLine'} | ${0} | ${'hello\r\nworld\r\n'} | ${'hello\nworld\n'}
+ ${'endOfLine'} | ${1} | ${'hello\nworld\n'} | ${'hello\r\nworld\r\n'}
+ ${'endOfLine'} | ${1} | ${'hello\r\nworld\r\n'} | ${'hello\r\nworld\r\n'}
+ ${'insertFinalNewline'} | ${true} | ${'hello\nworld'} | ${'hello\nworld\n'}
+ ${'insertFinalNewline'} | ${true} | ${'hello\nworld\n'} | ${'hello\nworld\n'}
+ ${'insertFinalNewline'} | ${false} | ${'hello\nworld'} | ${'hello\nworld'}
+ ${'trimTrailingWhitespace'} | ${true} | ${'hello \t\nworld \t\n'} | ${'hello\nworld\n'}
+ ${'trimTrailingWhitespace'} | ${true} | ${'hello \t\r\nworld \t\r\n'} | ${'hello\nworld\n'}
+ ${'trimTrailingWhitespace'} | ${false} | ${'hello \t\r\nworld \t\r\n'} | ${'hello \t\nworld \t\n'}
+ `(
+ 'correctly applies custom option $option=$value to content',
+ ({ option, value, contentBefore, contentAfter }) => {
+ model.options[option] = value;
+
+ model.updateNewContent(contentBefore);
+ model.applyCustomOptions();
+
+ expect(model.getModel().getValue()).toEqual(contentAfter);
+ },
+ );
});
});
diff --git a/spec/frontend/ide/lib/diff/diff_spec.js b/spec/frontend/ide/lib/diff/diff_spec.js
index d9b088e2c12..901f9e7cfd1 100644
--- a/spec/frontend/ide/lib/diff/diff_spec.js
+++ b/spec/frontend/ide/lib/diff/diff_spec.js
@@ -73,5 +73,13 @@ describe('Multi-file editor library diff calculator', () => {
expect(diff.endLineNumber).toBe(1);
});
+
+ it('disregards changes for EOL type changes', () => {
+ const text1 = 'line1\nline2\nline3\n';
+ const text2 = 'line1\r\nline2\r\nline3\r\n';
+
+ expect(computeDiff(text1, text2)).toEqual([]);
+ expect(computeDiff(text2, text1)).toEqual([]);
+ });
});
});
diff --git a/spec/frontend/ide/lib/editor_options_spec.js b/spec/frontend/ide/lib/editor_options_spec.js
deleted file mode 100644
index b07a583b7c8..00000000000
--- a/spec/frontend/ide/lib/editor_options_spec.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import editorOptions from '~/ide/lib/editor_options';
-
-describe('Multi-file editor library editor options', () => {
- it('returns an array', () => {
- expect(editorOptions).toEqual(expect.any(Array));
- });
-
- it('contains readOnly option', () => {
- expect(editorOptions[0].readOnly).toBeDefined();
- });
-});
diff --git a/spec/frontend/ide/lib/editor_spec.js b/spec/frontend/ide/lib/editor_spec.js
index 36d4c3c26ee..6320add8988 100644
--- a/spec/frontend/ide/lib/editor_spec.js
+++ b/spec/frontend/ide/lib/editor_spec.js
@@ -72,6 +72,7 @@ describe('Multi-file editor library', () => {
expect(monacoEditor.createDiffEditor).toHaveBeenCalledWith(holder, {
...defaultEditorOptions,
+ ignoreTrimWhitespace: false,
quickSuggestions: false,
occurrencesHighlight: false,
renderSideBySide: false,
diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js
index 4ae440ff09e..0b46e2cc02f 100644
--- a/spec/frontend/ide/utils_spec.js
+++ b/spec/frontend/ide/utils_spec.js
@@ -2,7 +2,8 @@ import {
isTextFile,
registerLanguages,
trimPathComponents,
- addFinalNewline,
+ insertFinalNewline,
+ trimTrailingWhitespace,
getPathParents,
} from '~/ide/utils';
import { languages } from 'monaco-editor';
@@ -155,6 +156,20 @@ describe('WebIDE utils', () => {
});
});
+ describe('trimTrailingWhitespace', () => {
+ it.each`
+ input | output
+ ${'text \n more text \n'} | ${'text\n more text\n'}
+ ${'text \n more text \n\n \n'} | ${'text\n more text\n\n\n'}
+ ${'text \t\t \n more text \n\t\ttext\n \n\t\t'} | ${'text\n more text\n\t\ttext\n\n'}
+ ${'text \r\n more text \r\n'} | ${'text\r\n more text\r\n'}
+ ${'text \r\n more text \r\n\r\n \r\n'} | ${'text\r\n more text\r\n\r\n\r\n'}
+ ${'text \t\t \r\n more text \r\n\t\ttext\r\n \r\n\t\t'} | ${'text\r\n more text\r\n\t\ttext\r\n\r\n'}
+ `("trims trailing whitespace in each line of file's contents: $input", ({ input, output }) => {
+ expect(trimTrailingWhitespace(input)).toBe(output);
+ });
+ });
+
describe('addFinalNewline', () => {
it.each`
input | output
@@ -163,7 +178,7 @@ describe('WebIDE utils', () => {
${'some text\n\n'} | ${'some text\n\n'}
${'some\n text'} | ${'some\n text\n'}
`('adds a newline if it doesnt already exist for input: $input', ({ input, output }) => {
- expect(addFinalNewline(input)).toEqual(output);
+ expect(insertFinalNewline(input)).toBe(output);
});
it.each`
@@ -174,7 +189,7 @@ describe('WebIDE utils', () => {
${'some text\r\n\r\n'} | ${'some text\r\n\r\n'}
${'some\r\n text'} | ${'some\r\n text\r\n'}
`('works with CRLF newline style; input: $input', ({ input, output }) => {
- expect(addFinalNewline(input, '\r\n')).toEqual(output);
+ expect(insertFinalNewline(input, '\r\n')).toBe(output);
});
});
diff --git a/spec/javascripts/droplab/plugins/input_setter_spec.js b/spec/javascripts/droplab/plugins/input_setter_spec.js
deleted file mode 100644
index 711e0486bff..00000000000
--- a/spec/javascripts/droplab/plugins/input_setter_spec.js
+++ /dev/null
@@ -1,214 +0,0 @@
-import InputSetter from '~/droplab/plugins/input_setter';
-
-describe('InputSetter', function() {
- describe('init', function() {
- beforeEach(function() {
- this.config = { InputSetter: {} };
- this.hook = { config: this.config };
- this.inputSetter = jasmine.createSpyObj('inputSetter', ['addEvents']);
-
- InputSetter.init.call(this.inputSetter, this.hook);
- });
-
- it('should set .hook', function() {
- expect(this.inputSetter.hook).toBe(this.hook);
- });
-
- it('should set .config', function() {
- expect(this.inputSetter.config).toBe(this.config.InputSetter);
- });
-
- it('should set .eventWrapper', function() {
- expect(this.inputSetter.eventWrapper).toEqual({});
- });
-
- it('should call .addEvents', function() {
- expect(this.inputSetter.addEvents).toHaveBeenCalled();
- });
-
- describe('if config.InputSetter is not set', function() {
- beforeEach(function() {
- this.config = { InputSetter: undefined };
- this.hook = { config: this.config };
-
- InputSetter.init.call(this.inputSetter, this.hook);
- });
-
- it('should set .config to an empty object', function() {
- expect(this.inputSetter.config).toEqual({});
- });
-
- it('should set hook.config to an empty object', function() {
- expect(this.hook.config.InputSetter).toEqual({});
- });
- });
- });
-
- describe('addEvents', function() {
- beforeEach(function() {
- this.hook = { list: { list: jasmine.createSpyObj('list', ['addEventListener']) } };
- this.inputSetter = { eventWrapper: {}, hook: this.hook, setInputs: () => {} };
-
- InputSetter.addEvents.call(this.inputSetter);
- });
-
- it('should set .eventWrapper.setInputs', function() {
- expect(this.inputSetter.eventWrapper.setInputs).toEqual(jasmine.any(Function));
- });
-
- it('should call .addEventListener', function() {
- expect(this.hook.list.list.addEventListener).toHaveBeenCalledWith(
- 'click.dl',
- this.inputSetter.eventWrapper.setInputs,
- );
- });
- });
-
- describe('removeEvents', function() {
- beforeEach(function() {
- this.hook = { list: { list: jasmine.createSpyObj('list', ['removeEventListener']) } };
- this.eventWrapper = jasmine.createSpyObj('eventWrapper', ['setInputs']);
- this.inputSetter = { eventWrapper: this.eventWrapper, hook: this.hook };
-
- InputSetter.removeEvents.call(this.inputSetter);
- });
-
- it('should call .removeEventListener', function() {
- expect(this.hook.list.list.removeEventListener).toHaveBeenCalledWith(
- 'click.dl',
- this.eventWrapper.setInputs,
- );
- });
- });
-
- describe('setInputs', function() {
- beforeEach(function() {
- this.event = { detail: { selected: {} } };
- this.config = [0, 1];
- this.inputSetter = { config: this.config, setInput: () => {} };
-
- spyOn(this.inputSetter, 'setInput');
-
- InputSetter.setInputs.call(this.inputSetter, this.event);
- });
-
- it('should call .setInput for each config element', function() {
- const allArgs = this.inputSetter.setInput.calls.allArgs();
-
- expect(allArgs.length).toEqual(2);
-
- allArgs.forEach((args, i) => {
- expect(args[0]).toBe(this.config[i]);
- expect(args[1]).toBe(this.event.detail.selected);
- });
- });
-
- describe('if config isnt an array', function() {
- beforeEach(function() {
- this.inputSetter = { config: {}, setInput: () => {} };
-
- InputSetter.setInputs.call(this.inputSetter, this.event);
- });
-
- it('should set .config to an array with .config as the first element', function() {
- expect(this.inputSetter.config).toEqual([{}]);
- });
- });
- });
-
- describe('setInput', function() {
- beforeEach(function() {
- this.selectedItem = { getAttribute: () => {} };
- this.input = { value: 'oldValue', tagName: 'INPUT', hasAttribute: () => {} };
- this.config = { valueAttribute: {}, input: this.input };
- this.inputSetter = { hook: { trigger: {} } };
- this.newValue = 'newValue';
-
- spyOn(this.selectedItem, 'getAttribute').and.returnValue(this.newValue);
- spyOn(this.input, 'hasAttribute').and.returnValue(false);
-
- InputSetter.setInput.call(this.inputSetter, this.config, this.selectedItem);
- });
-
- it('should call .getAttribute', function() {
- expect(this.selectedItem.getAttribute).toHaveBeenCalledWith(this.config.valueAttribute);
- });
-
- it('should call .hasAttribute', function() {
- expect(this.input.hasAttribute).toHaveBeenCalledWith(undefined);
- });
-
- it('should set the value of the input', function() {
- expect(this.input.value).toBe(this.newValue);
- });
-
- describe('if no config.input is provided', function() {
- beforeEach(function() {
- this.config = { valueAttribute: {} };
- this.trigger = { value: 'oldValue', tagName: 'INPUT', hasAttribute: () => {} };
- this.inputSetter = { hook: { trigger: this.trigger } };
-
- InputSetter.setInput.call(this.inputSetter, this.config, this.selectedItem);
- });
-
- it('should set the value of the hook.trigger', function() {
- expect(this.trigger.value).toBe(this.newValue);
- });
- });
-
- describe('if the input tag is not INPUT', function() {
- beforeEach(function() {
- this.input = { textContent: 'oldValue', tagName: 'SPAN', hasAttribute: () => {} };
- this.config = { valueAttribute: {}, input: this.input };
-
- InputSetter.setInput.call(this.inputSetter, this.config, this.selectedItem);
- });
-
- it('should set the textContent of the input', function() {
- expect(this.input.textContent).toBe(this.newValue);
- });
- });
-
- describe('if there is an inputAttribute', function() {
- beforeEach(function() {
- this.selectedItem = { getAttribute: () => {} };
- this.input = { id: 'oldValue', hasAttribute: () => {}, setAttribute: () => {} };
- this.inputSetter = { hook: { trigger: {} } };
- this.newValue = 'newValue';
- this.inputAttribute = 'id';
- this.config = {
- valueAttribute: {},
- input: this.input,
- inputAttribute: this.inputAttribute,
- };
-
- spyOn(this.selectedItem, 'getAttribute').and.returnValue(this.newValue);
- spyOn(this.input, 'hasAttribute').and.returnValue(true);
- spyOn(this.input, 'setAttribute');
-
- InputSetter.setInput.call(this.inputSetter, this.config, this.selectedItem);
- });
-
- it('should call setAttribute', function() {
- expect(this.input.setAttribute).toHaveBeenCalledWith(this.inputAttribute, this.newValue);
- });
-
- it('should not set the value or textContent of the input', function() {
- expect(this.input.value).not.toBe('newValue');
- expect(this.input.textContent).not.toBe('newValue');
- });
- });
- });
-
- describe('destroy', function() {
- beforeEach(function() {
- this.inputSetter = jasmine.createSpyObj('inputSetter', ['removeEvents']);
-
- InputSetter.destroy.call(this.inputSetter);
- });
-
- it('should call .removeEvents', function() {
- expect(this.inputSetter.removeEvents).toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb
index 0bf46217d60..7279399d1b8 100644
--- a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb
+++ b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb
@@ -54,6 +54,14 @@ describe Gitlab::Gfm::UploadsRewriter do
expect(new_paths).not_to include image_uploader.secret
expect(new_paths).not_to include zip_uploader.secret
end
+
+ it 'skips nil files do' do
+ allow_next_instance_of(UploaderFinder) do |finder|
+ allow(finder).to receive(:execute).and_return(nil)
+ end
+
+ expect(new_files).to be_empty
+ end
end
end
@@ -68,16 +76,6 @@ describe Gitlab::Gfm::UploadsRewriter do
expect(moved_text.scan(/\A\[.*?\]/).count).to eq(1)
end
- context 'path traversal in file name' do
- let(:text) do
- "![a](/uploads/11111111111111111111111111111111/../../../../../../../../../../../../../../etc/passwd)"
- end
-
- it 'throw an error' do
- expect { rewriter.rewrite(new_project) }.to raise_error(an_instance_of(StandardError).and(having_attributes(message: "Invalid path")))
- end
- end
-
context "file are stored locally" do
include_examples "files are accessible"
end
diff --git a/spec/lib/gitlab/process_memory_cache/helper_spec.rb b/spec/lib/gitlab/process_memory_cache/helper_spec.rb
new file mode 100644
index 00000000000..890642b1d5e
--- /dev/null
+++ b/spec/lib/gitlab/process_memory_cache/helper_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::ProcessMemoryCache::Helper, :use_clean_rails_memory_store_caching do
+ let(:minimal_test_class) do
+ Class.new do
+ include Gitlab::ProcessMemoryCache::Helper
+
+ def cached_content
+ fetch_memory_cache(:cached_content_instance_key) { expensive_computation }
+ end
+
+ def clear_cached_content
+ invalidate_memory_cache(:cached_content_instance_key)
+ end
+ end
+ end
+
+ before do
+ stub_const("MinimalTestClass", minimal_test_class)
+ end
+
+ subject { MinimalTestClass.new }
+
+ describe '.fetch_memory_cache' do
+ it 'memoizes the result' do
+ is_expected.to receive(:expensive_computation).once.and_return(1)
+
+ 2.times do
+ expect(subject.cached_content).to eq(1)
+ end
+ end
+
+ it 'resets the cache when the shared key is missing', :aggregate_failures do
+ expect(Rails.cache).to receive(:read).with(:cached_content_instance_key).twice.and_return(nil)
+ is_expected.to receive(:expensive_computation).thrice.and_return(1, 2, 3)
+
+ 3.times do |index|
+ expect(subject.cached_content).to eq(index + 1)
+ end
+ end
+ end
+
+ describe '.invalidate_memory_cache' do
+ it 'invalidates the cache' do
+ is_expected.to receive(:expensive_computation).twice.and_return(1, 2)
+
+ expect { subject.clear_cached_content }.to change { subject.cached_content }
+ end
+ end
+end
diff --git a/spec/models/ci/instance_variable_spec.rb b/spec/models/ci/instance_variable_spec.rb
index ff8676e1424..4ad168ff0f2 100644
--- a/spec/models/ci/instance_variable_spec.rb
+++ b/spec/models/ci/instance_variable_spec.rb
@@ -39,7 +39,7 @@ describe Ci::InstanceVariable do
it { expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable) }
it 'memoizes the result' do
- expect(described_class).to receive(:store_cache).with(:ci_instance_variable_data).once.and_call_original
+ expect(described_class).to receive(:unscoped).once.and_call_original
2.times do
expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable)
@@ -65,15 +65,6 @@ describe Ci::InstanceVariable do
expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable, variable)
end
-
- it 'resets the cache when the shared key is missing' do
- expect(Rails.cache).to receive(:read).with(:ci_instance_variable_changed_at).twice.and_return(nil)
- expect(described_class).to receive(:store_cache).with(:ci_instance_variable_data).thrice.and_call_original
-
- 3.times do
- expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable)
- end
- end
end
describe '.unprotected_cached', :use_clean_rails_memory_store_caching do
@@ -83,7 +74,7 @@ describe Ci::InstanceVariable do
it { expect(described_class.unprotected_cached).to contain_exactly(unprotected_variable) }
it 'memoizes the result' do
- expect(described_class).to receive(:store_cache).with(:ci_instance_variable_data).once.and_call_original
+ expect(described_class).to receive(:unscoped).once.and_call_original
2.times do
expect(described_class.unprotected_cached).to contain_exactly(unprotected_variable)