Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									03c3f9f501
								
							
						
					
					
						commit
						b5249f2d99
					
				
							
								
								
									
										12
									
								
								Dangerfile
								
								
								
								
							
							
						
						
									
										12
									
								
								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? | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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(); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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(), | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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) => { | ||||
|  |  | |||
|  | @ -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', | ||||
|  |  | |||
|  | @ -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'), | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  | @ -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 | ||||
|  | @ -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 | ||||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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.') | ||||
|  |  | |||
|  | @ -0,0 +1,5 @@ | |||
| --- | ||||
| title: Add finder for group-level runners | ||||
| merge_request: 29283 | ||||
| author: Arthur de Lapertosa Lisboa | ||||
| type: added | ||||
|  | @ -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. | ||||
| 
 | ||||
|  | ||||
|  | @ -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. | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  | @ -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 | ||||
|  |  | |||
							
								
								
									
										5
									
								
								qa/qa.rb
								
								
								
								
							
							
						
						
									
										5
									
								
								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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  | @ -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 | ||||
|  | @ -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 | ||||
|  | @ -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 | ||||
|  | @ -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 | ||||
|  | @ -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 | ||||
|  | @ -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(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -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); | ||||
|     it('updates state with the value of the model', () => { | ||||
|       vm.model.setValue('testing 1234'); | ||||
| 
 | ||||
|         vm.addFinalNewline = insertFinalNewline; | ||||
|       vm.setupEditor(); | ||||
| 
 | ||||
|         vm.model.setValue(input); | ||||
| 
 | ||||
|         expect(vm.file.content).toBe(output); | ||||
|       }, | ||||
|     ); | ||||
|       expect(vm.file.content).toBe('testing 1234'); | ||||
|     }); | ||||
| 
 | ||||
|     it('sets head model as staged file', () => { | ||||
|       jest.spyOn(vm.editor, 'createModel'); | ||||
|  |  | |||
|  | @ -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); | ||||
|       }, | ||||
|     ); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -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([]); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -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(); | ||||
|   }); | ||||
| }); | ||||
|  | @ -72,6 +72,7 @@ describe('Multi-file editor library', () => { | |||
| 
 | ||||
|       expect(monacoEditor.createDiffEditor).toHaveBeenCalledWith(holder, { | ||||
|         ...defaultEditorOptions, | ||||
|         ignoreTrimWhitespace: false, | ||||
|         quickSuggestions: false, | ||||
|         occurrencesHighlight: false, | ||||
|         renderSideBySide: false, | ||||
|  |  | |||
|  | @ -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); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -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 | ||||
|         "" | ||||
|       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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  | @ -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) | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue