From cc41a771832a9d44c9a87e25aa784cb904d03fd5 Mon Sep 17 00:00:00 2001 From: Will Chandler Date: Sun, 3 Jun 2018 17:40:49 -0400 Subject: Add timestamps to gitlab-rake gitlab:backup:restore Adds a new method 'puts_time' that prepends the time of a message when printing it. All instances of 'progress.puts' in the gitlab:backup:restore tasks are replaced with puts_time. Example output: 2018-06-03 16:33:25 -0400 -- Restoring uploads .. Closes #46448 --- ...-stage-of-gitlab-rake-gitlab-backup-restore.yml | 5 ++ lib/tasks/gitlab/backup.rake | 92 +++++++++++----------- spec/tasks/gitlab/backup_rake_spec.rb | 37 +++++---- 3 files changed, 76 insertions(+), 58 deletions(-) create mode 100644 changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml diff --git a/changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml b/changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml new file mode 100644 index 00000000000..4ce6787570a --- /dev/null +++ b/changelogs/unreleased/46448-add-timestamps-for-each-stage-of-gitlab-rake-gitlab-backup-restore.yml @@ -0,0 +1,5 @@ +--- +title: Display timestamps to messages printed by gitlab:backup:restore rake tasks +merge_request: +author: Will Chandler +type: changed diff --git a/lib/tasks/gitlab/backup.rake b/lib/tasks/gitlab/backup.rake index e96fbb64372..3a1a36e36ce 100644 --- a/lib/tasks/gitlab/backup.rake +++ b/lib/tasks/gitlab/backup.rake @@ -47,9 +47,9 @@ namespace :gitlab do # Drop all tables Load the schema to ensure we don't have any newer tables # hanging out from a failed upgrade - progress.puts 'Cleaning the database ... '.color(:blue) + puts_time 'Cleaning the database ... '.color(:blue) Rake::Task['gitlab:db:drop_tables'].invoke - progress.puts 'done'.color(:green) + puts_time 'done'.color(:green) Rake::Task['gitlab:backup:db:restore'].invoke rescue Gitlab::TaskAbortedByUserError puts "Quitting...".color(:red) @@ -72,165 +72,169 @@ namespace :gitlab do namespace :repo do task create: :gitlab_environment do - progress.puts "Dumping repositories ...".color(:blue) + puts_time "Dumping repositories ...".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("repositories") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Repository.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring repositories ...".color(:blue) + puts_time "Restoring repositories ...".color(:blue) Backup::Repository.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :db do task create: :gitlab_environment do - progress.puts "Dumping database ... ".color(:blue) + puts_time "Dumping database ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("db") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Database.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring database ... ".color(:blue) + puts_time "Restoring database ... ".color(:blue) Backup::Database.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :builds do task create: :gitlab_environment do - progress.puts "Dumping builds ... ".color(:blue) + puts_time "Dumping builds ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("builds") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Builds.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring builds ... ".color(:blue) + puts_time "Restoring builds ... ".color(:blue) Backup::Builds.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :uploads do task create: :gitlab_environment do - progress.puts "Dumping uploads ... ".color(:blue) + puts_time "Dumping uploads ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("uploads") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Uploads.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring uploads ... ".color(:blue) + puts_time "Restoring uploads ... ".color(:blue) Backup::Uploads.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :artifacts do task create: :gitlab_environment do - progress.puts "Dumping artifacts ... ".color(:blue) + puts_time "Dumping artifacts ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("artifacts") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Artifacts.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring artifacts ... ".color(:blue) + puts_time "Restoring artifacts ... ".color(:blue) Backup::Artifacts.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :pages do task create: :gitlab_environment do - progress.puts "Dumping pages ... ".color(:blue) + puts_time "Dumping pages ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("pages") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Pages.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring pages ... ".color(:blue) + puts_time "Restoring pages ... ".color(:blue) Backup::Pages.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :lfs do task create: :gitlab_environment do - progress.puts "Dumping lfs objects ... ".color(:blue) + puts_time "Dumping lfs objects ... ".color(:blue) if ENV["SKIP"] && ENV["SKIP"].include?("lfs") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Lfs.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end end task restore: :gitlab_environment do - progress.puts "Restoring lfs objects ... ".color(:blue) + puts_time "Restoring lfs objects ... ".color(:blue) Backup::Lfs.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) end end namespace :registry do task create: :gitlab_environment do - progress.puts "Dumping container registry images ... ".color(:blue) + puts_time "Dumping container registry images ... ".color(:blue) if Gitlab.config.registry.enabled if ENV["SKIP"] && ENV["SKIP"].include?("registry") - progress.puts "[SKIPPED]".color(:cyan) + puts_time "[SKIPPED]".color(:cyan) else Backup::Registry.new(progress).dump - progress.puts "done".color(:green) + puts_time "done".color(:green) end else - progress.puts "[DISABLED]".color(:cyan) + puts_time "[DISABLED]".color(:cyan) end end task restore: :gitlab_environment do - progress.puts "Restoring container registry images ... ".color(:blue) + puts_time "Restoring container registry images ... ".color(:blue) if Gitlab.config.registry.enabled Backup::Registry.new(progress).restore - progress.puts "done".color(:green) + puts_time "done".color(:green) else - progress.puts "[DISABLED]".color(:cyan) + puts_time "[DISABLED]".color(:cyan) end end end + def puts_time(msg) + progress.puts "#{Time.now} -- #{msg}" + end + def progress if ENV['CRON'] # We need an object we can say 'puts' and 'print' to; let's use a diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index c9252bebb2e..70f3d77dc98 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -71,20 +71,29 @@ describe 'gitlab:app namespace rake task' do end.to raise_error(SystemExit) end - it 'invokes restoration on match' do - allow(YAML).to receive(:load_file) - .and_return({ gitlab_version: gitlab_version }) - expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:db:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:repo:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:builds:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:uploads:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:artifacts:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:pages:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:lfs:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:registry:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:shell:setup']).to receive(:invoke) - expect { run_rake_task('gitlab:backup:restore') }.to output.to_stdout + context 'restore with matching gitlab version' do + before do + allow(YAML).to receive(:load_file) + .and_return({ gitlab_version: gitlab_version }) + expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:db:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:repo:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:builds:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:uploads:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:artifacts:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:pages:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:lfs:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:registry:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:shell:setup']).to receive(:invoke) + end + + it 'invokes restoration on match' do + expect { run_rake_task('gitlab:backup:restore') }.to output.to_stdout + end + + it 'prints timestamps on messages' do + expect { run_rake_task('gitlab:backup:restore') }.to output(/.*\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\s[-+]\d{4}\s--\s.*/).to_stdout + end end end end # backup_restore task -- cgit v1.2.3 From edc51a2fdc6fed4d94381d846910f5a980000e67 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 16 Oct 2018 15:03:48 +0000 Subject: Add wip parameter at project level endpoint to api documentation It resolves #52589 issue --- doc/api/merge_requests.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 862ee398a84..4d0c2f86190 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -47,7 +47,7 @@ Parameters: | `source_branch` | string | no | Return merge requests with the given source branch | | `target_branch` | string | no | Return merge requests with the given target branch | | `search` | string | no | Search merge requests against their `title` and `description` | -| `wip` | string | no | Filter merge requests against their `wip` status. `yes` to return *only* WIP merge requests, `no` to return *non* WIP merge requests | +| `wip` | string | no | Filter merge requests against their `wip` status. `yes` to return *only* WIP merge requests, `no` to return *non* WIP merge requests | ```json [ @@ -168,9 +168,10 @@ Parameters: | `author_id` | integer | no | Returns merge requests created by the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | | `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | | `my_reaction_emoji` | string | no | Return merge requests reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ | -| `source_branch` | string | no | Return merge requests with the given source branch | -| `target_branch` | string | no | Return merge requests with the given target branch | +| `source_branch` | string | no | Return merge requests with the given source branch | +| `target_branch` | string | no | Return merge requests with the given target branch | | `search` | string | no | Search merge requests against their `title` and `description` | +| `wip` | string | no | Filter merge requests against their `wip` status. `yes` to return *only* WIP merge requests, `no` to return *non* WIP merge requests ```json [ @@ -281,8 +282,8 @@ Parameters: | `author_id` | integer | no | Returns merge requests created by the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | | `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | | `my_reaction_emoji` | string | no | Return merge requests reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ | -| `source_branch` | string | no | Return merge requests with the given source branch | -| `target_branch` | string | no | Return merge requests with the given target branch | +| `source_branch` | string | no | Return merge requests with the given source branch | +| `target_branch` | string | no | Return merge requests with the given target branch | | `search` | string | no | Search merge requests against their `title` and `description` | ```json -- cgit v1.2.3 From 820d18dfcc12906479ab72e1972803a14369f128 Mon Sep 17 00:00:00 2001 From: Craig Davidson Date: Fri, 4 Jan 2019 15:08:25 +0000 Subject: Added further details to the CI_PROJECT_DIR variable highlighting a dependency to the Runner builds_dir configuration option. --- doc/ci/variables/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 209a2c15d90..bdb846606fb 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -85,7 +85,7 @@ future GitLab releases.** | **CI_PIPELINE_SOURCE** | 10.0 | all | Indicates how the pipeline was triggered. Possible options are: `push`, `web`, `trigger`, `schedule`, `api`, and `pipeline`. For pipelines created before GitLab 9.5, this will show as `unknown` | | **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] | | **CI_PIPELINE_URL** | 11.1 | 0.5 | Pipeline details URL | -| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run | +| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run. Note that if the GitLab Runner `builds_dir` parameter is set it will have an impact on this. See [GitLab Runner - Advanced Configuration](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section). | | **CI_PROJECT_ID** | all | all | The unique id of the current project that GitLab CI uses internally | | **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built (actually it is project folder name) | | **CI_PROJECT_NAMESPACE** | 8.10 | 0.5 | The project namespace (username or groupname) that is currently being built | -- cgit v1.2.3 From 1ba642ef864c74759ef93b60ee96c9f1a8c7cdfa Mon Sep 17 00:00:00 2001 From: James Lopez Date: Wed, 5 Dec 2018 14:31:43 +0100 Subject: Fix path disclosure on Project Import --- app/services/projects/import_error_filter.rb | 14 ++++++++ app/services/projects/import_service.rb | 12 +++++-- .../unreleased/security-import-path-logging.yml | 5 +++ lib/gitlab/import_export/shared.rb | 39 +++++++++++++++------- spec/lib/gitlab/import_export/shared_spec.rb | 31 +++++++++++++++++ .../gitlab/import_export/version_checker_spec.rb | 2 +- spec/services/projects/import_error_filter_spec.rb | 17 ++++++++++ spec/services/projects/import_service_spec.rb | 4 +-- 8 files changed, 107 insertions(+), 17 deletions(-) create mode 100644 app/services/projects/import_error_filter.rb create mode 100644 changelogs/unreleased/security-import-path-logging.yml create mode 100644 spec/lib/gitlab/import_export/shared_spec.rb create mode 100644 spec/services/projects/import_error_filter_spec.rb diff --git a/app/services/projects/import_error_filter.rb b/app/services/projects/import_error_filter.rb new file mode 100644 index 00000000000..a0fc5149bb4 --- /dev/null +++ b/app/services/projects/import_error_filter.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Projects + # Used by project imports, it removes any potential paths + # included in an error message that could be stored in the DB + class ImportErrorFilter + ERROR_MESSAGE_FILTER = /[^\s]*#{File::SEPARATOR}[^\s]*(?=(\s|\z))/ + FILTER_MESSAGE = '[FILTERED]' + + def self.filter_message(message) + message.gsub(ERROR_MESSAGE_FILTER, FILTER_MESSAGE) + end + end +end diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 0c426faa22d..afd32c0d968 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -24,8 +24,16 @@ module Projects import_data success - rescue => e + rescue Gitlab::UrlBlocker::BlockedUrlError => e + Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type }) + error("Error importing repository #{project.safe_import_url} into #{project.full_path} - #{e.message}") + rescue => e + message = Projects::ImportErrorFilter.filter_message(e.message) + + Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type }) + + error("Error importing repository #{project.safe_import_url} into #{project.full_path} - #{message}") end private @@ -35,7 +43,7 @@ module Projects begin Gitlab::UrlBlocker.validate!(project.import_url, ports: Project::VALID_IMPORT_PORTS) rescue Gitlab::UrlBlocker::BlockedUrlError => e - raise Error, "Blocked import URL: #{e.message}" + raise e, "Blocked import URL: #{e.message}" end end diff --git a/changelogs/unreleased/security-import-path-logging.yml b/changelogs/unreleased/security-import-path-logging.yml new file mode 100644 index 00000000000..2ba2d88d82a --- /dev/null +++ b/changelogs/unreleased/security-import-path-logging.yml @@ -0,0 +1,5 @@ +--- +title: Fix path disclosure on project import error +merge_request: +author: +type: security diff --git a/lib/gitlab/import_export/shared.rb b/lib/gitlab/import_export/shared.rb index c13e6c1d83b..947caaaefee 100644 --- a/lib/gitlab/import_export/shared.rb +++ b/lib/gitlab/import_export/shared.rb @@ -8,6 +8,7 @@ module Gitlab def initialize(project) @project = project @errors = [] + @logger = Gitlab::Import::Logger.build end def active_export_count @@ -23,19 +24,16 @@ module Gitlab end def error(error) - error_out(error.message, caller[0].dup) - add_error_message(error.message) + log_error(message: error.message, caller: caller[0].dup) + log_debug(backtrace: error.backtrace&.join("\n")) + + Gitlab::Sentry.track_acceptable_exception(error, extra: log_base_data) - # Debug: - if error.backtrace - Rails.logger.error("Import/Export backtrace: #{error.backtrace.join("\n")}") - else - Rails.logger.error("No backtrace found") - end + add_error_message(error.message) end - def add_error_message(error_message) - @errors << error_message + def add_error_message(message) + @errors << filtered_error_message(message) end def after_export_in_progress? @@ -52,8 +50,25 @@ module Gitlab @project.disk_path end - def error_out(message, caller) - Rails.logger.error("Import/Export error raised on #{caller}: #{message}") + def log_error(details) + @logger.error(log_base_data.merge(details)) + end + + def log_debug(details) + @logger.debug(log_base_data.merge(details)) + end + + def log_base_data + { + importer: 'Import/Export', + import_jid: @project&.import_state&.import_jid, + project_id: @project&.id, + project_path: @project&.full_path + } + end + + def filtered_error_message(message) + Projects::ImportErrorFilter.filter_message(message) end def after_export_lock_file diff --git a/spec/lib/gitlab/import_export/shared_spec.rb b/spec/lib/gitlab/import_export/shared_spec.rb new file mode 100644 index 00000000000..f2d750c6595 --- /dev/null +++ b/spec/lib/gitlab/import_export/shared_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' +require 'fileutils' + +describe Gitlab::ImportExport::Shared do + let(:project) { build(:project) } + subject { project.import_export_shared } + + describe '#error' do + let(:error) { StandardError.new('Error importing into /my/folder Permission denied @ unlink_internal - /var/opt/gitlab/gitlab-rails/shared/a/b/c/uploads/file') } + + it 'filters any full paths' do + subject.error(error) + + expect(subject.errors).to eq(['Error importing into [FILTERED] Permission denied @ unlink_internal - [FILTERED]']) + end + + it 'calls the error logger with the full message' do + expect(subject).to receive(:log_error).with(hash_including(message: error.message)) + + subject.error(error) + end + + it 'calls the debug logger with a backtrace' do + error.set_backtrace('backtrace') + + expect(subject).to receive(:log_debug).with(hash_including(backtrace: 'backtrace')) + + subject.error(error) + end + end +end diff --git a/spec/lib/gitlab/import_export/version_checker_spec.rb b/spec/lib/gitlab/import_export/version_checker_spec.rb index 49d857d9483..76f8253ec9b 100644 --- a/spec/lib/gitlab/import_export/version_checker_spec.rb +++ b/spec/lib/gitlab/import_export/version_checker_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' include ImportExport::CommonUtil describe Gitlab::ImportExport::VersionChecker do - let(:shared) { Gitlab::ImportExport::Shared.new(nil) } + let!(:shared) { Gitlab::ImportExport::Shared.new(nil) } describe 'bundle a project Git repo' do let(:version) { Gitlab::ImportExport.version } diff --git a/spec/services/projects/import_error_filter_spec.rb b/spec/services/projects/import_error_filter_spec.rb new file mode 100644 index 00000000000..312b658de89 --- /dev/null +++ b/spec/services/projects/import_error_filter_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Projects::ImportErrorFilter do + it 'filters any full paths' do + message = 'Error importing into /my/folder Permission denied @ unlink_internal - /var/opt/gitlab/gitlab-rails/shared/a/b/c/uploads/file' + + expect(described_class.filter_message(message)).to eq('Error importing into [FILTERED] Permission denied @ unlink_internal - [FILTERED]') + end + + it 'filters any relative paths ignoring single slash ones' do + message = 'Error importing into my/project Permission denied @ unlink_internal - ../file/ and folder/../file' + + expect(described_class.filter_message(message)).to eq('Error importing into [FILTERED] Permission denied @ unlink_internal - [FILTERED] and [FILTERED]') + end +end diff --git a/spec/services/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb index 06f865dc848..a005defb3ee 100644 --- a/spec/services/projects/import_service_spec.rb +++ b/spec/services/projects/import_service_spec.rb @@ -136,12 +136,12 @@ describe Projects::ImportService do end it 'fails if repository import fails' do - expect_any_instance_of(Gitlab::Shell).to receive(:import_repository).and_raise(Gitlab::Shell::Error.new('Failed to import the repository')) + expect_any_instance_of(Gitlab::Shell).to receive(:import_repository).and_raise(Gitlab::Shell::Error.new('Failed to import the repository /a/b/c')) result = subject.execute expect(result[:status]).to eq :error - expect(result[:message]).to eq "Error importing repository #{project.safe_import_url} into #{project.full_path} - Failed to import the repository" + expect(result[:message]).to eq "Error importing repository #{project.safe_import_url} into #{project.full_path} - Failed to import the repository [FILTERED]" end context 'when repository import scheduled' do -- cgit v1.2.3 From 6b466e38738e3e6bcf4b13cca1e5f680daa43ae4 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Tue, 11 Dec 2018 01:08:48 +0000 Subject: Fix bug causing repo mirror settings UI to break Fixes an exception relating to the new project cleanup settings in 11.6 that causes the mirror repo settings UI to become unusable. --- app/assets/javascripts/lib/utils/file_upload.js | 3 ++ .../fix-repo-settings-file-upload-error.yml | 5 +++ spec/javascripts/lib/utils/file_upload_spec.js | 44 ++++++++++++++++++---- 3 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 changelogs/unreleased/fix-repo-settings-file-upload-error.yml diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js index b41ffb44971..82ee83e4348 100644 --- a/app/assets/javascripts/lib/utils/file_upload.js +++ b/app/assets/javascripts/lib/utils/file_upload.js @@ -1,6 +1,9 @@ export default (buttonSelector, fileSelector) => { const btn = document.querySelector(buttonSelector); const fileInput = document.querySelector(fileSelector); + + if (!btn || !fileInput) return; + const form = btn.closest('form'); btn.addEventListener('click', () => { diff --git a/changelogs/unreleased/fix-repo-settings-file-upload-error.yml b/changelogs/unreleased/fix-repo-settings-file-upload-error.yml new file mode 100644 index 00000000000..b219fdfaa1e --- /dev/null +++ b/changelogs/unreleased/fix-repo-settings-file-upload-error.yml @@ -0,0 +1,5 @@ +--- +title: Fix bug causing repository mirror settings UI to break +merge_request: 23712 +author: +type: fixed diff --git a/spec/javascripts/lib/utils/file_upload_spec.js b/spec/javascripts/lib/utils/file_upload_spec.js index 92c9cc70aaf..8f7092f63de 100644 --- a/spec/javascripts/lib/utils/file_upload_spec.js +++ b/spec/javascripts/lib/utils/file_upload_spec.js @@ -9,28 +9,56 @@ describe('File upload', () => { `); + }); + + describe('when there is a matching button and input', () => { + beforeEach(() => { + fileUpload('.js-button', '.js-input'); + }); + + it('clicks file input after clicking button', () => { + const btn = document.querySelector('.js-button'); + const input = document.querySelector('.js-input'); + + spyOn(input, 'click'); + + btn.click(); + + expect(input.click).toHaveBeenCalled(); + }); + + it('updates file name text', () => { + const input = document.querySelector('.js-input'); - fileUpload('.js-button', '.js-input'); + input.value = 'path/to/file/index.js'; + + input.dispatchEvent(new CustomEvent('change')); + + expect(document.querySelector('.js-filename').textContent).toEqual('index.js'); + }); }); - it('clicks file input after clicking button', () => { - const btn = document.querySelector('.js-button'); + it('fails gracefully when there is no matching button', () => { const input = document.querySelector('.js-input'); + const btn = document.querySelector('.js-button'); + fileUpload('.js-not-button', '.js-input'); spyOn(input, 'click'); btn.click(); - expect(input.click).toHaveBeenCalled(); + expect(input.click).not.toHaveBeenCalled(); }); - it('updates file name text', () => { + it('fails gracefully when there is no matching input', () => { const input = document.querySelector('.js-input'); + const btn = document.querySelector('.js-button'); + fileUpload('.js-button', '.js-not-input'); - input.value = 'path/to/file/index.js'; + spyOn(input, 'click'); - input.dispatchEvent(new CustomEvent('change')); + btn.click(); - expect(document.querySelector('.js-filename').textContent).toEqual('index.js'); + expect(input.click).not.toHaveBeenCalled(); }); }); -- cgit v1.2.3 From bcba534a637505d146e4ee7a989b4b33284698bb Mon Sep 17 00:00:00 2001 From: Craig Davidson Date: Tue, 8 Jan 2019 08:20:47 +0000 Subject: Apply suggestion to doc/ci/variables/README.md --- doc/ci/variables/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index bdb846606fb..706662f69d4 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -85,7 +85,7 @@ future GitLab releases.** | **CI_PIPELINE_SOURCE** | 10.0 | all | Indicates how the pipeline was triggered. Possible options are: `push`, `web`, `trigger`, `schedule`, `api`, and `pipeline`. For pipelines created before GitLab 9.5, this will show as `unknown` | | **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] | | **CI_PIPELINE_URL** | 11.1 | 0.5 | Pipeline details URL | -| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run. Note that if the GitLab Runner `builds_dir` parameter is set it will have an impact on this. See [GitLab Runner - Advanced Configuration](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section). | +| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run. If the GitLab Runner `builds_dir` parameter is set, this variable is set relative to the value of `builds_dir`. For more information, see [Advanced configuration](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section) for GitLab Runner. | | **CI_PROJECT_ID** | all | all | The unique id of the current project that GitLab CI uses internally | | **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built (actually it is project folder name) | | **CI_PROJECT_NAMESPACE** | 8.10 | 0.5 | The project namespace (username or groupname) that is currently being built | -- cgit v1.2.3 From 7084d71e781d9893fe4c24e45af434e2ca511fdd Mon Sep 17 00:00:00 2001 From: James Lopez Date: Fri, 21 Dec 2018 15:26:33 +0100 Subject: Fix contributed projects finder shown private info --- app/finders/contributed_projects_finder.rb | 7 +++++ .../unreleased/security-contributed-projects.yml | 5 ++++ spec/controllers/users_controller_spec.rb | 32 ++++++++++++++++++++++ spec/finders/contributed_projects_finder_spec.rb | 12 ++++++++ 4 files changed, 56 insertions(+) create mode 100644 changelogs/unreleased/security-contributed-projects.yml diff --git a/app/finders/contributed_projects_finder.rb b/app/finders/contributed_projects_finder.rb index c1ef9dfefa7..f8c7f0c3167 100644 --- a/app/finders/contributed_projects_finder.rb +++ b/app/finders/contributed_projects_finder.rb @@ -14,6 +14,9 @@ class ContributedProjectsFinder < UnionFinder # Returns an ActiveRecord::Relation. # rubocop: disable CodeReuse/ActiveRecord def execute(current_user = nil) + # Do not show contributed projects if the user profile is private. + return Project.none unless can_read_profile?(current_user) + segments = all_projects(current_user) find_union(segments, Project).includes(:namespace).order_id_desc @@ -22,6 +25,10 @@ class ContributedProjectsFinder < UnionFinder private + def can_read_profile?(current_user) + Ability.allowed?(current_user, :read_user_profile, @user) + end + def all_projects(current_user) projects = [] diff --git a/changelogs/unreleased/security-contributed-projects.yml b/changelogs/unreleased/security-contributed-projects.yml new file mode 100644 index 00000000000..f745a2255ca --- /dev/null +++ b/changelogs/unreleased/security-contributed-projects.yml @@ -0,0 +1,5 @@ +--- +title: Fix contributed projects info still visible when user enable private profile +merge_request: +author: +type: security diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 27edf226ca3..af61026098b 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -206,6 +206,38 @@ describe UsersController do end end + describe 'GET #contributed' do + let(:project) { create(:project, :public) } + let(:current_user) { create(:user) } + + before do + sign_in(current_user) + + project.add_developer(public_user) + project.add_developer(private_user) + end + + context 'with public profile' do + it 'renders contributed projects' do + create(:push_event, project: project, author: public_user) + + get :contributed, params: { username: public_user.username } + + expect(assigns[:contributed_projects]).not_to be_empty + end + end + + context 'with private profile' do + it 'does not render contributed projects' do + create(:push_event, project: project, author: private_user) + + get :contributed, params: { username: private_user.username } + + expect(assigns[:contributed_projects]).to be_empty + end + end + end + describe 'GET #snippets' do before do sign_in(user) diff --git a/spec/finders/contributed_projects_finder_spec.rb b/spec/finders/contributed_projects_finder_spec.rb index 81fb4e3561c..ee84fd067d4 100644 --- a/spec/finders/contributed_projects_finder_spec.rb +++ b/spec/finders/contributed_projects_finder_spec.rb @@ -31,4 +31,16 @@ describe ContributedProjectsFinder do it { is_expected.to match_array([private_project, internal_project, public_project]) } end + + context 'user with private profile' do + it 'does not return contributed projects' do + private_user = create(:user, private_profile: true) + public_project.add_maintainer(private_user) + create(:push_event, project: public_project, author: private_user) + + projects = described_class.new(private_user).execute(current_user) + + expect(projects).to be_empty + end + end end -- cgit v1.2.3 From 81fee3617ab9005aa07d31308630e17330b8fd8b Mon Sep 17 00:00:00 2001 From: Oswaldo Ferreira Date: Wed, 9 Jan 2019 17:24:05 -0200 Subject: Don't process MR refs for guests in the notes --- app/policies/project_policy.rb | 2 +- .../unreleased/security-do-not-process-mr-ref-for-guests.yml | 5 +++++ spec/policies/project_policy_spec.rb | 12 +++++++++++- 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/security-do-not-process-mr-ref-for-guests.yml diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index d70417e710e..e801900e981 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -392,7 +392,7 @@ class ProjectPolicy < BasePolicy end.enable :read_issue_iid rule do - (can?(:read_project_for_iids) & merge_requests_visible_to_user) | can?(:read_merge_request) + (~guest & can?(:read_project_for_iids) & merge_requests_visible_to_user) | can?(:read_merge_request) end.enable :read_merge_request_iid rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster diff --git a/changelogs/unreleased/security-do-not-process-mr-ref-for-guests.yml b/changelogs/unreleased/security-do-not-process-mr-ref-for-guests.yml new file mode 100644 index 00000000000..0281dde11e6 --- /dev/null +++ b/changelogs/unreleased/security-do-not-process-mr-ref-for-guests.yml @@ -0,0 +1,5 @@ +--- +title: Don't process MR refs for guests in the notes +merge_request: 2771 +author: +type: security diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index 9cb20854f6e..3f87d5eef4b 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -12,7 +12,7 @@ describe ProjectPolicy do let(:base_guest_permissions) do %i[ read_project read_board read_list read_wiki read_issue - read_project_for_iids read_issue_iid read_merge_request_iid read_label + read_project_for_iids read_issue_iid read_label read_milestone read_project_snippet read_project_member read_note create_project create_issue create_note upload_file create_merge_request_in award_emoji read_release @@ -152,6 +152,16 @@ describe ProjectPolicy do end end + context 'for a guest in a private project' do + let(:project) { create(:project, :private) } + subject { described_class.new(guest, project) } + + it 'disallows the guest from reading the merge request and merge request iid' do + expect_disallowed(:read_merge_request) + expect_disallowed(:read_merge_request_iid) + end + end + context 'builds feature' do subject { described_class.new(owner, project) } -- cgit v1.2.3 From 25da6e56d7d1a02cc411d044816ec4afcc9f1fb2 Mon Sep 17 00:00:00 2001 From: Heinrich Lee Yu Date: Wed, 9 Jan 2019 16:55:29 +0800 Subject: Fix slow project reference pattern regex --- app/models/project.rb | 1 + changelogs/unreleased/security-fix-regex-dos.yml | 5 +++++ lib/gitlab/path_regex.rb | 3 ++- spec/lib/banzai/filter/project_reference_filter_spec.rb | 6 ++++++ 4 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/security-fix-regex-dos.yml diff --git a/app/models/project.rb b/app/models/project.rb index cab173503ce..c105a41b080 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -530,6 +530,7 @@ class Project < ActiveRecord::Base def reference_pattern %r{ + (?#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})\/)? (?#{Gitlab::PathRegex::PROJECT_PATH_FORMAT_REGEX}) }x diff --git a/changelogs/unreleased/security-fix-regex-dos.yml b/changelogs/unreleased/security-fix-regex-dos.yml new file mode 100644 index 00000000000..b08566d2f15 --- /dev/null +++ b/changelogs/unreleased/security-fix-regex-dos.yml @@ -0,0 +1,5 @@ +--- +title: Fix slow regex in project reference pattern +merge_request: +author: +type: security diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb index fa68dead80b..3c888be0710 100644 --- a/lib/gitlab/path_regex.rb +++ b/lib/gitlab/path_regex.rb @@ -125,7 +125,8 @@ module Gitlab # allow non-regex validations, etc), `NAMESPACE_FORMAT_REGEX_JS` serves as a Javascript-compatible version of # `NAMESPACE_FORMAT_REGEX`, with the negative lookbehind assertion removed. This means that the client-side validation # will pass for usernames ending in `.atom` and `.git`, but will be caught by the server-side validation. - PATH_REGEX_STR = '[a-zA-Z0-9_\.][a-zA-Z0-9_\-\.]*'.freeze + PATH_START_CHAR = '[a-zA-Z0-9_\.]'.freeze + PATH_REGEX_STR = PATH_START_CHAR + '[a-zA-Z0-9_\-\.]*'.freeze NAMESPACE_FORMAT_REGEX_JS = PATH_REGEX_STR + '[a-zA-Z0-9_\-]|[a-zA-Z0-9_]'.freeze NO_SUFFIX_REGEX = /(? character' do doc = reference_filter("Hey #{reference}foo") expect(doc.css('a').first.attr('href')).to eq urls.project_url(subject) -- cgit v1.2.3 From d32aec06fe2d6ee0b2b0c0d1ca8cfd9bab14e4e7 Mon Sep 17 00:00:00 2001 From: Hiroyuki Sato Date: Mon, 14 Jan 2019 01:24:31 +0900 Subject: Add 'in' filter that modifies scope of 'search' filter to issues and merge requests API --- app/finders/issuable_finder.rb | 4 +- app/finders/issues_finder.rb | 1 + app/finders/merge_requests_finder.rb | 1 + app/models/concerns/issuable.rb | 12 +++++- changelogs/unreleased/search-title.yml | 5 +++ doc/api/issues.md | 2 + doc/api/merge_requests.md | 2 + lib/api/issues.rb | 3 +- lib/api/merge_requests.rb | 3 +- spec/finders/issues_finder_spec.rb | 8 ++++ spec/models/concerns/issuable_spec.rb | 72 ++++++++++++++++++++++++++++++++ spec/requests/api/issues_spec.rb | 12 ++++++ spec/requests/api/merge_requests_spec.rb | 12 ++++++ 13 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 changelogs/unreleased/search-title.yml diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 1a69ec85d18..8984cef42e9 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -18,6 +18,7 @@ # assignee_id: integer or 'None' or 'Any' # assignee_username: string # search: string +# in: 'title', 'description' or a string joined them with comma # label_name: string # sort: string # non_archived: boolean @@ -56,6 +57,7 @@ class IssuableFinder milestone_title my_reaction_emoji search + in ] end @@ -408,7 +410,7 @@ class IssuableFinder items = klass.with(cte.to_arel).from(klass.table_name) end - items.full_search(search) + items.full_search(search, matched_columns: params[:in]) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 45e494725d7..bf39effa265 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -14,6 +14,7 @@ # milestone_title: string # assignee_id: integer # search: string +# in: 'title', 'description' or a string joined them with comma # label_name: string # sort: string # my_reaction_emoji: string diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index e190d5d90c9..3cfe9533bb6 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -15,6 +15,7 @@ # author_id: integer # assignee_id: integer # search: string +# in: 'title', 'description' or a string joined them with comma # label_name: string # sort: string # non_archived: boolean diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 0d363ec68b7..d6893a59b43 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -136,10 +136,18 @@ module Issuable # This method uses ILIKE on PostgreSQL and LIKE on MySQL. # # query - The search query as a String + # matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma. # # Returns an ActiveRecord::Relation. - def full_search(query) - fuzzy_search(query, [:title, :description]) + def full_search(query, matched_columns: 'title,description') + allowed_columns = [:title, :description] + matched_columns = matched_columns.to_s.split(',').map(&:to_sym) + matched_columns &= allowed_columns + + # Matching title or description if the matched_columns did not contain any allowed columns. + matched_columns = [:title, :description] if matched_columns.empty? + + fuzzy_search(query, matched_columns) end def sort_by_attribute(method, excluded_labels: []) diff --git a/changelogs/unreleased/search-title.yml b/changelogs/unreleased/search-title.yml new file mode 100644 index 00000000000..ff0933ed0b2 --- /dev/null +++ b/changelogs/unreleased/search-title.yml @@ -0,0 +1,5 @@ +--- +title: Add 'in' filter that modifies scope of 'search' filter to issues and merge requests API +merge_request: 24350 +author: Hiroyuki Sato +type: added diff --git a/doc/api/issues.md b/doc/api/issues.md index fb06119063f..b86340bb5e7 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -31,6 +31,7 @@ GET /issues?iids[]=42&iids[]=43 GET /issues?author_id=5 GET /issues?assignee_id=5 GET /issues?my_reaction_emoji=star +GET /issues?search=foo&in=title ``` | Attribute | Type | Required | Description | @@ -46,6 +47,7 @@ GET /issues?my_reaction_emoji=star | `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | | `search` | string | no | Search issues against their `title` and `description` | +| `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joined them with comma. Default is `title,description` | | `created_after` | datetime | no | Return issues created on or after the given time | | `created_before` | datetime | no | Return issues created on or before the given time | | `updated_after` | datetime | no | Return issues updated on or after the given time | diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index c9b271eada3..c351be5bd55 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -24,6 +24,7 @@ GET /merge_requests?labels=bug,reproduced GET /merge_requests?author_id=5 GET /merge_requests?my_reaction_emoji=star GET /merge_requests?scope=assigned_to_me +GET /merge_requests?search=foo&in=title ``` Parameters: @@ -47,6 +48,7 @@ Parameters: | `source_branch` | string | no | Return merge requests with the given source branch | | `target_branch` | string | no | Return merge requests with the given target branch | | `search` | string | no | Search merge requests against their `title` and `description` | +| `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joined them with comma. Default is `title,description` | | `wip` | string | no | Filter merge requests against their `wip` status. `yes` to return *only* WIP merge requests, `no` to return *non* WIP merge requests | ```json diff --git a/lib/api/issues.rb b/lib/api/issues.rb index dac700482b4..a797f01e89f 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -43,7 +43,8 @@ module API desc: 'Return issues sorted in `asc` or `desc` order.' optional :milestone, type: String, desc: 'Return issues for a specific milestone' optional :iids, type: Array[Integer], desc: 'The IID array of issues' - optional :search, type: String, desc: 'Search issues for text present in the title or description' + optional :search, type: String, desc: 'Search issues for text present in the title, description or any combination of these' + optional :in, type: String, desc: '`title`, `description` or a string joined them with comma' optional :created_after, type: DateTime, desc: 'Return issues created after the specified time' optional :created_before, type: DateTime, desc: 'Return issues created before the specified time' optional :updated_after, type: DateTime, desc: 'Return issues updated after the specified time' diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 8c1951cc535..991b7310cfd 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -109,7 +109,8 @@ module API optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji' optional :source_branch, type: String, desc: 'Return merge requests with the given source branch' optional :target_branch, type: String, desc: 'Return merge requests with the given target branch' - optional :search, type: String, desc: 'Search merge requests for text present in the title or description' + optional :search, type: String, desc: 'Search merge requests for text present in the title, description or any combination of these' + optional :in, type: String, desc: '`title`, `description` or a string joined them with comma' optional :wip, type: String, values: %w[yes no], desc: 'Search merge requests for WIP in the title' use :pagination end diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index 682fae06434..34cb09942be 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -314,6 +314,14 @@ describe IssuesFinder do end end + context 'filtering by issue term in title' do + let(:params) { { search: 'git', in: 'title' } } + + it 'returns issues with title match for search term' do + expect(issues).to contain_exactly(issue1) + end + end + context 'filtering by issues iids' do let(:params) { { iids: issue3.iid } } diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index a4bf3e2350a..d7003cdb760 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -139,6 +139,78 @@ describe Issuable do it 'returns issues with a matching description for a query shorter than 3 chars' do expect(issuable_class.full_search(searchable_issue2.description.downcase)).to eq([searchable_issue2]) end + + context 'when mathing columns is "title"' do + it 'returns issues with a matching title' do + expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'title')) + .to eq([searchable_issue]) + end + + it 'returns no issues with a matching description' do + expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'title')) + .to be_empty + end + end + + context 'when mathing columns is "description"' do + it 'returns no issues with a matching title' do + expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'description')) + .to be_empty + end + + it 'returns issues with a matching description' do + expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'description')) + .to eq([searchable_issue]) + end + end + + context 'when mathing columns is "title,description"' do + it 'returns issues with a matching title' do + expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'title,description')) + .to eq([searchable_issue]) + end + + it 'returns issues with a matching description' do + expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'title,description')) + .to eq([searchable_issue]) + end + end + + context 'when mathing columns is nil"' do + it 'returns issues with a matching title' do + expect(issuable_class.full_search(searchable_issue.title, matched_columns: nil)) + .to eq([searchable_issue]) + end + + it 'returns issues with a matching description' do + expect(issuable_class.full_search(searchable_issue.description, matched_columns: nil)) + .to eq([searchable_issue]) + end + end + + context 'when mathing columns is "invalid"' do + it 'returns issues with a matching title' do + expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'invalid')) + .to eq([searchable_issue]) + end + + it 'returns issues with a matching description' do + expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'invalid')) + .to eq([searchable_issue]) + end + end + + context 'when mathing columns is "title,invalid"' do + it 'returns issues with a matching title' do + expect(issuable_class.full_search(searchable_issue.title, matched_columns: 'title,invalid')) + .to eq([searchable_issue]) + end + + it 'returns no issues with a matching description' do + expect(issuable_class.full_search(searchable_issue.description, matched_columns: 'title,invalid')) + .to be_empty + end + end end describe '.to_ability_name' do diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index ba7930f6c9d..707b91e3b2a 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -208,6 +208,18 @@ describe API::Issues do expect_paginated_array_response(issue.id) end + it 'returns issues matching given search string for title and scoped in title' do + get api("/issues", user), params: { search: issue.title, in: 'title' } + + expect_paginated_array_response(issue.id) + end + + it 'returns an empty array if no issue matches given search string for title and scoped in description' do + get api("/issues", user), params: { search: issue.title, in: 'description' } + + expect_paginated_array_response([]) + end + it 'returns issues matching given search string for description' do get api("/issues", user), params: { search: issue.description } diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 79c0a1953dc..ed8a56a2221 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -260,6 +260,18 @@ describe API::MergeRequests do expect_response_ordered_exactly(merge_request) end + it 'returns merge requests matching given search string for title and scoped in title' do + get api("/merge_requests", user), params: { search: merge_request.title, in: 'title' } + + expect_response_ordered_exactly(merge_request) + end + + it 'returns an empty array if no merge reques matches given search string for description and scoped in title' do + get api("/merge_requests", user), params: { search: merge_request.description, in: 'title' } + + expect_response_contain_exactly + end + it 'returns merge requests for project matching given search string for description' do get api("/merge_requests", user), params: { project_id: project.id, search: merge_request.description } -- cgit v1.2.3 From b5a06d2934585fd7e1206d58b0384eb22585fa6a Mon Sep 17 00:00:00 2001 From: Heinrich Lee Yu Date: Mon, 14 Jan 2019 13:33:40 +0800 Subject: Use common error for unauthenticated users Removes special error message when creating new issues --- app/controllers/projects/issues_controller.rb | 10 +--------- .../unreleased/security-fix-new-issues-login-message.yml | 5 +++++ spec/controllers/projects/issues_controller_spec.rb | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) create mode 100644 changelogs/unreleased/security-fix-new-issues-login-message.yml diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 21688e54481..4d6bcb1de6a 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -19,7 +19,7 @@ class Projects::IssuesController < Projects::ApplicationController prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) } - prepend_before_action :authenticate_new_issue!, only: [:new] + prepend_before_action :authenticate_user!, only: [:new] prepend_before_action :store_uri, only: [:new, :show] before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update] @@ -247,14 +247,6 @@ class Projects::IssuesController < Projects::ApplicationController ] + [{ label_ids: [], assignee_ids: [] }] end - def authenticate_new_issue! - return if current_user - - notice = "Please sign in to create the new issue." - - redirect_to new_user_session_path, notice: notice - end - def store_uri if request.get? && !request.xhr? store_location_for :user, request.fullpath diff --git a/changelogs/unreleased/security-fix-new-issues-login-message.yml b/changelogs/unreleased/security-fix-new-issues-login-message.yml new file mode 100644 index 00000000000..9dabf2438c9 --- /dev/null +++ b/changelogs/unreleased/security-fix-new-issues-login-message.yml @@ -0,0 +1,5 @@ +--- +title: Use common error for unauthenticated users when creating issues +merge_request: +author: +type: security diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index df21dc7bc85..c250c6297e2 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -131,7 +131,7 @@ describe Projects::IssuesController do it 'redirects to signin if not logged in' do get :new, params: { namespace_id: project.namespace, project_id: project } - expect(flash[:notice]).to eq 'Please sign in to create the new issue.' + expect(flash[:alert]).to eq 'You need to sign in or sign up before continuing.' expect(response).to redirect_to(new_user_session_path) end -- cgit v1.2.3 From d4001e155679db1282dd13ac1bb399414eb22a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Ru=CC=88ttimann?= Date: Mon, 14 Jan 2019 10:44:36 +0100 Subject: update js_regex version --- Gemfile | 2 +- Gemfile.lock | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index 5972c434d7e..ea38a170f59 100644 --- a/Gemfile +++ b/Gemfile @@ -187,7 +187,7 @@ gem 're2', '~> 1.1.1' gem 'version_sorter', '~> 2.1.0' # Export Ruby Regex to Javascript -gem 'js_regex', '~> 2.2.1' +gem 'js_regex', '~> 3.1' # User agent parsing gem 'device_detector' diff --git a/Gemfile.lock b/Gemfile.lock index b4602dbbf36..d436dc698ce 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -113,6 +113,7 @@ GEM activesupport (>= 4.0.0) mime-types (>= 1.16) cause (0.1) + character_set (1.1.2) charlock_holmes (0.7.6) childprocess (0.9.0) ffi (~> 1.0, >= 1.0.11) @@ -394,8 +395,10 @@ GEM multipart-post oauth (~> 0.5, >= 0.5.0) jquery-atwho-rails (1.3.2) - js_regex (2.2.1) - regexp_parser (>= 0.4.11, <= 0.5.0) + js_regex (3.1.1) + character_set (~> 1.1) + regexp_parser (~> 1.1) + regexp_property_values (~> 0.3) json (1.8.6) json-jwt (1.9.4) activesupport @@ -693,7 +696,8 @@ GEM redis-store (>= 1.2, < 2) redis-store (1.6.0) redis (>= 2.2, < 5) - regexp_parser (0.5.0) + regexp_parser (1.3.0) + regexp_property_values (0.3.4) representable (3.0.4) declarative (< 0.1.0) declarative-option (< 0.2.0) @@ -1039,7 +1043,7 @@ DEPENDENCIES influxdb (~> 0.2) jira-ruby (~> 1.4) jquery-atwho-rails (~> 1.3.2) - js_regex (~> 2.2.1) + js_regex (~> 3.1) json-schema (~> 2.8.0) jwt (~> 2.1.0) kaminari (~> 1.0) -- cgit v1.2.3 From 9eb64a3455f4d3115c41bef5e5b0cdd8739bbb0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Ru=CC=88ttimann?= Date: Mon, 14 Jan 2019 12:59:08 +0100 Subject: fix initialization of JsRegex after js_regex gem update change --- app/helpers/users_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 73c1402eae5..949ca5fa5ec 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -30,7 +30,7 @@ module UsersHelper pattern, options = if settings.user_default_internal_regex_enabled? regex = settings.user_default_internal_regex_instance - JsRegex.new(regex).to_h.slice(:source, :options).values + JsRegex.new(regex, options: 'g').to_h.slice(:source, :options).values end { user_internal_regex_pattern: pattern, user_internal_regex_options: options } -- cgit v1.2.3 From 3197cd9b6cffadee9e5d633bd7d1f7673d1b9229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Ru=CC=88ttimann?= Date: Mon, 14 Jan 2019 13:42:27 +0100 Subject: remove newly supported regex feature from validation error test --- spec/validators/js_regex_validator_spec.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/spec/validators/js_regex_validator_spec.rb b/spec/validators/js_regex_validator_spec.rb index aeb55cdc0e5..4d3bafaf267 100644 --- a/spec/validators/js_regex_validator_spec.rb +++ b/spec/validators/js_regex_validator_spec.rb @@ -12,8 +12,6 @@ describe JsRegexValidator do '' | [] '(?#comment)' | ['Regex Pattern (?#comment) can not be expressed in Javascript'] '(?(a)b|c)' | ['invalid conditional pattern: /(?(a)b|c)/i'] - '[a-z&&[^uo]]' | ["Dropped unsupported set intersection '[a-z&&[^uo]]' at index 0", - "Dropped unsupported nested negative set data '[^uo]' at index 6"] end with_them do -- cgit v1.2.3 From 71729b38c54bafb440c3a588bc88d8e6346654ce Mon Sep 17 00:00:00 2001 From: Luke Duncalfe Date: Tue, 15 Jan 2019 15:17:21 +1300 Subject: Prefer build() rather than create() --- spec/lib/gitlab/data_builder/push_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/lib/gitlab/data_builder/push_spec.rb b/spec/lib/gitlab/data_builder/push_spec.rb index befdc18d1aa..d70438ba27c 100644 --- a/spec/lib/gitlab/data_builder/push_spec.rb +++ b/spec/lib/gitlab/data_builder/push_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Gitlab::DataBuilder::Push do let(:project) { create(:project, :repository) } - let(:user) { create(:user) } + let(:user) { build(:user) } describe '.build_sample' do let(:data) { described_class.build_sample(project, user) } -- cgit v1.2.3 From a2338de00c8723a1e14068cef198b20f1e20ab82 Mon Sep 17 00:00:00 2001 From: Heinrich Lee Yu Date: Tue, 15 Jan 2019 16:21:28 +0800 Subject: Prevent award_emoji to notes not visible to user When the parent noteable is not visible to the user (e.g. confidential) we prevent the user from adding emoji reactions to notes --- app/policies/note_policy.rb | 1 + changelogs/unreleased/security-2776-fix-add-reaction-permissions.yml | 5 +++++ spec/policies/note_policy_spec.rb | 2 ++ 3 files changed, 8 insertions(+) create mode 100644 changelogs/unreleased/security-2776-fix-add-reaction-permissions.yml diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb index f22843b6463..8d23e3abed3 100644 --- a/app/policies/note_policy.rb +++ b/app/policies/note_policy.rb @@ -18,6 +18,7 @@ class NotePolicy < BasePolicy prevent :read_note prevent :admin_note prevent :resolve_note + prevent :award_emoji end rule { is_author }.policy do diff --git a/changelogs/unreleased/security-2776-fix-add-reaction-permissions.yml b/changelogs/unreleased/security-2776-fix-add-reaction-permissions.yml new file mode 100644 index 00000000000..3ad92578c44 --- /dev/null +++ b/changelogs/unreleased/security-2776-fix-add-reaction-permissions.yml @@ -0,0 +1,5 @@ +--- +title: Prevent awarding emojis to notes whose parent is not visible to user +merge_request: +author: +type: security diff --git a/spec/policies/note_policy_spec.rb b/spec/policies/note_policy_spec.rb index 7e25c53e77c..0e848c74659 100644 --- a/spec/policies/note_policy_spec.rb +++ b/spec/policies/note_policy_spec.rb @@ -28,6 +28,7 @@ describe NotePolicy, mdoels: true do expect(policy).to be_disallowed(:admin_note) expect(policy).to be_disallowed(:resolve_note) expect(policy).to be_disallowed(:read_note) + expect(policy).to be_disallowed(:award_emoji) end end @@ -40,6 +41,7 @@ describe NotePolicy, mdoels: true do expect(policy).to be_allowed(:admin_note) expect(policy).to be_allowed(:resolve_note) expect(policy).to be_allowed(:read_note) + expect(policy).to be_allowed(:award_emoji) end end end -- cgit v1.2.3 From 4d7f93519ca82cca0ed3a4ed2165b41fe254368f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 19 Dec 2018 14:15:58 +0100 Subject: Do not expose trigger token when user should not see it --- app/controllers/projects/triggers_controller.rb | 7 +++--- app/models/ci/trigger.rb | 1 + app/presenters/ci/trigger_presenter.rb | 19 ++++++++++++++++ app/views/projects/triggers/_trigger.html.haml | 2 +- lib/api/entities.rb | 5 ++++- lib/api/helpers/presentable.rb | 29 +++++++++++++++++++++++++ lib/api/triggers.rb | 4 ++-- spec/requests/api/triggers_spec.rb | 14 +++++++----- 8 files changed, 68 insertions(+), 13 deletions(-) create mode 100644 app/presenters/ci/trigger_presenter.rb create mode 100644 lib/api/helpers/presentable.rb diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb index f5fdfb8accc..c7b4ebb2b24 100644 --- a/app/controllers/projects/triggers_controller.rb +++ b/app/controllers/projects/triggers_controller.rb @@ -66,12 +66,11 @@ class Projects::TriggersController < Projects::ApplicationController end def trigger - @trigger ||= project.triggers.find(params[:id]) || render_404 + @trigger ||= project.triggers.find(params[:id]) + .present(current_user: current_user) end def trigger_params - params.require(:trigger).permit( - :description - ) + params.require(:trigger).permit(:description) end end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 55db42162ca..3a9cdfcc35e 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -4,6 +4,7 @@ module Ci class Trigger < ActiveRecord::Base extend Gitlab::Ci::Model include IgnorableColumn + include Presentable ignore_column :deleted_at diff --git a/app/presenters/ci/trigger_presenter.rb b/app/presenters/ci/trigger_presenter.rb new file mode 100644 index 00000000000..605c8f328a4 --- /dev/null +++ b/app/presenters/ci/trigger_presenter.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ci + class TriggerPresenter < Gitlab::View::Presenter::Delegated + presents :trigger + + def has_token_exposed? + can?(current_user, :admin_trigger, trigger) + end + + def token + if has_token_exposed? + trigger.token + else + trigger.short_token + end + end + end +end diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index 7e4618e1a88..6f6f1e5e0c5 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -1,6 +1,6 @@ %tr %td - - if can?(current_user, :admin_trigger, trigger) + - if trigger.has_token_exposed? %span= trigger.token = clipboard_button(text: trigger.token, title: "Copy trigger token to clipboard") - else diff --git a/lib/api/entities.rb b/lib/api/entities.rb index a2a3c0a16d7..829d6fb13d4 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -1216,8 +1216,11 @@ module API end class Trigger < Grape::Entity + include ::API::Helpers::Presentable + expose :id - expose :token, :description + expose :token + expose :description expose :created_at, :updated_at, :last_used expose :owner, using: Entities::UserBasic end diff --git a/lib/api/helpers/presentable.rb b/lib/api/helpers/presentable.rb new file mode 100644 index 00000000000..973c2132efe --- /dev/null +++ b/lib/api/helpers/presentable.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module API + module Helpers + ## + # This module makes it possible to use `app/presenters` with + # Grape Entities. It instantiates model presenter and passes + # options defined in the API endpoint to the presenter itself. + # + # present object, with: Entities::Something, + # current_user: current_user, + # another_option: 'my options' + # + # Example above will make `current_user` and `another_option` + # values available in the subclass of `Gitlab::View::Presenter` + # thorough a separate method in the presenter. + # + # The model class needs to have `::Presentable` module mixed in + # if you want to use `API::Helpers::Presentable`. + # + module Presentable + extend ActiveSupport::Concern + + def initialize(object, options = {}) + super(object.present(options), options) + end + end + end +end diff --git a/lib/api/triggers.rb b/lib/api/triggers.rb index 3ce1529f259..7b8691171f0 100644 --- a/lib/api/triggers.rb +++ b/lib/api/triggers.rb @@ -51,7 +51,7 @@ module API triggers = user_project.triggers.includes(:trigger_requests) - present paginate(triggers), with: Entities::Trigger + present paginate(triggers), with: Entities::Trigger, current_user: current_user end # rubocop: enable CodeReuse/ActiveRecord @@ -68,7 +68,7 @@ module API trigger = user_project.triggers.find(params.delete(:trigger_id)) break not_found!('Trigger') unless trigger - present trigger, with: Entities::Trigger + present trigger, with: Entities::Trigger, current_user: current_user end desc 'Create a trigger' do diff --git a/spec/requests/api/triggers_spec.rb b/spec/requests/api/triggers_spec.rb index 15dc901d06e..f0f01e97f1d 100644 --- a/spec/requests/api/triggers_spec.rb +++ b/spec/requests/api/triggers_spec.rb @@ -1,8 +1,9 @@ require 'spec_helper' describe API::Triggers do - let(:user) { create(:user) } - let(:user2) { create(:user) } + set(:user) { create(:user) } + set(:user2) { create(:user) } + let!(:trigger_token) { 'secure_token' } let!(:trigger_token_2) { 'secure_token_2' } let!(:project) { create(:project, :repository, creator: user) } @@ -132,14 +133,17 @@ describe API::Triggers do end describe 'GET /projects/:id/triggers' do - context 'authenticated user with valid permissions' do - it 'returns list of triggers' do + context 'authenticated user who can access triggers' do + it 'returns a list of triggers with tokens exposed correctly' do get api("/projects/#{project.id}/triggers", user) expect(response).to have_gitlab_http_status(200) expect(response).to include_pagination_headers + expect(json_response).to be_a(Array) - expect(json_response[0]).to have_key('token') + expect(json_response.size).to eq 2 + expect(json_response.dig(0, 'token')).to eq trigger_token + expect(json_response.dig(1, 'token')).to eq trigger_token_2[0..3] end end -- cgit v1.2.3 From 69486116331bb262687ade4dfd4ab0619fad4063 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 19 Dec 2018 15:51:02 +0100 Subject: Present all pipeline triggers using trigger presenter --- app/controllers/projects/settings/ci_cd_controller.rb | 2 ++ app/models/ci/trigger.rb | 2 +- lib/api/triggers.rb | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 75e590f3f33..f2f63e986bb 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -99,7 +99,9 @@ module Projects def define_triggers_variables @triggers = @project.triggers + .present(current_user: current_user) @trigger = ::Ci::Trigger.new + .present(current_user: current_user) end def define_badges_variables diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 3a9cdfcc35e..637148c4ce4 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -30,7 +30,7 @@ module Ci end def short_token - token[0...4] + token[0...4] if token.present? end def legacy? diff --git a/lib/api/triggers.rb b/lib/api/triggers.rb index 7b8691171f0..b67f056f491 100644 --- a/lib/api/triggers.rb +++ b/lib/api/triggers.rb @@ -85,7 +85,7 @@ module API declared_params(include_missing: false).merge(owner: current_user)) if trigger.valid? - present trigger, with: Entities::Trigger + present trigger, with: Entities::Trigger, current_user: current_user else render_validation_error!(trigger) end @@ -106,7 +106,7 @@ module API break not_found!('Trigger') unless trigger if trigger.update(declared_params(include_missing: false)) - present trigger, with: Entities::Trigger + present trigger, with: Entities::Trigger, current_user: current_user else render_validation_error!(trigger) end @@ -127,7 +127,7 @@ module API if trigger.update(owner: current_user) status :ok - present trigger, with: Entities::Trigger + present trigger, with: Entities::Trigger, current_user: current_user else render_validation_error!(trigger) end -- cgit v1.2.3 From ba8a1ec92c783dae6f050e80c491bd1a42e1023d Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 20 Dec 2018 14:37:37 +0100 Subject: Add some specs for trigger presenter --- spec/presenters/ci/trigger_presenter_spec.rb | 51 ++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 spec/presenters/ci/trigger_presenter_spec.rb diff --git a/spec/presenters/ci/trigger_presenter_spec.rb b/spec/presenters/ci/trigger_presenter_spec.rb new file mode 100644 index 00000000000..a589759f379 --- /dev/null +++ b/spec/presenters/ci/trigger_presenter_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe Ci::TriggerPresenter do + set(:user) { create(:user) } + set(:project) { create(:project) } + + set(:trigger) do + create(:ci_trigger, token: '123456789abcd', project: project) + end + + let(:subject) do + described_class.new(trigger, current_user: user) + end + + before do + project.add_maintainer(user) + end + + context 'when user is not a trigger owner' do + describe '#token' do + it 'exposes only short token' do + expect(subject.token).not_to eq trigger.token + expect(subject.token).to eq '1234' + end + end + + describe '#has_token_exposed?' do + it 'does not have token exposed' do + expect(subject).not_to have_token_exposed + end + end + end + + context 'when user is a trigger owner and builds admin' do + before do + trigger.update(owner: user) + end + + describe '#token' do + it 'exposes full token' do + expect(subject.token).to eq trigger.token + end + end + + describe '#has_token_exposed?' do + it 'has token exposed' do + expect(subject).to have_token_exposed + end + end + end +end -- cgit v1.2.3 From 0001066c5c9adc33abfd6fe5aa7422cb7479862d Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 3 Jan 2019 10:54:05 +0100 Subject: Fix subject in trigger presenter tests --- spec/presenters/ci/trigger_presenter_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/presenters/ci/trigger_presenter_spec.rb b/spec/presenters/ci/trigger_presenter_spec.rb index a589759f379..231b539c188 100644 --- a/spec/presenters/ci/trigger_presenter_spec.rb +++ b/spec/presenters/ci/trigger_presenter_spec.rb @@ -8,7 +8,7 @@ describe Ci::TriggerPresenter do create(:ci_trigger, token: '123456789abcd', project: project) end - let(:subject) do + subject do described_class.new(trigger, current_user: user) end -- cgit v1.2.3 From 63e5a314b4f149fad39d40f898e13f705340cc22 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 3 Jan 2019 11:32:58 +0100 Subject: Add changelog for trigger token exposure fix --- changelogs/unreleased/security-pipeline-trigger-tokens-exposure.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/security-pipeline-trigger-tokens-exposure.yml diff --git a/changelogs/unreleased/security-pipeline-trigger-tokens-exposure.yml b/changelogs/unreleased/security-pipeline-trigger-tokens-exposure.yml new file mode 100644 index 00000000000..97d743eead1 --- /dev/null +++ b/changelogs/unreleased/security-pipeline-trigger-tokens-exposure.yml @@ -0,0 +1,5 @@ +--- +title: Expose CI/CD trigger token only to the trigger owner +merge_request: +author: +type: security -- cgit v1.2.3 From 2190704c61323191b581b4776bda450fa5a089b8 Mon Sep 17 00:00:00 2001 From: Robert Schilling Date: Tue, 15 Jan 2019 16:05:09 +0100 Subject: API: Support username with dots --- lib/api/projects.rb | 2 +- lib/api/users.rb | 2 +- spec/requests/api/projects_spec.rb | 13 +++++++++++-- spec/requests/api/users_spec.rb | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 9f3a1699146..d76d5d822b1 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -128,7 +128,7 @@ module API end end - resource :users, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + resource :users, requirements: { user_id: API::NO_SLASH_URL_PART_REGEX } do desc 'Get a user projects' do success Entities::BasicProjectDetails end diff --git a/lib/api/users.rb b/lib/api/users.rb index b41fce76df0..ff33e892ef8 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -135,7 +135,7 @@ module API params do requires :id_or_username, type: String, desc: 'The ID or username of the user' end - get ":id_or_username/status" do + get ":id_or_username/status", requirements: { id_or_username: API::NO_SLASH_URL_PART_REGEX } do user = find_user(params[:id_or_username]) not_found!('User') unless user && can?(current_user, :read_user, user) diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index ffe4512fa6f..05b73a77f1a 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -21,7 +21,7 @@ describe API::Projects do let(:project) { create(:project, :repository, namespace: user.namespace) } let(:project2) { create(:project, namespace: user.namespace) } let(:project_member) { create(:project_member, :developer, user: user3, project: project) } - let(:user4) { create(:user) } + let(:user4) { create(:user, username: 'user.with.dot') } let(:project3) do create(:project, :private, @@ -724,7 +724,7 @@ describe API::Projects do expect(json_response['message']).to eq('404 User Not Found') end - it 'returns projects filtered by user' do + it 'returns projects filtered by user id' do get api("/users/#{user4.id}/projects/", user) expect(response).to have_gitlab_http_status(200) @@ -733,6 +733,15 @@ describe API::Projects do expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id) end + it 'returns projects filtered by username' do + get api("/users/#{user4.username}/projects/", user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.map { |project| project['id'] }).to contain_exactly(public_project.id) + end + it 'returns projects filtered by minimal access level' do private_project1 = create(:project, :private, name: 'private_project1', creator_id: user4.id, namespace: user4.namespace) private_project2 = create(:project, :private, name: 'private_project2', creator_id: user4.id, namespace: user4.namespace) diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index f3431e0be3d..1cf0f4f2cf7 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe API::Users do - let(:user) { create(:user) } + let(:user) { create(:user, username: 'user.with.dot') } let(:admin) { create(:admin) } let(:key) { create(:key, user: user) } let(:gpg_key) { create(:gpg_key, user: user) } -- cgit v1.2.3 From 33200ed6904b00fc6c24363e29f4a8de895fb6c1 Mon Sep 17 00:00:00 2001 From: Robert Schilling Date: Tue, 15 Jan 2019 16:19:01 +0100 Subject: Add missing changelogs --- .../51913-api-getting-projects-for-users-with-dot-gets-404.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/51913-api-getting-projects-for-users-with-dot-gets-404.yml diff --git a/changelogs/unreleased/51913-api-getting-projects-for-users-with-dot-gets-404.yml b/changelogs/unreleased/51913-api-getting-projects-for-users-with-dot-gets-404.yml new file mode 100644 index 00000000000..9d72efdd52a --- /dev/null +++ b/changelogs/unreleased/51913-api-getting-projects-for-users-with-dot-gets-404.yml @@ -0,0 +1,5 @@ +--- +title: 'API: Support username with dots' +merge_request: 24395 +author: Robert Schilling +type: fixed -- cgit v1.2.3 From 4ae4a4799e52f9382560388a3c08296f5e596db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Trzci=C5=84ski?= Date: Wed, 16 Jan 2019 12:48:50 +0100 Subject: Update pages config only when changed --- .../projects/update_pages_configuration_service.rb | 23 +++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb index abf40b3ad7a..43eb110c994 100644 --- a/app/services/projects/update_pages_configuration_service.rb +++ b/app/services/projects/update_pages_configuration_service.rb @@ -9,8 +9,10 @@ module Projects end def execute - update_file(pages_config_file, pages_config.to_json) - reload_daemon + if update_file(pages_config_file, pages_config.to_json) + reload_daemon + end + success rescue => e error(e.message) @@ -69,7 +71,12 @@ module Projects def update_file(file, data) unless data FileUtils.remove(file, force: true) - return + return true + end + + existing_data = read_file(file) + if data == existing_data + return false end temp_file = "#{file}.#{SecureRandom.hex(16)}" @@ -77,9 +84,19 @@ module Projects f.write(data) end FileUtils.move(temp_file, file, force: true) + + true ensure # In case if the updating fails FileUtils.remove(temp_file, force: true) end + + def read_file(file) + File.open(file, 'r') do |f| + f.read + end + rescue + nil + end end end -- cgit v1.2.3 From ebda58e817b843116a9e39ffcac05c9d5aa39535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Trzci=C5=84ski?= Date: Wed, 16 Jan 2019 13:00:43 +0100 Subject: Do not reload daemon if configuration file of pages does not change --- .../projects/update_pages_configuration_service.rb | 33 ++++++++++++---------- .../update-pages-config-only-when-changed.yml | 5 ++++ .../update_pages_configuration_service_spec.rb | 32 ++++++++++++++++----- 3 files changed, 48 insertions(+), 22 deletions(-) create mode 100644 changelogs/unreleased/update-pages-config-only-when-changed.yml diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb index 43eb110c994..674071ad92a 100644 --- a/app/services/projects/update_pages_configuration_service.rb +++ b/app/services/projects/update_pages_configuration_service.rb @@ -2,6 +2,8 @@ module Projects class UpdatePagesConfigurationService < BaseService + include Gitlab::Utils::StrongMemoize + attr_reader :project def initialize(project) @@ -9,17 +11,25 @@ module Projects end def execute - if update_file(pages_config_file, pages_config.to_json) - reload_daemon + if file_equals?(pages_config_file, pages_config_json) + return success(reload: false) end - success + update_file(pages_config_file, pages_config_json) + reload_daemon + success(reload: true) rescue => e error(e.message) end private + def pages_config_json + strong_memoize(:pages_config_json) do + pages_config.to_json + end + end + def pages_config { domains: pages_domains_config, @@ -69,28 +79,21 @@ module Projects end def update_file(file, data) - unless data - FileUtils.remove(file, force: true) - return true - end - - existing_data = read_file(file) - if data == existing_data - return false - end - temp_file = "#{file}.#{SecureRandom.hex(16)}" File.open(temp_file, 'w') do |f| f.write(data) end FileUtils.move(temp_file, file, force: true) - - true ensure # In case if the updating fails FileUtils.remove(temp_file, force: true) end + def file_equals?(file, data) + existing_data = read_file(file) + data == existing_data.to_s + end + def read_file(file) File.open(file, 'r') do |f| f.read diff --git a/changelogs/unreleased/update-pages-config-only-when-changed.yml b/changelogs/unreleased/update-pages-config-only-when-changed.yml new file mode 100644 index 00000000000..8d9e02df678 --- /dev/null +++ b/changelogs/unreleased/update-pages-config-only-when-changed.yml @@ -0,0 +1,5 @@ +--- +title: Do not reload daemon if configuration file of pages does not change +merge_request: +author: +type: performance diff --git a/spec/services/projects/update_pages_configuration_service_spec.rb b/spec/services/projects/update_pages_configuration_service_spec.rb index e4d4e6ff3dd..7f5ef3129d7 100644 --- a/spec/services/projects/update_pages_configuration_service_spec.rb +++ b/spec/services/projects/update_pages_configuration_service_spec.rb @@ -2,23 +2,41 @@ require 'spec_helper' describe Projects::UpdatePagesConfigurationService do let(:project) { create(:project) } - subject { described_class.new(project) } + let(:service) { described_class.new(project) } describe "#update" do let(:file) { Tempfile.new('pages-test') } + subject { service.execute } + after do file.close file.unlink end - it 'updates the .update file' do - # Access this reference to ensure scoping works - Projects::Settings # rubocop:disable Lint/Void - expect(subject).to receive(:pages_config_file).and_return(file.path) - expect(subject).to receive(:reload_daemon).and_call_original + before do + allow(service).to receive(:pages_config_file).and_return(file.path) + end + + context 'when configuration changes' do + it 'updates the .update file' do + expect(service).to receive(:reload_daemon).and_call_original + + expect(subject).to include(status: :success, reload: true) + end + end + + context 'when configuration does not change' do + before do + # we set the configuration + service.execute + end + + it 'does not update the .update file' do + expect(service).not_to receive(:reload_daemon) - expect(subject.execute).to eq({ status: :success }) + expect(subject).to include(status: :success, reload: false) + end end end end -- cgit v1.2.3 From c3cfffbc6fcb0bdcbc45e7670a041b2a38f1b6f1 Mon Sep 17 00:00:00 2001 From: Luke Duncalfe Date: Tue, 15 Jan 2019 15:11:04 +1300 Subject: Fix private user email being visible in tag webhooks Fixes #54721 --- changelogs/unreleased/security-fix-user-email-tag-push-leak.yml | 5 +++++ lib/gitlab/data_builder/push.rb | 2 +- spec/lib/gitlab/data_builder/push_spec.rb | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 changelogs/unreleased/security-fix-user-email-tag-push-leak.yml diff --git a/changelogs/unreleased/security-fix-user-email-tag-push-leak.yml b/changelogs/unreleased/security-fix-user-email-tag-push-leak.yml new file mode 100644 index 00000000000..915ea7b5216 --- /dev/null +++ b/changelogs/unreleased/security-fix-user-email-tag-push-leak.yml @@ -0,0 +1,5 @@ +--- +title: Fix private user email being visible in push (and tag push) webhooks +merge_request: +author: +type: security diff --git a/lib/gitlab/data_builder/push.rb b/lib/gitlab/data_builder/push.rb index 862127110b9..ea08b5f7eae 100644 --- a/lib/gitlab/data_builder/push.rb +++ b/lib/gitlab/data_builder/push.rb @@ -93,7 +93,7 @@ module Gitlab user_id: user.id, user_name: user.name, user_username: user.username, - user_email: user.email, + user_email: user.public_email, user_avatar: user.avatar_url(only_path: false), project_id: project.id, project: project.hook_attrs, diff --git a/spec/lib/gitlab/data_builder/push_spec.rb b/spec/lib/gitlab/data_builder/push_spec.rb index d70438ba27c..0c4decc6518 100644 --- a/spec/lib/gitlab/data_builder/push_spec.rb +++ b/spec/lib/gitlab/data_builder/push_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Gitlab::DataBuilder::Push do let(:project) { create(:project, :repository) } - let(:user) { build(:user) } + let(:user) { build(:user, public_email: 'public-email@example.com') } describe '.build_sample' do let(:data) { described_class.build_sample(project, user) } @@ -36,7 +36,7 @@ describe Gitlab::DataBuilder::Push do it { expect(data[:user_id]).to eq(user.id) } it { expect(data[:user_name]).to eq(user.name) } it { expect(data[:user_username]).to eq(user.username) } - it { expect(data[:user_email]).to eq(user.email) } + it { expect(data[:user_email]).to eq(user.public_email) } it { expect(data[:user_avatar]).to eq(user.avatar_url) } it { expect(data[:project_id]).to eq(project.id) } it { expect(data[:project]).to be_a(Hash) } -- cgit v1.2.3 From e5c0ee815fcd7b073e52882e0fd10a6dcb5369bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20L=C3=B3pez?= Date: Wed, 9 Jan 2019 16:05:52 +0100 Subject: Fixed bug when external wiki is enabled When the external wiki is enabled, the internal wiki link is replaced by the external wiki url. But the internal wiki is still accessible. In this change the external wiki will have its own tab in the sidebar and only if the services are disabled the tab (and access rights) will not be displayed. --- app/helpers/external_wiki_helper.rb | 12 ----- app/helpers/projects_helper.rb | 13 ++++-- app/policies/project_policy.rb | 2 +- app/views/layouts/nav/sidebar/_project.html.haml | 21 +++++++-- app/views/projects/blob/viewers/_readme.html.haml | 2 +- app/views/projects/wikis/pages.html.haml | 2 +- app/views/projects/wikis/show.html.haml | 2 +- ...ki-access-rights-with-external-wiki-enabled.yml | 5 ++ locale/gitlab.pot | 3 ++ spec/helpers/projects_helper_spec.rb | 20 ++++++++ spec/models/ability_spec.rb | 1 - .../project_services/external_wiki_service_spec.rb | 21 --------- spec/policies/project_policy_spec.rb | 24 +++++++--- .../layouts/nav/sidebar/_project.html.haml_spec.rb | 54 ++++++++++++++++++++++ 14 files changed, 131 insertions(+), 51 deletions(-) delete mode 100644 app/helpers/external_wiki_helper.rb create mode 100644 changelogs/unreleased/security-fix-wiki-access-rights-with-external-wiki-enabled.yml diff --git a/app/helpers/external_wiki_helper.rb b/app/helpers/external_wiki_helper.rb deleted file mode 100644 index e36d63b2946..00000000000 --- a/app/helpers/external_wiki_helper.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -module ExternalWikiHelper - def get_project_wiki_path(project) - external_wiki_service = project.external_wiki - if external_wiki_service - external_wiki_service.properties['external_wiki_url'] - else - project_wiki_path(project, :home) - end - end -end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index e67c327f7f8..085debcaf0e 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -313,19 +313,24 @@ module ProjectsHelper nav_tabs << :operations end - if project.external_issue_tracker - nav_tabs << :external_issue_tracker - end - tab_ability_map.each do |tab, ability| if can?(current_user, ability, project) nav_tabs << tab end end + nav_tabs << external_nav_tabs(project) + nav_tabs.flatten end + def external_nav_tabs(project) + [].tap do |tabs| + tabs << :external_issue_tracker if project.external_issue_tracker + tabs << :external_wiki if project.has_external_wiki? + end + end + def tab_ability_map { environments: :read_environment, diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 3146f26bed5..c336d5907d9 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -311,7 +311,7 @@ class ProjectPolicy < BasePolicy prevent(*create_read_update_admin_destroy(:project_snippet)) end - rule { wiki_disabled & ~has_external_wiki }.policy do + rule { wiki_disabled }.policy do prevent(*create_read_update_admin_destroy(:wiki)) prevent(:download_wiki_code) end diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index e516c76400a..2321eebf1f3 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -275,19 +275,34 @@ %strong.fly-out-top-item-name = _('Registry') - - if project_nav_tab? :wiki + - if project_nav_tab?(:wiki) + - wiki_url = project_wiki_path(@project, :home) = nav_link(controller: :wikis) do - = link_to get_project_wiki_path(@project), class: 'shortcuts-wiki' do + = link_to wiki_url, class: 'shortcuts-wiki' do .nav-icon-container = sprite_icon('book') %span.nav-item-name = _('Wiki') %ul.sidebar-sub-level-items.is-fly-out-only = nav_link(controller: :wikis, html_options: { class: "fly-out-top-item" } ) do - = link_to get_project_wiki_path(@project) do + = link_to wiki_url do %strong.fly-out-top-item-name = _('Wiki') + - if project_nav_tab?(:external_wiki) + - external_wiki_url = @project.external_wiki.external_wiki_url + = nav_link do + = link_to external_wiki_url, class: 'shortcuts-external_wiki' do + .nav-icon-container + = sprite_icon('issue-external') + %span.nav-item-name + = _('External Wiki') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(html_options: { class: "fly-out-top-item" } ) do + = link_to external_wiki_url do + %strong.fly-out-top-item-name + = _('External Wiki') + - if project_nav_tab? :snippets = nav_link(controller: :snippets) do = link_to project_snippets_path(@project), class: 'shortcuts-snippets' do diff --git a/app/views/projects/blob/viewers/_readme.html.haml b/app/views/projects/blob/viewers/_readme.html.haml index d8492abc638..c2329a7aa66 100644 --- a/app/views/projects/blob/viewers/_readme.html.haml +++ b/app/views/projects/blob/viewers/_readme.html.haml @@ -1,4 +1,4 @@ = icon('info-circle fw') = succeed '.' do To learn more about this project, read - = link_to "the wiki", get_project_wiki_path(viewer.project) + = link_to "the wiki", project_wiki_path(viewer.project, :home) diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/projects/wikis/pages.html.haml index aeef64fd7eb..94267b6e0cf 100644 --- a/app/views/projects/wikis/pages.html.haml +++ b/app/views/projects/wikis/pages.html.haml @@ -1,5 +1,5 @@ - @no_container = true -- add_to_breadcrumbs "Wiki", get_project_wiki_path(@project) +- add_to_breadcrumbs "Wiki", project_wiki_path(@project, :home) - breadcrumb_title s_("Wiki|Pages") - page_title s_("Wiki|Pages"), _("Wiki") diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index 4d5fd55364c..8b348bb4e4f 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -2,7 +2,7 @@ - breadcrumb_title @page.human_title - wiki_breadcrumb_dropdown_links(@page.slug) - page_title @page.human_title, _("Wiki") -- add_to_breadcrumbs _("Wiki"), get_project_wiki_path(@project) +- add_to_breadcrumbs _("Wiki"), project_wiki_path(@project, :home) .wiki-page-header.has-sidebar-toggle %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } diff --git a/changelogs/unreleased/security-fix-wiki-access-rights-with-external-wiki-enabled.yml b/changelogs/unreleased/security-fix-wiki-access-rights-with-external-wiki-enabled.yml new file mode 100644 index 00000000000..d5f20b87a90 --- /dev/null +++ b/changelogs/unreleased/security-fix-wiki-access-rights-with-external-wiki-enabled.yml @@ -0,0 +1,5 @@ +--- +title: Fix wiki access rights when external wiki is enabled +merge_request: +author: +type: security diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f25f9cc531a..3d376085da0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3013,6 +3013,9 @@ msgstr "" msgid "External URL" msgstr "" +msgid "External Wiki" +msgstr "" + msgid "Facebook" msgstr "" diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 88b5d87f087..c2dd666f9df 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -358,6 +358,26 @@ describe ProjectsHelper do is_expected.not_to include(:pipelines) end end + + context 'when project has external wiki' do + before do + allow(project).to receive(:has_external_wiki?).and_return(true) + end + + it 'includes external wiki tab' do + is_expected.to include(:external_wiki) + end + end + + context 'when project does not have external wiki' do + before do + allow(project).to receive(:has_external_wiki?).and_return(false) + end + + it 'does not include external wiki tab' do + is_expected.not_to include(:external_wiki) + end + end end describe '#show_projects' do diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index 199f49d0bf2..eee80e9bad7 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -298,7 +298,6 @@ describe Ability do context 'wiki named abilities' do it 'disables wiki abilities if the project has no wiki' do - expect(project).to receive(:has_external_wiki?).and_return(false) expect(subject).not_to be_allowed(:read_wiki) expect(subject).not_to be_allowed(:create_wiki) expect(subject).not_to be_allowed(:update_wiki) diff --git a/spec/models/project_services/external_wiki_service_spec.rb b/spec/models/project_services/external_wiki_service_spec.rb index 25e6ce7e804..62fd97b038b 100644 --- a/spec/models/project_services/external_wiki_service_spec.rb +++ b/spec/models/project_services/external_wiki_service_spec.rb @@ -1,7 +1,6 @@ require 'spec_helper' describe ExternalWikiService do - include ExternalWikiHelper describe "Associations" do it { is_expected.to belong_to :project } it { is_expected.to have_one :service_hook } @@ -25,24 +24,4 @@ describe ExternalWikiService do it { is_expected.not_to validate_presence_of(:external_wiki_url) } end end - - describe 'External wiki' do - let(:project) { create(:project) } - - context 'when it is active' do - before do - properties = { 'external_wiki_url' => 'https://gitlab.com' } - @service = project.create_external_wiki_service(active: true, properties: properties) - end - - after do - @service.destroy! - end - - it 'replaces the wiki url' do - wiki_path = get_project_wiki_path(project) - expect(wiki_path).to match('https://gitlab.com') - end - end - end end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index 9cb20854f6e..54d52b91d61 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -102,15 +102,27 @@ describe ProjectPolicy do expect(Ability).not_to be_allowed(user, :read_issue, project) end - context 'when the feature is disabled' do + context 'wiki feature' do + let(:permissions) { %i(read_wiki create_wiki update_wiki admin_wiki download_wiki_code) } + subject { described_class.new(owner, project) } - before do - project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED) - end + context 'when the feature is disabled' do + before do + project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED) + end - it 'does not include the wiki permissions' do - expect_disallowed :read_wiki, :create_wiki, :update_wiki, :admin_wiki, :download_wiki_code + it 'does not include the wiki permissions' do + expect_disallowed(*permissions) + end + + context 'when there is an external wiki' do + it 'does not include the wiki permissions' do + allow(project).to receive(:has_external_wiki?).and_return(true) + + expect_disallowed(*permissions) + end + end end end diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb index ec20c346234..9d7f1d47598 100644 --- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb @@ -75,4 +75,58 @@ describe 'layouts/nav/sidebar/_project' do end end end + + describe 'wiki entry tab' do + let(:can_read_wiki) { true } + + before do + allow(view).to receive(:can?).with(nil, :read_wiki, project).and_return(can_read_wiki) + end + + describe 'when wiki is enabled' do + it 'shows the wiki tab with the wiki internal link' do + render + + expect(rendered).to have_link('Wiki', href: project_wiki_path(project, :home)) + end + end + + describe 'when wiki is disabled' do + let(:can_read_wiki) { false } + + it 'does not show the wiki tab' do + render + + expect(rendered).not_to have_link('Wiki', href: project_wiki_path(project, :home)) + end + end + end + + describe 'external wiki entry tab' do + let(:properties) { { 'external_wiki_url' => 'https://gitlab.com' } } + let(:service_status) { true } + + before do + project.create_external_wiki_service(active: service_status, properties: properties) + project.reload + end + + context 'when it is active' do + it 'shows the external wiki tab with the external wiki service link' do + render + + expect(rendered).to have_link('External Wiki', href: properties['external_wiki_url']) + end + end + + context 'when it is disabled' do + let(:service_status) { false } + + it 'does not show the external wiki tab' do + render + + expect(rendered).not_to have_link('External Wiki', href: project_wiki_path(project, :home)) + end + end + end end -- cgit v1.2.3 From 9da3ae70923fdf35e4850676cc1d238e5d63ea16 Mon Sep 17 00:00:00 2001 From: Hiroyuki Sato Date: Sun, 20 Jan 2019 11:41:11 +0900 Subject: Don't create merge request pipeline without commits --- app/models/ci/pipeline.rb | 7 +++++++ .../unreleased/not-run-pipeline-on-empty-merge-request.yml | 5 +++++ spec/models/ci/pipeline_spec.rb | 13 ++++++++++--- spec/models/merge_request_spec.rb | 2 +- spec/services/ci/create_pipeline_service_spec.rb | 9 +++++++++ spec/services/merge_requests/refresh_service_spec.rb | 8 ++++++-- 6 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 30a957b4117..09fcdc03577 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -54,6 +54,7 @@ module Ci validates :ref, presence: { unless: :importing? } validates :merge_request, presence: { if: :merge_request? } validates :merge_request, absence: { unless: :merge_request? } + validate :presence_of_commits_in_merge_request, if: :merge_request? validates :tag, inclusion: { in: [false], if: :merge_request? } validates :status, presence: { unless: :importing? } validate :valid_commit_sha, unless: :importing? @@ -345,6 +346,12 @@ module Ci end end + def presence_of_commits_in_merge_request + if merge_request&.has_no_commits? + errors.add(:merge_request, "must have commits") + end + end + def git_author_name strong_memoize(:git_author_name) do commit.try(:author_name) diff --git a/changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml b/changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml new file mode 100644 index 00000000000..732e4baf4e9 --- /dev/null +++ b/changelogs/unreleased/not-run-pipeline-on-empty-merge-request.yml @@ -0,0 +1,5 @@ +--- +title: Don't create new merge request pipeline without commits +merge_request: 24503 +author: Hiroyuki Sato +type: added diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 17f33785fda..2869022f2f6 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Ci::Pipeline, :mailer do let(:user) { create(:user) } - set(:project) { create(:project) } + set(:project) { create(:project, :repository) } let(:pipeline) do create(:ci_empty_pipeline, status: :created, project: project) @@ -131,12 +131,18 @@ describe Ci::Pipeline, :mailer do context 'when source is merge request' do let(:source) { :merge_request } - context 'when merge request is specified' do + context 'when merge request with commits is specified' do let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_project: project, target_branch: 'master') } it { expect(pipeline).to be_valid } end + context 'when merge request without commits is specified' do + let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'not-merged-branch', target_project: project, target_branch: 'master') } + + it { expect(pipeline).not_to be_valid } + end + context 'when merge request is empty' do let(:merge_request) { nil } @@ -1036,6 +1042,8 @@ describe Ci::Pipeline, :mailer do end context 'when repository does not exist' do + let(:project) { create(:project) } + let(:pipeline) do create(:ci_empty_pipeline, project: project, ref: 'master') end @@ -2071,7 +2079,6 @@ describe Ci::Pipeline, :mailer do end describe "#all_merge_requests" do - let(:project) { create(:project) } let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: 'master') } it "returns all merge requests having the same source branch" do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index bfc9035cb56..c9643b34fa0 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1378,7 +1378,7 @@ describe MergeRequest do source_project: project, source_branch: source_ref, target_project: project, - target_branch: 'stable') + target_branch: 'merge-test') end before do diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 8497e90bd8b..be6bdc21b81 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -736,6 +736,15 @@ describe Ci::CreatePipelineService do end end + context 'when there are no commits between source branch and target branch' do + let(:ref_name) { 'refs/heads/not-merged-branch' } + + it 'does not create a merge request pipeline' do + expect(pipeline).not_to be_persisted + expect(pipeline.errors[:merge_request]).to eq(["must have commits"]) + end + end + context 'when merge request is created from a forked project' do let(:merge_request) do create(:merge_request, diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 1169ed5f9f2..57b8e71c2d5 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -150,11 +150,15 @@ describe MergeRequests::RefreshService do } end - it 'create merge request pipeline' do + it 'create merge request pipeline with commits' do expect { subject } .to change { @merge_request.merge_request_pipelines.count }.by(1) .and change { @fork_merge_request.merge_request_pipelines.count }.by(1) - .and change { @another_merge_request.merge_request_pipelines.count }.by(1) + .and change { @another_merge_request.merge_request_pipelines.count }.by(0) + + expect(@merge_request.has_commits?).to be true + expect(@fork_merge_request.has_commits?).to be true + expect(@another_merge_request.has_commits?).to be false end context "when branch pipeline was created before a merge request pipline has been created" do -- cgit v1.2.3 From 9ec860ea05d5c74387cbff4593ca76072a38ad5f Mon Sep 17 00:00:00 2001 From: Tiago Botelho Date: Tue, 11 Dec 2018 14:52:22 +0000 Subject: Group Guests are no longer able to see merge requests Group guests will only be displayed merge requests to projects they have a access level to, higher than Reporter. Visible projects will still display the merge requests to Guests --- app/models/project.rb | 22 +++++--- app/models/project_feature.rb | 19 ++++++- app/models/user.rb | 8 ++- ...urity-guests-can-see-list-of-merge-requests.yml | 6 +++ spec/finders/merge_requests_finder_spec.rb | 32 ++++++++---- spec/models/project_spec.rb | 60 ++++++++++++++++++++++ spec/models/user_spec.rb | 27 ++++++++++ 7 files changed, 154 insertions(+), 20 deletions(-) create mode 100644 changelogs/unreleased/security-guests-can-see-list-of-merge-requests.yml diff --git a/app/models/project.rb b/app/models/project.rb index 15465d9b356..ccf4ab5171b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -377,8 +377,10 @@ class Project < ActiveRecord::Base # "enabled" here means "not disabled". It includes private features! scope :with_feature_enabled, ->(feature) { - access_level_attribute = ProjectFeature.access_level_attribute(feature) - with_project_feature.where(project_features: { access_level_attribute => [nil, ProjectFeature::PRIVATE, ProjectFeature::ENABLED, ProjectFeature::PUBLIC] }) + access_level_attribute = ProjectFeature.arel_table[ProjectFeature.access_level_attribute(feature)] + enabled_feature = access_level_attribute.gt(ProjectFeature::DISABLED).or(access_level_attribute.eq(nil)) + + with_project_feature.where(enabled_feature) } # Picks a feature where the level is exactly that given. @@ -465,7 +467,8 @@ class Project < ActiveRecord::Base # logged in users to more efficiently get private projects with the given # feature. def self.with_feature_available_for_user(feature, user) - visible = [nil, ProjectFeature::ENABLED, ProjectFeature::PUBLIC] + visible = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC] + min_access_level = ProjectFeature.required_minimum_access_level(feature) if user&.admin? with_feature_enabled(feature) @@ -473,10 +476,15 @@ class Project < ActiveRecord::Base column = ProjectFeature.quoted_access_level_column(feature) with_project_feature - .where("#{column} IN (?) OR (#{column} = ? AND EXISTS (?))", - visible, - ProjectFeature::PRIVATE, - user.authorizations_for_projects) + .where( + "(projects.visibility_level > :private AND (#{column} IS NULL OR #{column} >= (:public_visible) OR (#{column} = :private_visible AND EXISTS(:authorizations))))"\ + " OR (projects.visibility_level = :private AND (#{column} IS NULL OR #{column} >= :private_visible) AND EXISTS(:authorizations))", + { + private: Gitlab::VisibilityLevel::PRIVATE, + public_visible: ProjectFeature::ENABLED, + private_visible: ProjectFeature::PRIVATE, + authorizations: user.authorizations_for_projects(min_access_level: min_access_level) + }) else with_feature_access_level(feature, visible) end diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 39f2b8fe0de..f700090a493 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -23,11 +23,11 @@ class ProjectFeature < ActiveRecord::Base PUBLIC = 30 FEATURES = %i(issues merge_requests wiki snippets builds repository pages).freeze + PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze class << self def access_level_attribute(feature) - feature = feature.model_name.plural.to_sym if feature.respond_to?(:model_name) - raise ArgumentError, "invalid project feature: #{feature}" unless FEATURES.include?(feature) + feature = ensure_feature!(feature) "#{feature}_access_level".to_sym end @@ -38,6 +38,21 @@ class ProjectFeature < ActiveRecord::Base "#{table}.#{attribute}" end + + def required_minimum_access_level(feature) + feature = ensure_feature!(feature) + + PRIVATE_FEATURES_MIN_ACCESS_LEVEL.fetch(feature, Gitlab::Access::GUEST) + end + + private + + def ensure_feature!(feature) + feature = feature.model_name.plural.to_sym if feature.respond_to?(:model_name) + raise ArgumentError, "invalid project feature: #{feature}" unless FEATURES.include?(feature) + + feature + end end # Default scopes force us to unscope here since a service may need to check diff --git a/app/models/user.rb b/app/models/user.rb index 26fd2d903a1..262b1835e98 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -754,8 +754,12 @@ class User < ActiveRecord::Base # # Example use: # `Project.where('EXISTS(?)', user.authorizations_for_projects)` - def authorizations_for_projects - project_authorizations.select(1).where('project_authorizations.project_id = projects.id') + def authorizations_for_projects(min_access_level: nil) + authorizations = project_authorizations.select(1).where('project_authorizations.project_id = projects.id') + + return authorizations unless min_access_level.present? + + authorizations.where('project_authorizations.access_level >= ?', min_access_level) end # Returns the projects this user has reporter (or greater) access to, limited diff --git a/changelogs/unreleased/security-guests-can-see-list-of-merge-requests.yml b/changelogs/unreleased/security-guests-can-see-list-of-merge-requests.yml new file mode 100644 index 00000000000..f5b74011829 --- /dev/null +++ b/changelogs/unreleased/security-guests-can-see-list-of-merge-requests.yml @@ -0,0 +1,6 @@ +--- +title: Group guests are no longer able to see merge requests they don't have access + to at group level +merge_request: +author: +type: security diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index ff4c6b8dd42..107da08a0a9 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -68,20 +68,34 @@ describe MergeRequestsFinder do expect(merge_requests.size).to eq(2) end - it 'filters by group' do - params = { group_id: group.id } + context 'filtering by group' do + it 'includes all merge requests when user has access' do + params = { group_id: group.id } - merge_requests = described_class.new(user, params).execute + merge_requests = described_class.new(user, params).execute - expect(merge_requests.size).to eq(3) - end + expect(merge_requests.size).to eq(3) + end - it 'filters by group including subgroups', :nested_groups do - params = { group_id: group.id, include_subgroups: true } + it 'excludes merge requests from projects the user does not have access to' do + private_project = create_project_without_n_plus_1(:private, group: group) + private_mr = create(:merge_request, :simple, author: user, source_project: private_project, target_project: private_project) + params = { group_id: group.id } - merge_requests = described_class.new(user, params).execute + private_project.add_guest(user) + merge_requests = described_class.new(user, params).execute - expect(merge_requests.size).to eq(6) + expect(merge_requests.size).to eq(3) + expect(merge_requests).not_to include(private_mr) + end + + it 'filters by group including subgroups', :nested_groups do + params = { group_id: group.id, include_subgroups: true } + + merge_requests = described_class.new(user, params).execute + + expect(merge_requests.size).to eq(6) + end end it 'filters by non_archived' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 7a8dc59039e..585dfe46189 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -3074,6 +3074,66 @@ describe Project do end end + describe '.with_feature_available_for_user' do + let!(:user) { create(:user) } + let!(:feature) { MergeRequest } + let!(:project) { create(:project, :public, :merge_requests_enabled) } + + subject { described_class.with_feature_available_for_user(feature, user) } + + context 'when user has access to project' do + subject { described_class.with_feature_available_for_user(feature, user) } + + before do + project.add_guest(user) + end + + context 'when public project' do + context 'when feature is public' do + it 'returns project' do + is_expected.to include(project) + end + end + + context 'when feature is private' do + let!(:project) { create(:project, :public, :merge_requests_private) } + + it 'returns project when user has access to the feature' do + project.add_maintainer(user) + + is_expected.to include(project) + end + + it 'does not return project when user does not have the minimum access level required' do + is_expected.not_to include(project) + end + end + end + + context 'when private project' do + let!(:project) { create(:project) } + + it 'returns project when user has access to the feature' do + project.add_maintainer(user) + + is_expected.to include(project) + end + + it 'does not return project when user does not have the minimum access level required' do + is_expected.not_to include(project) + end + end + end + + context 'when user does not have access to project' do + let!(:project) { create(:project) } + + it 'does not return project when user cant access project' do + is_expected.not_to include(project) + end + end + end + describe '#pages_available?' do let(:project) { create(:project, group: group) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 33842e74b92..78477ab0a5a 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1997,6 +1997,33 @@ describe User do expect(subject).to include(accessible) expect(subject).not_to include(other) end + + context 'with min_access_level' do + let!(:user) { create(:user) } + let!(:project) { create(:project, :private, namespace: user.namespace) } + + before do + project.add_developer(user) + end + + subject { Project.where("EXISTS (?)", user.authorizations_for_projects(min_access_level: min_access_level)) } + + context 'when developer access' do + let(:min_access_level) { Gitlab::Access::DEVELOPER } + + it 'includes projects a user has access to' do + expect(subject).to include(project) + end + end + + context 'when owner access' do + let(:min_access_level) { Gitlab::Access::OWNER } + + it 'does not include projects with higher access level' do + expect(subject).not_to include(project) + end + end + end end describe '#authorized_projects', :delete do -- cgit v1.2.3 From 79b256ed6dedf3e5f0c670f1dac47b113039cbff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20L=C3=B3pez?= Date: Fri, 14 Dec 2018 17:51:37 +0100 Subject: Added validations to prevent LFS object forgery --- app/models/lfs_download_object.rb | 22 +++ app/services/projects/import_service.rb | 8 +- .../lfs_pointers/lfs_download_link_list_service.rb | 13 +- .../projects/lfs_pointers/lfs_download_service.rb | 109 +++++++++----- ...ecurity-fix-lfs-import-project-ssrf-forgery.yml | 5 + .../github_import/importer/lfs_object_importer.rb | 8 +- .../github_import/representation/lfs_object.rb | 4 +- .../importer/lfs_object_importer_spec.rb | 22 ++- .../importer/lfs_objects_importer_spec.rb | 14 +- spec/models/lfs_download_object_spec.rb | 68 +++++++++ spec/services/projects/import_service_spec.rb | 9 +- .../lfs_download_link_list_service_spec.rb | 18 +-- .../lfs_pointers/lfs_download_service_spec.rb | 162 +++++++++++++++++---- 13 files changed, 359 insertions(+), 103 deletions(-) create mode 100644 app/models/lfs_download_object.rb create mode 100644 changelogs/unreleased/security-fix-lfs-import-project-ssrf-forgery.yml create mode 100644 spec/models/lfs_download_object_spec.rb diff --git a/app/models/lfs_download_object.rb b/app/models/lfs_download_object.rb new file mode 100644 index 00000000000..6383f95d546 --- /dev/null +++ b/app/models/lfs_download_object.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class LfsDownloadObject + include ActiveModel::Validations + + attr_accessor :oid, :size, :link + delegate :sanitized_url, :credentials, to: :sanitized_uri + + validates :oid, format: { with: /\A\h{64}\z/ } + validates :size, numericality: { greater_than_or_equal_to: 0 } + validates :link, public_url: { protocols: %w(http https) } + + def initialize(oid:, size:, link:) + @oid = oid + @size = size + @link = link + end + + def sanitized_uri + @sanitized_uri ||= Gitlab::UrlSanitizer.new(link) + end +end diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index 0c426faa22d..3353aec6aec 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -86,11 +86,11 @@ module Projects return unless project.lfs_enabled? - oids_to_download = Projects::LfsPointers::LfsImportService.new(project).execute - download_service = Projects::LfsPointers::LfsDownloadService.new(project) + lfs_objects_to_download = Projects::LfsPointers::LfsImportService.new(project).execute - oids_to_download.each do |oid, link| - download_service.execute(oid, link) + lfs_objects_to_download.each do |lfs_download_object| + Projects::LfsPointers::LfsDownloadService.new(project, lfs_download_object) + .execute end rescue => e # Right now, to avoid aborting the importing process, we silently fail diff --git a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb index a837ea82e38..7998976b00a 100644 --- a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb @@ -41,16 +41,17 @@ module Projects end def parse_response_links(objects_response) - objects_response.each_with_object({}) do |entry, link_list| + objects_response.each_with_object([]) do |entry, link_list| begin - oid = entry['oid'] link = entry.dig('actions', DOWNLOAD_ACTION, 'href') raise DownloadLinkNotFound unless link - link_list[oid] = add_credentials(link) - rescue DownloadLinkNotFound, URI::InvalidURIError - Rails.logger.error("Link for Lfs Object with oid #{oid} not found or invalid.") + link_list << LfsDownloadObject.new(oid: entry['oid'], + size: entry['size'], + link: add_credentials(link)) + rescue DownloadLinkNotFound, Addressable::URI::InvalidURIError + log_error("Link for Lfs Object with oid #{entry['oid']} not found or invalid.") end end end @@ -70,7 +71,7 @@ module Projects end def add_credentials(link) - uri = URI.parse(link) + uri = Addressable::URI.parse(link) if should_add_credentials?(uri) uri.user = remote_uri.user diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb index b5128443435..398f00a598d 100644 --- a/app/services/projects/lfs_pointers/lfs_download_service.rb +++ b/app/services/projects/lfs_pointers/lfs_download_service.rb @@ -4,68 +4,93 @@ module Projects module LfsPointers class LfsDownloadService < BaseService - VALID_PROTOCOLS = %w[http https].freeze + SizeError = Class.new(StandardError) + OidError = Class.new(StandardError) - # rubocop: disable CodeReuse/ActiveRecord - def execute(oid, url) - return unless project&.lfs_enabled? && oid.present? && url.present? + attr_reader :lfs_download_object + delegate :oid, :size, :credentials, :sanitized_url, to: :lfs_download_object, prefix: :lfs - return if LfsObject.exists?(oid: oid) + def initialize(project, lfs_download_object) + super(project) - sanitized_uri = sanitize_url!(url) + @lfs_download_object = lfs_download_object + end - with_tmp_file(oid) do |file| - download_and_save_file(file, sanitized_uri) - lfs_object = LfsObject.new(oid: oid, size: file.size, file: file) + # rubocop: disable CodeReuse/ActiveRecord + def execute + return unless project&.lfs_enabled? && lfs_download_object + return error("LFS file with oid #{lfs_oid} has invalid attributes") unless lfs_download_object.valid? + return if LfsObject.exists?(oid: lfs_oid) - project.all_lfs_objects << lfs_object + wrap_download_errors do + download_lfs_file! end - rescue Gitlab::UrlBlocker::BlockedUrlError => e - Rails.logger.error("LFS file with oid #{oid} couldn't be downloaded: #{e.message}") - rescue StandardError => e - Rails.logger.error("LFS file with oid #{oid} couldn't be downloaded from #{sanitized_uri.sanitized_url}: #{e.message}") end # rubocop: enable CodeReuse/ActiveRecord private - def sanitize_url!(url) - Gitlab::UrlSanitizer.new(url).tap do |sanitized_uri| - # Just validate that HTTP/HTTPS protocols are used. The - # subsequent Gitlab::HTTP.get call will do network checks - # based on the settings. - Gitlab::UrlBlocker.validate!(sanitized_uri.sanitized_url, - protocols: VALID_PROTOCOLS) + def wrap_download_errors(&block) + yield + rescue SizeError, OidError, StandardError => e + error("LFS file with oid #{lfs_oid} could't be downloaded from #{lfs_sanitized_url}: #{e.message}") + end + + def download_lfs_file! + with_tmp_file do |tmp_file| + download_and_save_file!(tmp_file) + project.all_lfs_objects << LfsObject.new(oid: lfs_oid, + size: lfs_size, + file: tmp_file) + + success end end - def download_and_save_file(file, sanitized_uri) - response = Gitlab::HTTP.get(sanitized_uri.sanitized_url, headers(sanitized_uri)) do |fragment| + def download_and_save_file!(file) + digester = Digest::SHA256.new + response = Gitlab::HTTP.get(lfs_sanitized_url, download_headers) do |fragment| + digester << fragment file.write(fragment) + + raise_size_error! if file.size > lfs_size end raise StandardError, "Received error code #{response.code}" unless response.success? - end - def headers(sanitized_uri) - query_options.tap do |headers| - credentials = sanitized_uri.credentials + raise_size_error! if file.size != lfs_size + raise_oid_error! if digester.hexdigest != lfs_oid + end - if credentials[:user].present? || credentials[:password].present? + def download_headers + { stream_body: true }.tap do |headers| + if lfs_credentials[:user].present? || lfs_credentials[:password].present? # Using authentication headers in the request - headers[:http_basic_authentication] = [credentials[:user], credentials[:password]] + headers[:basic_auth] = { username: lfs_credentials[:user], password: lfs_credentials[:password] } end end end - def query_options - { stream_body: true } - end - - def with_tmp_file(oid) + def with_tmp_file create_tmp_storage_dir - File.open(File.join(tmp_storage_dir, oid), 'wb') { |file| yield file } + File.open(tmp_filename, 'wb') do |file| + begin + yield file + rescue StandardError => e + # If the lfs file is successfully downloaded it will be removed + # when it is added to the project's lfs files. + # Nevertheless if any excetion raises the file would remain + # in the file system. Here we ensure to remove it + File.unlink(file) if File.exist?(file) + + raise e + end + end + end + + def tmp_filename + File.join(tmp_storage_dir, lfs_oid) end def create_tmp_storage_dir @@ -79,6 +104,20 @@ module Projects def storage_dir @storage_dir ||= Gitlab.config.lfs.storage_path end + + def raise_size_error! + raise SizeError, 'Size mistmatch' + end + + def raise_oid_error! + raise OidError, 'Oid mismatch' + end + + def error(message, http_status = nil) + log_error(message) + + super + end end end end diff --git a/changelogs/unreleased/security-fix-lfs-import-project-ssrf-forgery.yml b/changelogs/unreleased/security-fix-lfs-import-project-ssrf-forgery.yml new file mode 100644 index 00000000000..b6315ec29d8 --- /dev/null +++ b/changelogs/unreleased/security-fix-lfs-import-project-ssrf-forgery.yml @@ -0,0 +1,5 @@ +--- +title: Add more LFS validations to prevent forgery +merge_request: +author: +type: security diff --git a/lib/gitlab/github_import/importer/lfs_object_importer.rb b/lib/gitlab/github_import/importer/lfs_object_importer.rb index a88c17aaf82..195383fd3e9 100644 --- a/lib/gitlab/github_import/importer/lfs_object_importer.rb +++ b/lib/gitlab/github_import/importer/lfs_object_importer.rb @@ -13,10 +13,12 @@ module Gitlab @project = project end + def lfs_download_object + LfsDownloadObject.new(oid: lfs_object.oid, size: lfs_object.size, link: lfs_object.link) + end + def execute - Projects::LfsPointers::LfsDownloadService - .new(project) - .execute(lfs_object.oid, lfs_object.download_link) + Projects::LfsPointers::LfsDownloadService.new(project, lfs_download_object).execute end end end diff --git a/lib/gitlab/github_import/representation/lfs_object.rb b/lib/gitlab/github_import/representation/lfs_object.rb index debe0fa0baf..a4606173f49 100644 --- a/lib/gitlab/github_import/representation/lfs_object.rb +++ b/lib/gitlab/github_import/representation/lfs_object.rb @@ -9,11 +9,11 @@ module Gitlab attr_reader :attributes - expose_attribute :oid, :download_link + expose_attribute :oid, :link, :size # Builds a lfs_object def self.from_api_response(lfs_object) - new({ oid: lfs_object[0], download_link: lfs_object[1] }) + new({ oid: lfs_object.oid, link: lfs_object.link, size: lfs_object.size }) end # Builds a new lfs_object using a Hash that was built from a JSON payload. diff --git a/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb b/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb index 4857f2afbe2..8fd328d9c1e 100644 --- a/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb @@ -2,20 +2,26 @@ require 'spec_helper' describe Gitlab::GithubImport::Importer::LfsObjectImporter do let(:project) { create(:project) } - let(:download_link) { "http://www.gitlab.com/lfs_objects/oid" } - - let(:github_lfs_object) do - Gitlab::GithubImport::Representation::LfsObject.new( - oid: 'oid', download_link: download_link - ) + let(:lfs_attributes) do + { + oid: 'oid', + size: 1, + link: 'http://www.gitlab.com/lfs_objects/oid' + } end + let(:lfs_download_object) { LfsDownloadObject.new(lfs_attributes) } + let(:github_lfs_object) { Gitlab::GithubImport::Representation::LfsObject.new(lfs_attributes) } + let(:importer) { described_class.new(github_lfs_object, project, nil) } describe '#execute' do it 'calls the LfsDownloadService with the lfs object attributes' do - expect_any_instance_of(Projects::LfsPointers::LfsDownloadService) - .to receive(:execute).with('oid', download_link) + allow(importer).to receive(:lfs_download_object).and_return(lfs_download_object) + + service = double + expect(Projects::LfsPointers::LfsDownloadService).to receive(:new).with(project, lfs_download_object).and_return(service) + expect(service).to receive(:execute) importer.execute end diff --git a/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb b/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb index 5f5c6b803c0..50442552eee 100644 --- a/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb @@ -5,7 +5,15 @@ describe Gitlab::GithubImport::Importer::LfsObjectsImporter do let(:client) { double(:client) } let(:download_link) { "http://www.gitlab.com/lfs_objects/oid" } - let(:github_lfs_object) { ['oid', download_link] } + let(:lfs_attributes) do + { + oid: 'oid', + size: 1, + link: 'http://www.gitlab.com/lfs_objects/oid' + } + end + + let(:lfs_download_object) { LfsDownloadObject.new(lfs_attributes) } describe '#parallel?' do it 'returns true when running in parallel mode' do @@ -48,7 +56,7 @@ describe Gitlab::GithubImport::Importer::LfsObjectsImporter do allow(importer) .to receive(:each_object_to_import) - .and_yield(['oid', download_link]) + .and_yield(lfs_download_object) expect(Gitlab::GithubImport::Importer::LfsObjectImporter) .to receive(:new) @@ -71,7 +79,7 @@ describe Gitlab::GithubImport::Importer::LfsObjectsImporter do allow(importer) .to receive(:each_object_to_import) - .and_yield(github_lfs_object) + .and_yield(lfs_download_object) expect(Gitlab::GithubImport::ImportLfsObjectWorker) .to receive(:perform_async) diff --git a/spec/models/lfs_download_object_spec.rb b/spec/models/lfs_download_object_spec.rb new file mode 100644 index 00000000000..88838b127d2 --- /dev/null +++ b/spec/models/lfs_download_object_spec.rb @@ -0,0 +1,68 @@ +require 'rails_helper' + +describe LfsDownloadObject do + let(:oid) { 'cd293be6cea034bd45a0352775a219ef5dc7825ce55d1f7dae9762d80ce64411' } + let(:link) { 'http://www.example.com' } + let(:size) { 1 } + + subject { described_class.new(oid: oid, size: size, link: link) } + + describe 'validations' do + it { is_expected.to validate_numericality_of(:size).is_greater_than_or_equal_to(0) } + + context 'oid attribute' do + it 'must be 64 characters long' do + aggregate_failures do + expect(described_class.new(oid: 'a' * 63, size: size, link: link)).to be_invalid + expect(described_class.new(oid: 'a' * 65, size: size, link: link)).to be_invalid + expect(described_class.new(oid: 'a' * 64, size: size, link: link)).to be_valid + end + end + + it 'must contain only hexadecimal characters' do + aggregate_failures do + expect(subject).to be_valid + expect(described_class.new(oid: 'g' * 64, size: size, link: link)).to be_invalid + end + end + end + + context 'link attribute' do + it 'only http and https protocols are valid' do + aggregate_failures do + expect(described_class.new(oid: oid, size: size, link: 'http://www.example.com')).to be_valid + expect(described_class.new(oid: oid, size: size, link: 'https://www.example.com')).to be_valid + expect(described_class.new(oid: oid, size: size, link: 'ftp://www.example.com')).to be_invalid + expect(described_class.new(oid: oid, size: size, link: 'ssh://www.example.com')).to be_invalid + expect(described_class.new(oid: oid, size: size, link: 'git://www.example.com')).to be_invalid + end + end + + it 'cannot be empty' do + expect(described_class.new(oid: oid, size: size, link: '')).not_to be_valid + end + + context 'when localhost or local network addresses' do + subject { described_class.new(oid: oid, size: size, link: 'http://192.168.1.1') } + + before do + allow(ApplicationSetting) + .to receive(:current) + .and_return(ApplicationSetting.build_from_defaults(allow_local_requests_from_hooks_and_services: setting)) + end + + context 'are allowed' do + let(:setting) { true } + + it { expect(subject).to be_valid } + end + + context 'are not allowed' do + let(:setting) { false } + + it { expect(subject).to be_invalid } + end + end + end + end +end diff --git a/spec/services/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb index 06f865dc848..c525de6cd25 100644 --- a/spec/services/projects/import_service_spec.rb +++ b/spec/services/projects/import_service_spec.rb @@ -152,8 +152,11 @@ describe Projects::ImportService do it 'downloads lfs objects if lfs_enabled is enabled for project' do allow(project).to receive(:lfs_enabled?).and_return(true) + + service = double expect_any_instance_of(Projects::LfsPointers::LfsImportService).to receive(:execute).and_return(oid_download_links) - expect_any_instance_of(Projects::LfsPointers::LfsDownloadService).to receive(:execute).twice + expect(Projects::LfsPointers::LfsDownloadService).to receive(:new).and_return(service).twice + expect(service).to receive(:execute).twice subject.execute end @@ -211,8 +214,10 @@ describe Projects::ImportService do it 'does not have a custom repository importer downloads lfs objects' do allow(Gitlab::GithubImport::ParallelImporter).to receive(:imports_repository?).and_return(false) + service = double expect_any_instance_of(Projects::LfsPointers::LfsImportService).to receive(:execute).and_return(oid_download_links) - expect_any_instance_of(Projects::LfsPointers::LfsDownloadService).to receive(:execute) + expect(Projects::LfsPointers::LfsDownloadService).to receive(:new).and_return(service).twice + expect(service).to receive(:execute).twice subject.execute end diff --git a/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb index d7a2829d5f8..f222c52199f 100644 --- a/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb +++ b/spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb @@ -37,8 +37,8 @@ describe Projects::LfsPointers::LfsDownloadLinkListService do describe '#execute' do it 'retrieves each download link of every non existent lfs object' do - subject.execute(new_oids).each do |oid, link| - expect(link).to eq "#{import_url}/gitlab-lfs/objects/#{oid}" + subject.execute(new_oids).each do |lfs_download_object| + expect(lfs_download_object.link).to eq "#{import_url}/gitlab-lfs/objects/#{lfs_download_object.oid}" end end @@ -50,8 +50,8 @@ describe Projects::LfsPointers::LfsDownloadLinkListService do it 'adds credentials to the download_link' do result = subject.execute(new_oids) - result.each do |oid, link| - expect(link.starts_with?('http://user:password@')).to be_truthy + result.each do |lfs_download_object| + expect(lfs_download_object.link.starts_with?('http://user:password@')).to be_truthy end end end @@ -60,8 +60,8 @@ describe Projects::LfsPointers::LfsDownloadLinkListService do it 'does not add any credentials' do result = subject.execute(new_oids) - result.each do |oid, link| - expect(link.starts_with?('http://user:password@')).to be_falsey + result.each do |lfs_download_object| + expect(lfs_download_object.link.starts_with?('http://user:password@')).to be_falsey end end end @@ -74,8 +74,8 @@ describe Projects::LfsPointers::LfsDownloadLinkListService do it 'downloads without any credentials' do result = subject.execute(new_oids) - result.each do |oid, link| - expect(link.starts_with?('http://user:password@')).to be_falsey + result.each do |lfs_download_object| + expect(lfs_download_object.link.starts_with?('http://user:password@')).to be_falsey end end end @@ -92,7 +92,7 @@ describe Projects::LfsPointers::LfsDownloadLinkListService do describe '#parse_response_links' do it 'does not add oid entry if href not found' do - expect(Rails.logger).to receive(:error).with("Link for Lfs Object with oid whatever not found or invalid.") + expect(subject).to receive(:log_error).with("Link for Lfs Object with oid whatever not found or invalid.") result = subject.send(:parse_response_links, invalid_object_response) diff --git a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb index 95c9b6e63b8..d952d1d1ffd 100644 --- a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb +++ b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb @@ -2,68 +2,156 @@ require 'spec_helper' describe Projects::LfsPointers::LfsDownloadService do let(:project) { create(:project) } - let(:oid) { '9e548e25631dd9ce6b43afd6359ab76da2819d6a5b474e66118c7819e1d8b3e8' } - let(:download_link) { "http://gitlab.com/#{oid}" } let(:lfs_content) { SecureRandom.random_bytes(10) } + let(:oid) { Digest::SHA256.hexdigest(lfs_content) } + let(:download_link) { "http://gitlab.com/#{oid}" } + let(:size) { lfs_content.size } + let(:lfs_object) { LfsDownloadObject.new(oid: oid, size: size, link: download_link) } + let(:local_request_setting) { false } - subject { described_class.new(project) } + subject { described_class.new(project, lfs_object) } before do + ApplicationSetting.create_from_defaults + + stub_application_setting(allow_local_requests_from_hooks_and_services: local_request_setting) allow(project).to receive(:lfs_enabled?).and_return(true) - WebMock.stub_request(:get, download_link).to_return(body: lfs_content) + end + + shared_examples 'lfs temporal file is removed' do + it do + subject.execute - allow(Gitlab::CurrentSettings).to receive(:allow_local_requests_from_hooks_and_services?).and_return(false) + expect(File.exist?(subject.send(:tmp_filename))).to be false + end + end + + shared_examples 'no lfs object is created' do + it do + expect { subject.execute }.not_to change { LfsObject.count } + end + + it 'returns error result' do + expect(subject.execute[:status]).to eq :error + end + + it 'an error is logged' do + expect(subject).to receive(:log_error) + + subject.execute + end + + it_behaves_like 'lfs temporal file is removed' + end + + shared_examples 'lfs object is created' do + it do + expect(subject).to receive(:download_and_save_file!).and_call_original + + expect { subject.execute }.to change { LfsObject.count }.by(1) + end + + it 'returns success result' do + expect(subject.execute[:status]).to eq :success + end + + it_behaves_like 'lfs temporal file is removed' end describe '#execute' do context 'when file download succeeds' do - it 'a new lfs object is created' do - expect { subject.execute(oid, download_link) }.to change { LfsObject.count }.from(0).to(1) + before do + WebMock.stub_request(:get, download_link).to_return(body: lfs_content) end + it_behaves_like 'lfs object is created' + it 'has the same oid' do - subject.execute(oid, download_link) + subject.execute expect(LfsObject.first.oid).to eq oid end + it 'has the same size' do + subject.execute + + expect(LfsObject.first.size).to eq size + end + it 'stores the content' do - subject.execute(oid, download_link) + subject.execute expect(File.binread(LfsObject.first.file.file.file)).to eq lfs_content end end context 'when file download fails' do - it 'no lfs object is created' do - expect { subject.execute(oid, download_link) }.to change { LfsObject.count } + before do + allow(Gitlab::HTTP).to receive(:get).and_return(code: 500, 'success?' => false) + end + + it_behaves_like 'no lfs object is created' + + it 'raise StandardError exception' do + expect(subject).to receive(:download_and_save_file!).and_raise(StandardError) + + subject.execute + end + end + + context 'when downloaded lfs file has a different size' do + let(:size) { 1 } + + before do + WebMock.stub_request(:get, download_link).to_return(body: lfs_content) + end + + it_behaves_like 'no lfs object is created' + + it 'raise SizeError exception' do + expect(subject).to receive(:download_and_save_file!).and_raise(described_class::SizeError) + + subject.execute + end + end + + context 'when downloaded lfs file has a different oid' do + before do + WebMock.stub_request(:get, download_link).to_return(body: lfs_content) + allow_any_instance_of(Digest::SHA256).to receive(:hexdigest).and_return('foobar') + end + + it_behaves_like 'no lfs object is created' + + it 'raise OidError exception' do + expect(subject).to receive(:download_and_save_file!).and_raise(described_class::OidError) + + subject.execute end end context 'when credentials present' do let(:download_link_with_credentials) { "http://user:password@gitlab.com/#{oid}" } + let(:lfs_object) { LfsDownloadObject.new(oid: oid, size: size, link: download_link_with_credentials) } before do WebMock.stub_request(:get, download_link).with(headers: { 'Authorization' => 'Basic dXNlcjpwYXNzd29yZA==' }).to_return(body: lfs_content) end it 'the request adds authorization headers' do - subject.execute(oid, download_link_with_credentials) + subject end end context 'when localhost requests are allowed' do let(:download_link) { 'http://192.168.2.120' } + let(:local_request_setting) { true } before do - allow(Gitlab::CurrentSettings).to receive(:allow_local_requests_from_hooks_and_services?).and_return(true) + WebMock.stub_request(:get, download_link).to_return(body: lfs_content) end - it 'downloads the file' do - expect(subject).to receive(:download_and_save_file).and_call_original - - expect { subject.execute(oid, download_link) }.to change { LfsObject.count }.by(1) - end + it_behaves_like 'lfs object is created' end context 'when a bad URL is used' do @@ -71,7 +159,9 @@ describe Projects::LfsPointers::LfsDownloadService do with_them do it 'does not download the file' do - expect { subject.execute(oid, download_link) }.not_to change { LfsObject.count } + expect(subject).not_to receive(:download_lfs_file!) + + expect { subject.execute }.not_to change { LfsObject.count } end end end @@ -85,15 +175,11 @@ describe Projects::LfsPointers::LfsDownloadService do WebMock.stub_request(:get, download_link).to_return(status: 301, headers: { 'Location' => redirect_link }) end - it 'does not follow the redirection' do - expect(Rails.logger).to receive(:error).with(/LFS file with oid #{oid} couldn't be downloaded/) - - expect { subject.execute(oid, download_link) }.not_to change { LfsObject.count } - end + it_behaves_like 'no lfs object is created' end end - context 'that is valid' do + context 'that is not blocked' do let(:redirect_link) { "http://example.com/"} before do @@ -101,21 +187,35 @@ describe Projects::LfsPointers::LfsDownloadService do WebMock.stub_request(:get, redirect_link).to_return(body: lfs_content) end - it 'follows the redirection' do - expect { subject.execute(oid, download_link) }.to change { LfsObject.count }.from(0).to(1) - end + it_behaves_like 'lfs object is created' + end + end + + context 'when the lfs object attributes are invalid' do + let(:oid) { 'foobar' } + + before do + expect(lfs_object).to be_invalid + end + + it_behaves_like 'no lfs object is created' + + it 'does not download the file' do + expect(subject).not_to receive(:download_lfs_file!) + + subject.execute end end context 'when an lfs object with the same oid already exists' do before do - create(:lfs_object, oid: 'oid') + create(:lfs_object, oid: oid) end it 'does not download the file' do - expect(subject).not_to receive(:download_and_save_file) + expect(subject).not_to receive(:download_lfs_file!) - subject.execute('oid', download_link) + subject.execute end end end -- cgit v1.2.3 From 7d823035657dd00568a4154dcc11779914058891 Mon Sep 17 00:00:00 2001 From: Brett Walker Date: Mon, 14 Jan 2019 16:57:54 -0600 Subject: Show tooltip for malicious looking links Such as those with IDN homographs or embedded right-to-left (RTLO) characters. Autolinked hrefs should be escaped --- .../security-2769-idn-homograph-attack.yml | 5 ++ lib/banzai/filter/autolink_filter.rb | 13 +++- lib/banzai/filter/external_link_filter.rb | 85 ++++++++++++++++++++-- lib/banzai/pipeline/email_pipeline.rb | 3 +- spec/lib/banzai/filter/autolink_filter_spec.rb | 16 ++++ .../lib/banzai/filter/external_link_filter_spec.rb | 65 +++++++++++++++++ spec/lib/banzai/pipeline/email_pipeline_spec.rb | 14 ++++ spec/lib/banzai/pipeline/full_pipeline_spec.rb | 38 ++++++++++ 8 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 changelogs/unreleased/security-2769-idn-homograph-attack.yml diff --git a/changelogs/unreleased/security-2769-idn-homograph-attack.yml b/changelogs/unreleased/security-2769-idn-homograph-attack.yml new file mode 100644 index 00000000000..a014b522c96 --- /dev/null +++ b/changelogs/unreleased/security-2769-idn-homograph-attack.yml @@ -0,0 +1,5 @@ +--- +title: Make potentially malicious links more visible in the UI and scrub RTLO chars from links +merge_request: 2770 +author: +type: security diff --git a/lib/banzai/filter/autolink_filter.rb b/lib/banzai/filter/autolink_filter.rb index deda4b1872e..f3061bad4ff 100644 --- a/lib/banzai/filter/autolink_filter.rb +++ b/lib/banzai/filter/autolink_filter.rb @@ -8,6 +8,10 @@ module Banzai # # Based on HTML::Pipeline::AutolinkFilter # + # Note that our CommonMark parser, `commonmarker` (using the autolink extension) + # handles standard autolinking, like http/https. We detect additional + # schemes (smb, rdar, etc). + # # Context options: # :autolink - Boolean, skips all processing done by this filter when false # :link_attr - Hash of attributes for the generated links @@ -107,10 +111,13 @@ module Banzai end end - # match has come from node.to_html above, so we know it's encoded - # correctly. + # Since this came from a Text node, make sure the new href is encoded. + # `commonmarker` percent encodes the domains of links it handles, so + # do the same (instead of using `normalized_encode`). + href_safe = Addressable::URI.encode(match).html_safe + html_safe_match = match.html_safe - options = link_options.merge(href: html_safe_match) + options = link_options.merge(href: href_safe) content_tag(:a, html_safe_match, options) + dropped end diff --git a/lib/banzai/filter/external_link_filter.rb b/lib/banzai/filter/external_link_filter.rb index 4f60b6f84c6..61ee3eac216 100644 --- a/lib/banzai/filter/external_link_filter.rb +++ b/lib/banzai/filter/external_link_filter.rb @@ -4,17 +4,29 @@ module Banzai module Filter # HTML Filter to modify the attributes of external links class ExternalLinkFilter < HTML::Pipeline::Filter - SCHEMES = ['http', 'https', nil].freeze + SCHEMES = ['http', 'https', nil].freeze + RTLO = "\u202E".freeze + ENCODED_RTLO = '%E2%80%AE'.freeze def call links.each do |node| - uri = uri(node['href'].to_s) - - node.set_attribute('href', uri.to_s) if uri + # URI.parse does stricter checking on the url than Addressable, + # such as on `mailto:` links. Since we've been using it, do an + # initial parse for validity and then use Addressable + # for IDN support, etc + uri = uri_strict(node['href'].to_s) + if uri + node.set_attribute('href', uri.to_s) + addressable_uri = addressable_uri(node['href']) + else + addressable_uri = nil + end - if SCHEMES.include?(uri&.scheme) && !internal_url?(uri) - node.set_attribute('rel', 'nofollow noreferrer noopener') - node.set_attribute('target', '_blank') + unless internal_url?(addressable_uri) + punycode_autolink_node!(addressable_uri, node) + sanitize_link_text!(node) + add_malicious_tooltip!(addressable_uri, node) + add_nofollow!(addressable_uri, node) end end @@ -23,12 +35,18 @@ module Banzai private - def uri(href) + def uri_strict(href) URI.parse(href) rescue URI::Error nil end + def addressable_uri(href) + Addressable::URI.parse(href) + rescue Addressable::URI::InvalidURIError + nil + end + def links query = 'descendant-or-self::a[@href and not(@href = "")]' doc.xpath(query) @@ -45,6 +63,57 @@ module Banzai def internal_url @internal_url ||= URI.parse(Gitlab.config.gitlab.url) end + + # Only replace an autolink with an IDN with it's punycode + # version if we need emailable links. Otherwise let it + # be shown normally and the tooltips will show the + # punycode version. + def punycode_autolink_node!(uri, node) + return unless uri + return unless context[:emailable_links] + + unencoded_uri_str = Addressable::URI.unencode(node['href']) + + if unencoded_uri_str == node.content && idn?(uri) + node.content = uri.normalize + end + end + + # escape any right-to-left (RTLO) characters in link text + def sanitize_link_text!(node) + node.inner_html = node.inner_html.gsub(RTLO, ENCODED_RTLO) + end + + # If the domain is an international domain name (IDN), + # let's expose with a tooltip in case it's intended + # to be malicious. This is particularly useful for links + # where the link text is not the same as the actual link. + # We will continue to show the unicode version of the domain + # in autolinked link text, which could contain emojis, etc. + # + # Also show the tooltip if the url contains the RTLO character, + # as this is an indicator of a malicious link + def add_malicious_tooltip!(uri, node) + if idn?(uri) || has_encoded_rtlo?(uri) + node.add_class('has-tooltip') + node.set_attribute('title', uri.normalize) + end + end + + def add_nofollow!(uri, node) + if SCHEMES.include?(uri&.scheme) + node.set_attribute('rel', 'nofollow noreferrer noopener') + node.set_attribute('target', '_blank') + end + end + + def idn?(uri) + uri&.normalized_host&.start_with?('xn--') + end + + def has_encoded_rtlo?(uri) + uri&.to_s&.include?(ENCODED_RTLO) + end end end end diff --git a/lib/banzai/pipeline/email_pipeline.rb b/lib/banzai/pipeline/email_pipeline.rb index 2c08581ce0d..fc51063c06c 100644 --- a/lib/banzai/pipeline/email_pipeline.rb +++ b/lib/banzai/pipeline/email_pipeline.rb @@ -11,7 +11,8 @@ module Banzai def self.transform_context(context) super(context).merge( - only_path: false + only_path: false, + emailable_links: true ) end end diff --git a/spec/lib/banzai/filter/autolink_filter_spec.rb b/spec/lib/banzai/filter/autolink_filter_spec.rb index 7a457403b51..6217381c491 100644 --- a/spec/lib/banzai/filter/autolink_filter_spec.rb +++ b/spec/lib/banzai/filter/autolink_filter_spec.rb @@ -188,6 +188,22 @@ describe Banzai::Filter::AutolinkFilter do expect(doc.at_css('a')['class']).to eq 'custom' end + it 'escapes RTLO and other characters' do + # rendered text looks like "http://example.com/evilexe.mp3" + evil_link = "#{link}evil\u202E3pm.exe" + doc = filter("#{evil_link}") + + expect(doc.at_css('a')['href']).to eq "http://about.gitlab.com/evil%E2%80%AE3pm.exe" + end + + it 'encodes international domains' do + link = "http://one😄two.com" + expected = "http://one%F0%9F%98%84two.com" + doc = filter(link) + + expect(doc.at_css('a')['href']).to eq expected + end + described_class::IGNORE_PARENTS.each do |elem| it "ignores valid links contained inside '#{elem}' element" do exp = act = "<#{elem}>See #{link}" diff --git a/spec/lib/banzai/filter/external_link_filter_spec.rb b/spec/lib/banzai/filter/external_link_filter_spec.rb index e6dae8d5382..2acbe05f082 100644 --- a/spec/lib/banzai/filter/external_link_filter_spec.rb +++ b/spec/lib/banzai/filter/external_link_filter_spec.rb @@ -62,6 +62,13 @@ describe Banzai::Filter::ExternalLinkFilter do expect(doc.to_html).to eq(expected) end + + it 'adds rel and target to improperly formatted autolinks' do + doc = filter %q(

mailto://jblogs@example.com

) + expected = %q(

mailto://jblogs@example.com

) + + expect(doc.to_html).to eq(expected) + end end context 'for links with a username' do @@ -112,4 +119,62 @@ describe Banzai::Filter::ExternalLinkFilter do it_behaves_like 'an external link with rel attribute' end + + context 'links with RTLO character' do + # In rendered text this looks like "http://example.com/evilexe.mp3" + let(:doc) { filter %Q(http://example.com/evil\u202E3pm.exe) } + + it_behaves_like 'an external link with rel attribute' + + it 'escapes RTLO in link text' do + expected = %q(http://example.com/evil%E2%80%AE3pm.exe) + + expect(doc.to_html).to include(expected) + end + + it 'does not mangle the link text' do + doc = filter %Q(Oneand\u202Eexe.mp3) + + expect(doc.to_html).to include('Oneand%E2%80%AEexe.mp3') + end + end + + context 'for generated autolinks' do + context 'with an IDN character' do + let(:doc) { filter(%q(http://exa😄mple.com)) } + let(:doc_email) { filter(%q(http://exa😄mple.com), emailable_links: true) } + + it_behaves_like 'an external link with rel attribute' + + it 'does not change the link text' do + expect(doc.to_html).to include('http://exa😄mple.com') + end + + it 'uses punycode for emails' do + expect(doc_email.to_html).to include('http://xn--example-6p25f.com/') + end + end + end + + context 'for links that look malicious' do + context 'with an IDN character' do + let(:doc) { filter %q(http://exa😄mple.com) } + + it 'adds a toolip with punycode' do + expect(doc.to_html).to include('http://exa😄mple.com') + expect(doc.to_html).to include('class="has-tooltip"') + expect(doc.to_html).to include('title="http://xn--example-6p25f.com/"') + end + end + + context 'with RTLO character' do + let(:doc) { filter %q(Evil Test) } + + it 'adds a toolip with punycode' do + expect(doc.to_html).to include('Evil Test') + expect(doc.to_html).to include('class="has-tooltip"') + expect(doc.to_html).to include('title="http://example.com/evil%E2%80%AE3pm.exe"') + end + end + end end diff --git a/spec/lib/banzai/pipeline/email_pipeline_spec.rb b/spec/lib/banzai/pipeline/email_pipeline_spec.rb index 6a11ca2f9d5..b99161109eb 100644 --- a/spec/lib/banzai/pipeline/email_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/email_pipeline_spec.rb @@ -10,5 +10,19 @@ describe Banzai::Pipeline::EmailPipeline do expect(described_class.filters).not_to be_empty expect(described_class.filters).not_to include(Banzai::Filter::ImageLazyLoadFilter) end + + it 'shows punycode for autolinks' do + examples = %W[ + http://one😄two.com + http://\u0261itlab.com + ] + + examples.each do |markdown| + result = described_class.call(markdown, project: nil)[:output] + link = result.css('a').first + + expect(link.content).to include('http://xn--') + end + end end end diff --git a/spec/lib/banzai/pipeline/full_pipeline_spec.rb b/spec/lib/banzai/pipeline/full_pipeline_spec.rb index 3634655c6a5..162856be4c5 100644 --- a/spec/lib/banzai/pipeline/full_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/full_pipeline_spec.rb @@ -57,4 +57,42 @@ describe Banzai::Pipeline::FullPipeline do expect(html.lines.map(&:strip).join("\n")).to eq filtered_footnote end end + + describe 'links are detected as malicious' do + it 'has tooltips for malicious links' do + examples = %W[ + http://example.com/evil\u202E3pm.exe + [evilexe.mp3](http://example.com/evil\u202E3pm.exe) + rdar://localhost.com/\u202E3pm.exe + http://one😄two.com + [Evil-Test](http://one😄two.com) + http://\u0261itlab.com + [Evil-GitLab-link](http://\u0261itlab.com) + ![Evil-GitLab-link](http://\u0261itlab.com.png) + ] + + examples.each do |markdown| + result = described_class.call(markdown, project: nil)[:output] + link = result.css('a').first + + expect(link[:class]).to include('has-tooltip') + end + end + + it 'has no tooltips for safe links' do + examples = %w[ + http://example.com + [Safe-Test](http://example.com) + https://commons.wikimedia.org/wiki/File:اسكرام_2_-_تمنراست.jpg + [Wikipedia-link](https://commons.wikimedia.org/wiki/File:اسكرام_2_-_تمنراست.jpg) + ] + + examples.each do |markdown| + result = described_class.call(markdown, project: nil)[:output] + link = result.css('a').first + + expect(link[:class]).to be_nil + end + end + end end -- cgit v1.2.3 From ac2d8af55c7f33ecb6a5d0bad008ad5f8c4706b9 Mon Sep 17 00:00:00 2001 From: Brett Walker Date: Mon, 21 Jan 2019 13:52:05 -0600 Subject: Bump the CACHE_COMMONMARK_VERSION Since we needed to bump the version to 13 in the backports, and we know that an MR on master also bumped it to 13, bump to 14 to ensure that when a customer upgrades to the most recent release, the markdown gets recalcuated as necessary. --- app/models/concerns/cache_markdown_field.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 73a27326f6c..002f3e17891 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -15,7 +15,7 @@ module CacheMarkdownField # Increment this number every time the renderer changes its output CACHE_REDCARPET_VERSION = 3 CACHE_COMMONMARK_VERSION_START = 10 - CACHE_COMMONMARK_VERSION = 13 + CACHE_COMMONMARK_VERSION = 14 # changes to these attributes cause the cache to be invalidates INVALIDATED_BY = %w[author project].freeze -- cgit v1.2.3 From db054bc1668e2c9a1d745d00dbe3ba4dd74f6929 Mon Sep 17 00:00:00 2001 From: syasonik Date: Wed, 16 Jan 2019 14:05:19 -0600 Subject: Support flat response for envs index route To support environment folders in the UI on the Environments List page, the environments index route previously returned one environment per folder, excluding those other than the latest deploy. However, the environtments dropdown on the metrics dashboard requires that any environment be selectable. To accommodate both use cases, support an optional 'nested' parameter in the index route to return either a flat, complete response or a nested response based on the use case in question. The new default response structure is the flat response. --- .../environments/mixins/environments_mixin.js | 2 +- .../environments/services/environments_service.js | 4 +- .../environments/stores/environments_store.js | 3 +- .../monitoring/components/dashboard.vue | 8 +-- .../monitoring/stores/monitoring_store.js | 4 +- .../projects/environments_controller.rb | 21 +++--- changelogs/unreleased/fix-49388.yml | 5 ++ .../projects/environments_controller_spec.rb | 43 ++++++++++-- spec/javascripts/monitoring/mock_data.js | 80 +++++++++------------- 9 files changed, 97 insertions(+), 73 deletions(-) create mode 100644 changelogs/unreleased/fix-49388.yml diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 96dc1f07cb9..e81a1525df0 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -143,7 +143,7 @@ export default { */ created() { this.service = new EnvironmentsService(this.endpoint); - this.requestData = { page: this.page, scope: this.scope }; + this.requestData = { page: this.page, scope: this.scope, nested: true }; this.poll = new Poll({ resource: this.service, diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js index 4e07ccba91a..cb4ff6856db 100644 --- a/app/assets/javascripts/environments/services/environments_service.js +++ b/app/assets/javascripts/environments/services/environments_service.js @@ -7,8 +7,8 @@ export default class EnvironmentsService { } fetchEnvironments(options = {}) { - const { scope, page } = options; - return axios.get(this.environmentsEndpoint, { params: { scope, page } }); + const { scope, page, nested } = options; + return axios.get(this.environmentsEndpoint, { params: { scope, page, nested } }); } // eslint-disable-next-line class-methods-use-this diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 5808a2d4afa..ac9a31c202c 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -20,7 +20,8 @@ export default class EnvironmentsStore { * * Stores the received environments. * - * In the main environments endpoint, each environment has the following schema + * In the main environments endpoint (with { nested: true } in params), each folder + * has the following schema: * { name: String, size: Number, latest: Object } * In the endpoint to retrieve environments from each folder, the environment does * not have the `latest` key and the data is all in the root level. diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index cea5c1a56ca..973fc8e10c9 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -196,13 +196,13 @@ export default { class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up" > diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 8692c873a41..96ecc5ab8a8 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -66,9 +66,7 @@ export default class MonitoringStore { } storeEnvironmentsData(environmentsData = []) { - this.environmentsData = environmentsData.filter( - environment => !!environment.latest.last_deployment, - ); + this.environmentsData = environmentsData.filter(environment => !!environment.last_deployment); } getMetricsCount() { diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index a63eea0ca0e..1a1b024d766 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -15,6 +15,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController push_frontend_feature_flag(:area_chart, project) end + # Returns all environments or all folders based on the :nested param def index @environments = project.environments .with_state(params[:scope] || :available) @@ -25,11 +26,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController Gitlab::PollingInterval.set_header(response, interval: 3_000) render json: { - environments: EnvironmentSerializer - .new(project: @project, current_user: @current_user) - .with_pagination(request, response) - .within_folders - .represent(@environments), + environments: serialize_environments(request, response, params[:nested]), available_count: project.environments.available.count, stopped_count: project.environments.stopped.count } @@ -37,6 +34,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController end end + # Returns all environments for a given folder # rubocop: disable CodeReuse/ActiveRecord def folder folder_environments = project.environments.where(environment_type: params[:id]) @@ -48,10 +46,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController format.html format.json do render json: { - environments: EnvironmentSerializer - .new(project: @project, current_user: @current_user) - .with_pagination(request, response) - .represent(@environments), + environments: serialize_environments(request, response), available_count: folder_environments.available.count, stopped_count: folder_environments.stopped.count } @@ -186,6 +181,14 @@ class Projects::EnvironmentsController < Projects::ApplicationController @environment ||= project.environments.find(params[:id]) end + def serialize_environments(request, response, nested = false) + serializer = EnvironmentSerializer + .new(project: @project, current_user: @current_user) + .with_pagination(request, response) + serializer = serializer.within_folders if nested + serializer.represent(@environments) + end + def authorize_stop_environment! access_denied! unless can?(current_user, :stop_environment, environment) end diff --git a/changelogs/unreleased/fix-49388.yml b/changelogs/unreleased/fix-49388.yml new file mode 100644 index 00000000000..f8b5e3e1943 --- /dev/null +++ b/changelogs/unreleased/fix-49388.yml @@ -0,0 +1,5 @@ +--- +title: Update metrics environment dropdown to show complete option set +merge_request: 24441 +author: +type: fixed diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 94fb85f217c..a4d494a820f 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -47,9 +47,43 @@ describe Projects::EnvironmentsController do let(:environments) { json_response['environments'] } + context 'with default parameters' do + before do + get :index, params: environment_params(format: :json) + end + + it 'responds with a flat payload describing available environments' do + expect(environments.count).to eq 3 + expect(environments.first['name']).to eq 'production' + expect(environments.second['name']).to eq 'staging/review-1' + expect(environments.third['name']).to eq 'staging/review-2' + expect(json_response['available_count']).to eq 3 + expect(json_response['stopped_count']).to eq 1 + end + + it 'sets the polling interval header' do + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['Poll-Interval']).to eq("3000") + end + end + + context 'when a folder-based nested structure is requested' do + before do + get :index, params: environment_params(format: :json, nested: true) + end + + it 'responds with a payload containing the latest environment for each folder' do + expect(environments.count).to eq 2 + expect(environments.first['name']).to eq 'production' + expect(environments.second['name']).to eq 'staging' + expect(environments.second['size']).to eq 2 + expect(environments.second['latest']['name']).to eq 'staging/review-2' + end + end + context 'when requesting available environments scope' do before do - get :index, params: environment_params(format: :json, scope: :available) + get :index, params: environment_params(format: :json, nested: true, scope: :available) end it 'responds with a payload describing available environments' do @@ -64,16 +98,11 @@ describe Projects::EnvironmentsController do expect(json_response['available_count']).to eq 3 expect(json_response['stopped_count']).to eq 1 end - - it 'sets the polling interval header' do - expect(response).to have_gitlab_http_status(:ok) - expect(response.headers['Poll-Interval']).to eq("3000") - end end context 'when requesting stopped environments scope' do before do - get :index, params: environment_params(format: :json, scope: :stopped) + get :index, params: environment_params(format: :json, nested: true, scope: :stopped) end it 'responds with a payload describing stopped environments' do diff --git a/spec/javascripts/monitoring/mock_data.js b/spec/javascripts/monitoring/mock_data.js index 18ad9843d22..b4e2cd75d47 100644 --- a/spec/javascripts/monitoring/mock_data.js +++ b/spec/javascripts/monitoring/mock_data.js @@ -6597,58 +6597,46 @@ export function convertDatesMultipleSeries(multipleSeries) { export const environmentData = [ { + id: 34, name: 'production', - size: 1, - latest: { - id: 34, - name: 'production', - state: 'available', - external_url: 'http://root-autodevops-deploy.my-fake-domain.com', - environment_type: null, - stop_action: false, - metrics_path: '/root/hello-prometheus/environments/34/metrics', - environment_path: '/root/hello-prometheus/environments/34', - stop_path: '/root/hello-prometheus/environments/34/stop', - terminal_path: '/root/hello-prometheus/environments/34/terminal', - folder_path: '/root/hello-prometheus/environments/folders/production', - created_at: '2018-06-29T16:53:38.301Z', - updated_at: '2018-06-29T16:57:09.825Z', - last_deployment: { - id: 127, - }, + state: 'available', + external_url: 'http://root-autodevops-deploy.my-fake-domain.com', + environment_type: null, + stop_action: false, + metrics_path: '/root/hello-prometheus/environments/34/metrics', + environment_path: '/root/hello-prometheus/environments/34', + stop_path: '/root/hello-prometheus/environments/34/stop', + terminal_path: '/root/hello-prometheus/environments/34/terminal', + folder_path: '/root/hello-prometheus/environments/folders/production', + created_at: '2018-06-29T16:53:38.301Z', + updated_at: '2018-06-29T16:57:09.825Z', + last_deployment: { + id: 127, }, }, { - name: 'review', - size: 1, - latest: { - id: 35, - name: 'review/noop-branch', - state: 'available', - external_url: 'http://root-autodevops-deploy-review-noop-branc-die93w.my-fake-domain.com', - environment_type: 'review', - stop_action: true, - metrics_path: '/root/hello-prometheus/environments/35/metrics', - environment_path: '/root/hello-prometheus/environments/35', - stop_path: '/root/hello-prometheus/environments/35/stop', - terminal_path: '/root/hello-prometheus/environments/35/terminal', - folder_path: '/root/hello-prometheus/environments/folders/review', - created_at: '2018-07-03T18:39:41.702Z', - updated_at: '2018-07-03T18:44:54.010Z', - last_deployment: { - id: 128, - }, + id: 35, + name: 'review/noop-branch', + state: 'available', + external_url: 'http://root-autodevops-deploy-review-noop-branc-die93w.my-fake-domain.com', + environment_type: 'review', + stop_action: true, + metrics_path: '/root/hello-prometheus/environments/35/metrics', + environment_path: '/root/hello-prometheus/environments/35', + stop_path: '/root/hello-prometheus/environments/35/stop', + terminal_path: '/root/hello-prometheus/environments/35/terminal', + folder_path: '/root/hello-prometheus/environments/folders/review', + created_at: '2018-07-03T18:39:41.702Z', + updated_at: '2018-07-03T18:44:54.010Z', + last_deployment: { + id: 128, }, }, { - name: 'no-deployment', - size: 1, - latest: { - id: 36, - name: 'no-deployment/noop-branch', - state: 'available', - created_at: '2018-07-04T18:39:41.702Z', - updated_at: '2018-07-04T18:44:54.010Z', - }, + id: 36, + name: 'no-deployment/noop-branch', + state: 'available', + created_at: '2018-07-04T18:39:41.702Z', + updated_at: '2018-07-04T18:44:54.010Z', }, ]; -- cgit v1.2.3 From 0ebe4191630ed86eea20465327a8caa1c737a4bc Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Tue, 15 Jan 2019 19:51:37 +0530 Subject: Add `sanitize_name` helper to sanitize URLs in user full name --- app/helpers/emails_helper.rb | 8 ++++++++ spec/helpers/emails_helper_spec.rb | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index fa5d3ae474a..dedc58f482b 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -36,6 +36,14 @@ module EmailsHelper nil end + def sanitize_name(name) + if name =~ URI::DEFAULT_PARSER.regexp[:URI_REF] + name.tr('.', '_') + else + name + end + end + def password_reset_token_valid_time valid_hours = Devise.reset_password_within / 60 / 60 if valid_hours >= 24 diff --git a/spec/helpers/emails_helper_spec.rb b/spec/helpers/emails_helper_spec.rb index 3820cf5cb9d..23d7e41803e 100644 --- a/spec/helpers/emails_helper_spec.rb +++ b/spec/helpers/emails_helper_spec.rb @@ -1,6 +1,20 @@ require 'spec_helper' describe EmailsHelper do + describe 'sanitize_name' do + context 'when name contains a valid URL string' do + it 'returns name with `.` replaced with `_` to prevent mail clients from auto-linking URLs' do + expect(sanitize_name('https://about.gitlab.com')).to eq('https://about_gitlab_com') + expect(sanitize_name('www.gitlab.com')).to eq('www_gitlab_com') + expect(sanitize_name('//about.gitlab.com/handbook/security/#best-practices')).to eq('//about_gitlab_com/handbook/security/#best-practices') + end + + it 'returns name as it is when it does not contain a URL' do + expect(sanitize_name('Foo Bar')).to eq('Foo Bar') + end + end + end + describe 'password_reset_token_valid_time' do def validate_time_string(time_limit, expected_string) Devise.reset_password_within = time_limit -- cgit v1.2.3 From 2ec7bc0798ce0ef0f55c429a2819b29588043548 Mon Sep 17 00:00:00 2001 From: Heinrich Lee Yu Date: Wed, 16 Jan 2019 02:53:24 +0800 Subject: Prevent comments by email when issue is locked This changes the permission check so it uses the policy on Noteable instead of Project. This prevents bypassing of rules defined in Noteable for locked discussions and confidential issues. Also rechecks permissions when reply_to_discussion_id is provided since the discussion_id may be from a different noteable. --- app/policies/issue_policy.rb | 1 + app/policies/personal_snippet_policy.rb | 5 ++- app/policies/project_snippet_policy.rb | 2 ++ app/services/notes/build_service.rb | 15 ++------- ...ty-2779-fix-email-comment-permissions-check.yml | 5 +++ lib/gitlab/email/handler/reply_processing.rb | 2 +- .../email/handler/create_note_handler_spec.rb | 37 ++++++++++++++++++++++ spec/models/sent_notification_spec.rb | 2 +- spec/policies/personal_snippet_policy_spec.rb | 29 ++++++++++------- spec/policies/project_snippet_policy_spec.rb | 20 ++++++------ spec/services/notes/build_service_spec.rb | 9 ++++++ spec/services/notes/create_service_spec.rb | 4 +++ 12 files changed, 94 insertions(+), 37 deletions(-) create mode 100644 changelogs/unreleased/security-2779-fix-email-comment-permissions-check.yml diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index a0706eaa46c..dd8c5d49cf4 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -18,6 +18,7 @@ class IssuePolicy < IssuablePolicy prevent :read_issue_iid prevent :update_issue prevent :admin_issue + prevent :create_note end rule { locked }.policy do diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index 777f933cdcd..16e67a06d56 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -28,5 +28,8 @@ class PersonalSnippetPolicy < BasePolicy rule { anonymous }.prevent :comment_personal_snippet - rule { can?(:comment_personal_snippet) }.enable :award_emoji + rule { can?(:comment_personal_snippet) }.policy do + enable :create_note + enable :award_emoji + end end diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb index 7dafa33bb99..e5e005cee6d 100644 --- a/app/policies/project_snippet_policy.rb +++ b/app/policies/project_snippet_policy.rb @@ -43,4 +43,6 @@ class ProjectSnippetPolicy < BasePolicy enable :update_project_snippet enable :admin_project_snippet end + + rule { ~can?(:read_project_snippet) }.prevent :create_note end diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb index 7b92fe6fe14..bae98ede561 100644 --- a/app/services/notes/build_service.rb +++ b/app/services/notes/build_service.rb @@ -9,7 +9,7 @@ module Notes if in_reply_to_discussion_id.present? discussion = find_discussion(in_reply_to_discussion_id) - unless discussion + unless discussion && can?(current_user, :create_note, discussion.noteable) note = Note.new note.errors.add(:base, 'Discussion to reply to cannot be found') return note @@ -34,19 +34,8 @@ module Notes if project project.notes.find_discussion(discussion_id) else - discussion = Note.find_discussion(discussion_id) - noteable = discussion.noteable - - return nil unless noteable_without_project?(noteable) - - discussion + Note.find_discussion(discussion_id) end end - - def noteable_without_project?(noteable) - return true if noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable) - - false - end end end diff --git a/changelogs/unreleased/security-2779-fix-email-comment-permissions-check.yml b/changelogs/unreleased/security-2779-fix-email-comment-permissions-check.yml new file mode 100644 index 00000000000..2f76064d8a4 --- /dev/null +++ b/changelogs/unreleased/security-2779-fix-email-comment-permissions-check.yml @@ -0,0 +1,5 @@ +--- +title: Prevent unauthorized replies when discussion is locked or confidential +merge_request: +author: +type: security diff --git a/lib/gitlab/email/handler/reply_processing.rb b/lib/gitlab/email/handler/reply_processing.rb index ba9730d2685..d8f4be8ada1 100644 --- a/lib/gitlab/email/handler/reply_processing.rb +++ b/lib/gitlab/email/handler/reply_processing.rb @@ -56,7 +56,7 @@ module Gitlab raise ProjectNotFound unless author.can?(:read_project, project) end - raise UserNotAuthorizedError unless author.can?(permission, project || noteable) + raise UserNotAuthorizedError unless author.can?(permission, try(:noteable) || project) end def verify_record!(record:, invalid_exception:, record_name:) diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb index b1f48c15c21..e5420ea6bea 100644 --- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb @@ -118,6 +118,43 @@ describe Gitlab::Email::Handler::CreateNoteHandler do end end + shared_examples "checks permissions on noteable" do + context "when user has access" do + before do + project.add_reporter(user) + end + + it "creates a comment" do + expect { receiver.execute }.to change { noteable.notes.count }.by(1) + end + end + + context "when user does not have access" do + it "raises UserNotAuthorizedError" do + expect { receiver.execute }.to raise_error(Gitlab::Email::UserNotAuthorizedError) + end + end + end + + context "when discussion is locked" do + before do + noteable.update_attribute(:discussion_locked, true) + end + + it_behaves_like "checks permissions on noteable" + end + + context "when issue is confidential" do + let(:issue) { create(:issue, project: project) } + let(:note) { create(:note, noteable: issue, project: project) } + + before do + issue.update_attribute(:confidential, true) + end + + it_behaves_like "checks permissions on noteable" + end + context "when everything is fine" do before do setup_attachment diff --git a/spec/models/sent_notification_spec.rb b/spec/models/sent_notification_spec.rb index 5ec04b99957..677613b7980 100644 --- a/spec/models/sent_notification_spec.rb +++ b/spec/models/sent_notification_spec.rb @@ -48,7 +48,7 @@ describe SentNotification do let(:note) { create(:diff_note_on_merge_request) } it 'creates a new SentNotification' do - expect { described_class.record_note(note, user.id) }.to change { described_class.count }.by(1) + expect { described_class.record_note(note, note.author.id) }.to change { described_class.count }.by(1) end end diff --git a/spec/policies/personal_snippet_policy_spec.rb b/spec/policies/personal_snippet_policy_spec.rb index 3809692b373..d7036cc4171 100644 --- a/spec/policies/personal_snippet_policy_spec.rb +++ b/spec/policies/personal_snippet_policy_spec.rb @@ -14,6 +14,13 @@ describe PersonalSnippetPolicy do ] end + let(:comment_permissions) do + [ + :comment_personal_snippet, + :create_note + ] + end + def permissions(user) described_class.new(user, snippet) end @@ -26,7 +33,7 @@ describe PersonalSnippetPolicy do it do is_expected.to be_allowed(:read_personal_snippet) - is_expected.to be_disallowed(:comment_personal_snippet) + is_expected.to be_disallowed(*comment_permissions) is_expected.to be_disallowed(:award_emoji) is_expected.to be_disallowed(*author_permissions) end @@ -37,7 +44,7 @@ describe PersonalSnippetPolicy do it do is_expected.to be_allowed(:read_personal_snippet) - is_expected.to be_allowed(:comment_personal_snippet) + is_expected.to be_allowed(*comment_permissions) is_expected.to be_allowed(:award_emoji) is_expected.to be_disallowed(*author_permissions) end @@ -48,7 +55,7 @@ describe PersonalSnippetPolicy do it do is_expected.to be_allowed(:read_personal_snippet) - is_expected.to be_allowed(:comment_personal_snippet) + is_expected.to be_allowed(*comment_permissions) is_expected.to be_allowed(:award_emoji) is_expected.to be_allowed(*author_permissions) end @@ -63,7 +70,7 @@ describe PersonalSnippetPolicy do it do is_expected.to be_disallowed(:read_personal_snippet) - is_expected.to be_disallowed(:comment_personal_snippet) + is_expected.to be_disallowed(*comment_permissions) is_expected.to be_disallowed(:award_emoji) is_expected.to be_disallowed(*author_permissions) end @@ -74,7 +81,7 @@ describe PersonalSnippetPolicy do it do is_expected.to be_allowed(:read_personal_snippet) - is_expected.to be_allowed(:comment_personal_snippet) + is_expected.to be_allowed(*comment_permissions) is_expected.to be_allowed(:award_emoji) is_expected.to be_disallowed(*author_permissions) end @@ -85,7 +92,7 @@ describe PersonalSnippetPolicy do it do is_expected.to be_disallowed(:read_personal_snippet) - is_expected.to be_disallowed(:comment_personal_snippet) + is_expected.to be_disallowed(*comment_permissions) is_expected.to be_disallowed(:award_emoji) is_expected.to be_disallowed(*author_permissions) end @@ -96,7 +103,7 @@ describe PersonalSnippetPolicy do it do is_expected.to be_allowed(:read_personal_snippet) - is_expected.to be_allowed(:comment_personal_snippet) + is_expected.to be_allowed(*comment_permissions) is_expected.to be_allowed(:award_emoji) is_expected.to be_allowed(*author_permissions) end @@ -111,7 +118,7 @@ describe PersonalSnippetPolicy do it do is_expected.to be_disallowed(:read_personal_snippet) - is_expected.to be_disallowed(:comment_personal_snippet) + is_expected.to be_disallowed(*comment_permissions) is_expected.to be_disallowed(:award_emoji) is_expected.to be_disallowed(*author_permissions) end @@ -122,7 +129,7 @@ describe PersonalSnippetPolicy do it do is_expected.to be_disallowed(:read_personal_snippet) - is_expected.to be_disallowed(:comment_personal_snippet) + is_expected.to be_disallowed(*comment_permissions) is_expected.to be_disallowed(:award_emoji) is_expected.to be_disallowed(*author_permissions) end @@ -133,7 +140,7 @@ describe PersonalSnippetPolicy do it do is_expected.to be_disallowed(:read_personal_snippet) - is_expected.to be_disallowed(:comment_personal_snippet) + is_expected.to be_disallowed(*comment_permissions) is_expected.to be_disallowed(:award_emoji) is_expected.to be_disallowed(*author_permissions) end @@ -144,7 +151,7 @@ describe PersonalSnippetPolicy do it do is_expected.to be_allowed(:read_personal_snippet) - is_expected.to be_allowed(:comment_personal_snippet) + is_expected.to be_allowed(*comment_permissions) is_expected.to be_allowed(:award_emoji) is_expected.to be_allowed(*author_permissions) end diff --git a/spec/policies/project_snippet_policy_spec.rb b/spec/policies/project_snippet_policy_spec.rb index 4d32e06b553..d6329e84579 100644 --- a/spec/policies/project_snippet_policy_spec.rb +++ b/spec/policies/project_snippet_policy_spec.rb @@ -41,7 +41,7 @@ describe ProjectSnippetPolicy do subject { abilities(regular_user, :public) } it do - expect_allowed(:read_project_snippet) + expect_allowed(:read_project_snippet, :create_note) expect_disallowed(*author_permissions) end end @@ -50,7 +50,7 @@ describe ProjectSnippetPolicy do subject { abilities(external_user, :public) } it do - expect_allowed(:read_project_snippet) + expect_allowed(:read_project_snippet, :create_note) expect_disallowed(*author_permissions) end end @@ -70,7 +70,7 @@ describe ProjectSnippetPolicy do subject { abilities(regular_user, :internal) } it do - expect_allowed(:read_project_snippet) + expect_allowed(:read_project_snippet, :create_note) expect_disallowed(*author_permissions) end end @@ -79,7 +79,7 @@ describe ProjectSnippetPolicy do subject { abilities(external_user, :internal) } it do - expect_disallowed(:read_project_snippet) + expect_disallowed(:read_project_snippet, :create_note) expect_disallowed(*author_permissions) end end @@ -92,7 +92,7 @@ describe ProjectSnippetPolicy do end it do - expect_allowed(:read_project_snippet) + expect_allowed(:read_project_snippet, :create_note) expect_disallowed(*author_permissions) end end @@ -112,7 +112,7 @@ describe ProjectSnippetPolicy do subject { abilities(regular_user, :private) } it do - expect_disallowed(:read_project_snippet) + expect_disallowed(:read_project_snippet, :create_note) expect_disallowed(*author_permissions) end end @@ -123,7 +123,7 @@ describe ProjectSnippetPolicy do subject { described_class.new(regular_user, snippet) } it do - expect_allowed(:read_project_snippet) + expect_allowed(:read_project_snippet, :create_note) expect_allowed(*author_permissions) end end @@ -136,7 +136,7 @@ describe ProjectSnippetPolicy do end it do - expect_allowed(:read_project_snippet) + expect_allowed(:read_project_snippet, :create_note) expect_disallowed(*author_permissions) end end @@ -149,7 +149,7 @@ describe ProjectSnippetPolicy do end it do - expect_allowed(:read_project_snippet) + expect_allowed(:read_project_snippet, :create_note) expect_disallowed(*author_permissions) end end @@ -158,7 +158,7 @@ describe ProjectSnippetPolicy do subject { abilities(create(:admin), :private) } it do - expect_allowed(:read_project_snippet) + expect_allowed(:read_project_snippet, :create_note) expect_allowed(*author_permissions) end end diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb index ff85c261cd4..9aaccb4bffe 100644 --- a/spec/services/notes/build_service_spec.rb +++ b/spec/services/notes/build_service_spec.rb @@ -45,6 +45,15 @@ describe Notes::BuildService do end end + context 'when user has no access to discussion' do + it 'sets an error' do + another_user = create(:user) + new_note = described_class.new(project, another_user, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute + + expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found') + end + end + context 'personal snippet note' do def reply(note, user = nil) user ||= create(:user) diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index 80b015d4cd0..1b9ba42cfd6 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -127,6 +127,10 @@ describe Notes::CreateService do create(:diff_note_on_merge_request, noteable: merge_request, project: project_with_repo) end + before do + project_with_repo.add_maintainer(user) + end + context 'when eligible to have a note diff file' do let(:new_opts) do opts.merge(in_reply_to_discussion_id: nil, -- cgit v1.2.3 From 3c30c341a090b592a3ea5eddb95bd8ad7e7038ce Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Tue, 15 Jan 2019 19:52:26 +0530 Subject: Use `sanitize_name` to sanitize URL in user full name --- .../devise/mailer/_confirmation_instructions_secondary.html.haml | 2 +- app/views/notify/_note_email.text.erb | 4 ++-- app/views/notify/autodevops_disabled_email.text.erb | 2 +- app/views/notify/closed_issue_email.html.haml | 2 +- app/views/notify/closed_issue_email.text.haml | 2 +- app/views/notify/closed_merge_request_email.html.haml | 2 +- app/views/notify/closed_merge_request_email.text.haml | 6 +++--- app/views/notify/issue_status_changed_email.html.haml | 2 +- app/views/notify/issue_status_changed_email.text.erb | 2 +- app/views/notify/member_access_requested_email.text.erb | 2 +- app/views/notify/member_invite_accepted_email.text.erb | 2 +- app/views/notify/member_invited_email.text.erb | 2 +- app/views/notify/merge_request_status_email.html.haml | 2 +- app/views/notify/merge_request_status_email.text.haml | 6 +++--- app/views/notify/merge_request_unmergeable_email.text.haml | 4 ++-- app/views/notify/merged_merge_request_email.text.haml | 4 ++-- app/views/notify/new_gpg_key_email.html.haml | 2 +- app/views/notify/new_gpg_key_email.text.erb | 2 +- app/views/notify/new_issue_email.text.erb | 2 +- app/views/notify/new_mention_in_issue_email.text.erb | 4 ++-- app/views/notify/new_mention_in_merge_request_email.text.erb | 4 ++-- app/views/notify/new_merge_request_email.html.haml | 2 +- app/views/notify/new_ssh_key_email.html.haml | 2 +- app/views/notify/new_ssh_key_email.text.erb | 2 +- app/views/notify/new_user_email.html.haml | 2 +- app/views/notify/new_user_email.text.erb | 2 +- app/views/notify/pipeline_failed_email.text.erb | 6 +++--- app/views/notify/pipeline_success_email.text.erb | 6 +++--- app/views/notify/push_to_merge_request_email.html.haml | 2 +- app/views/notify/push_to_merge_request_email.text.haml | 2 +- app/views/notify/reassigned_issue_email.html.haml | 2 +- app/views/notify/reassigned_issue_email.text.erb | 2 +- app/views/notify/reassigned_merge_request_email.html.haml | 4 ++-- app/views/notify/reassigned_merge_request_email.text.erb | 4 ++-- app/views/notify/resolved_all_discussions_email.html.haml | 2 +- app/views/notify/resolved_all_discussions_email.text.erb | 2 +- spec/mailers/notify_spec.rb | 8 +++++--- 37 files changed, 56 insertions(+), 54 deletions(-) diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml index 3d0a1f622a5..ccc3e734276 100644 --- a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml +++ b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml @@ -1,5 +1,5 @@ #content - = email_default_heading("#{@resource.user.name}, you've added an additional email!") + = email_default_heading("#{sanitize_name(@resource.user.name)}, you've added an additional email!") %p Click the link below to confirm your email address (#{@resource.email}) #cta = link_to 'Confirm your email address', confirmation_url(@resource, confirmation_token: @token) diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb index 50209c46ed1..5a67214059c 100644 --- a/app/views/notify/_note_email.text.erb +++ b/app/views/notify/_note_email.text.erb @@ -3,7 +3,7 @@ <% discussion = note.discussion if note.part_of_discussion? -%> <% if discussion && !discussion.individual_note? -%> -<%= note.author_name -%> +<%= sanitize_name(note.author_name) -%> <% if discussion.new_discussion? -%> <%= " started a new discussion" -%> <% else -%> @@ -16,7 +16,7 @@ <% elsif Gitlab::CurrentSettings.email_author_in_body -%> -<%= "#{note.author_name} commented:" -%> +<%= "#{sanitize_name(note.author_name)} commented:" -%> <% end -%> diff --git a/app/views/notify/autodevops_disabled_email.text.erb b/app/views/notify/autodevops_disabled_email.text.erb index 695780c3145..bf863952478 100644 --- a/app/views/notify/autodevops_disabled_email.text.erb +++ b/app/views/notify/autodevops_disabled_email.text.erb @@ -3,7 +3,7 @@ Auto DevOps pipeline was disabled for <%= @project.name %> The Auto DevOps pipeline failed for pipeline <%= @pipeline.iid %> (<%= pipeline_url(@pipeline) %>) and has been disabled for <%= @project.name %>. In order to use the Auto DevOps pipeline with your project, please review the currently supported languagues (https://docs.gitlab.com/ee/topics/autodevops/#currently-supported-languages), adjust your project accordingly, and turn on the Auto DevOps pipeline within your CI/CD project settings (<%= project_settings_ci_cd_url(@project) %>). <% if @pipeline.user -%> - Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> ) + Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> ) <% else -%> Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API <% end -%> diff --git a/app/views/notify/closed_issue_email.html.haml b/app/views/notify/closed_issue_email.html.haml index b7284dd819b..eb148d72da1 100644 --- a/app/views/notify/closed_issue_email.html.haml +++ b/app/views/notify/closed_issue_email.html.haml @@ -1,2 +1,2 @@ %p - Issue was closed by #{@updated_by.name} + Issue was closed by #{sanitize_name(@updated_by.name)} diff --git a/app/views/notify/closed_issue_email.text.haml b/app/views/notify/closed_issue_email.text.haml index b35d4b7502d..b1f0a3f37ec 100644 --- a/app/views/notify/closed_issue_email.text.haml +++ b/app/views/notify/closed_issue_email.text.haml @@ -1,3 +1,3 @@ -Issue was closed by #{@updated_by.name} +Issue was closed by #{sanitize_name(@updated_by.name)} Issue ##{@issue.iid}: #{project_issue_url(@issue.project, @issue)} diff --git a/app/views/notify/closed_merge_request_email.html.haml b/app/views/notify/closed_merge_request_email.html.haml index 44e018304e1..2aa753e0d55 100644 --- a/app/views/notify/closed_merge_request_email.html.haml +++ b/app/views/notify/closed_merge_request_email.html.haml @@ -1,2 +1,2 @@ %p - Merge Request #{@merge_request.to_reference} was closed by #{@updated_by.name} + Merge Request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)} diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml index c4e06cb3cb1..1094d584a1c 100644 --- a/app/views/notify/closed_merge_request_email.text.haml +++ b/app/views/notify/closed_merge_request_email.text.haml @@ -1,8 +1,8 @@ -Merge Request #{@merge_request.to_reference} was closed by #{@updated_by.name} +Merge Request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)} Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)} = merge_path_description(@merge_request, 'to') -Author: #{@merge_request.author_name} -Assignee: #{@merge_request.assignee_name} +Author: #{sanitize_name(@merge_request.author_name)} +Assignee: #{sanitize_name(@merge_request.assignee_name)} diff --git a/app/views/notify/issue_status_changed_email.html.haml b/app/views/notify/issue_status_changed_email.html.haml index b6051b11cea..66e73a9b03f 100644 --- a/app/views/notify/issue_status_changed_email.html.haml +++ b/app/views/notify/issue_status_changed_email.html.haml @@ -1,2 +1,2 @@ %p - Issue was #{@issue_status} by #{@updated_by.name} + Issue was #{@issue_status} by #{sanitize_name(@updated_by.name)} diff --git a/app/views/notify/issue_status_changed_email.text.erb b/app/views/notify/issue_status_changed_email.text.erb index 4200881f7e8..f38b09e9820 100644 --- a/app/views/notify/issue_status_changed_email.text.erb +++ b/app/views/notify/issue_status_changed_email.text.erb @@ -1,4 +1,4 @@ -Issue was <%= @issue_status %> by <%= @updated_by.name %> +Issue was <%= @issue_status %> by <%= sanitize_name(@updated_by.name) %> Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %> diff --git a/app/views/notify/member_access_requested_email.text.erb b/app/views/notify/member_access_requested_email.text.erb index 9c5ee0eaf26..ddb4a7b3d2c 100644 --- a/app/views/notify/member_access_requested_email.text.erb +++ b/app/views/notify/member_access_requested_email.text.erb @@ -1,3 +1,3 @@ -<%= member.user.name %> (<%= user_url(member.user) %>) requested <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>. +<%= sanitize_name(member.user.name) %> (<%= user_url(member.user) %>) requested <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>. <%= polymorphic_url([member_source, :members]) %> diff --git a/app/views/notify/member_invite_accepted_email.text.erb b/app/views/notify/member_invite_accepted_email.text.erb index cef87101427..c824533eac2 100644 --- a/app/views/notify/member_invite_accepted_email.text.erb +++ b/app/views/notify/member_invite_accepted_email.text.erb @@ -1,3 +1,3 @@ -<%= member.invite_email %>, now known as <%= member.user.name %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>. +<%= member.invite_email %>, now known as <%= sanitize_name(member.user.name) %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>. <%= member_source.web_url %> diff --git a/app/views/notify/member_invited_email.text.erb b/app/views/notify/member_invited_email.text.erb index 0a6393355be..d944c3b4a50 100644 --- a/app/views/notify/member_invited_email.text.erb +++ b/app/views/notify/member_invited_email.text.erb @@ -1,4 +1,4 @@ -You have been invited <%= "by #{member.created_by.name} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>. +You have been invited <%= "by #{sanitize_name(member.created_by.name)} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>. Accept invitation: <%= invite_url(@token) %> Decline invitation: <%= decline_invite_url(@token) %> diff --git a/app/views/notify/merge_request_status_email.html.haml b/app/views/notify/merge_request_status_email.html.haml index b487e26b122..ffb416abf72 100644 --- a/app/views/notify/merge_request_status_email.html.haml +++ b/app/views/notify/merge_request_status_email.html.haml @@ -1,2 +1,2 @@ %p - Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{@updated_by.name} + Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)} diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml index ae2a2933865..b9b9e0c3ad7 100644 --- a/app/views/notify/merge_request_status_email.text.haml +++ b/app/views/notify/merge_request_status_email.text.haml @@ -1,8 +1,8 @@ -Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{@updated_by.name} +Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)} Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)} = merge_path_description(@merge_request, 'to') -Author: #{@merge_request.author_name} -Assignee: #{@merge_request.assignee_name} +Author: #{sanitize_name(@merge_request.author_name)} +Assignee: #{sanitize_name(@merge_request.assignee_name)} diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml index dcdd6db69d6..0c7bf1bb044 100644 --- a/app/views/notify/merge_request_unmergeable_email.text.haml +++ b/app/views/notify/merge_request_unmergeable_email.text.haml @@ -4,5 +4,5 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m = merge_path_description(@merge_request, 'to') -Author: #{@merge_request.author_name} -Assignee: #{@merge_request.assignee_name} +Author: #{sanitize_name(@merge_request.author_name)} +Assignee: #{sanitize_name(@merge_request.assignee_name)} diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml index 661c23bcbe2..045a43cbc84 100644 --- a/app/views/notify/merged_merge_request_email.text.haml +++ b/app/views/notify/merged_merge_request_email.text.haml @@ -4,5 +4,5 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m = merge_path_description(@merge_request, 'to') -Author: #{@merge_request.author_name} -Assignee: #{@merge_request.assignee_name} +Author: #{sanitize_name(@merge_request.author_name)} +Assignee: #{sanitize_name(@merge_request.assignee_name)} diff --git a/app/views/notify/new_gpg_key_email.html.haml b/app/views/notify/new_gpg_key_email.html.haml index 4b9350c4e88..b857705e01f 100644 --- a/app/views/notify/new_gpg_key_email.html.haml +++ b/app/views/notify/new_gpg_key_email.html.haml @@ -1,5 +1,5 @@ %p - Hi #{@user.name}! + Hi #{sanitize_name(@user.name)}! %p A new GPG key was added to your account: %p diff --git a/app/views/notify/new_gpg_key_email.text.erb b/app/views/notify/new_gpg_key_email.text.erb index 80b5a1fd7ff..92ea851eee4 100644 --- a/app/views/notify/new_gpg_key_email.text.erb +++ b/app/views/notify/new_gpg_key_email.text.erb @@ -1,4 +1,4 @@ -Hi <%= @user.name %>! +Hi <%= sanitize_name(@user.name) %>! A new GPG key was added to your account: diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb index 3c716f77296..58a2bcbe5eb 100644 --- a/app/views/notify/new_issue_email.text.erb +++ b/app/views/notify/new_issue_email.text.erb @@ -1,7 +1,7 @@ New Issue was created. Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %> -Author: <%= @issue.author_name %> +Author: <%= sanitize_name(@issue.author_name) %> Assignee: <%= @issue.assignee_list %> <%= @issue.description %> diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb index 23213106c5b..173091e4a80 100644 --- a/app/views/notify/new_mention_in_issue_email.text.erb +++ b/app/views/notify/new_mention_in_issue_email.text.erb @@ -1,7 +1,7 @@ You have been mentioned in an issue. Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %> -Author: <%= @issue.author_name %> -Assignee: <%= @issue.assignee_list %> +Author: <%= sanitize_name(@issue.author_name) %> +Assignee: <%= sanitize_name(@issue.assignee_list) %> <%= @issue.description %> diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb index 6fcebb22fc4..96a4f3f9eac 100644 --- a/app/views/notify/new_mention_in_merge_request_email.text.erb +++ b/app/views/notify/new_mention_in_merge_request_email.text.erb @@ -3,7 +3,7 @@ You have been mentioned in Merge Request <%= @merge_request.to_reference %> <%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %> <%= merge_path_description(@merge_request, 'to') %> -Author: <%= @merge_request.author_name %> -Assignee: <%= @merge_request.assignee_name %> +Author: <%= sanitize_name(@merge_request.author_name) %> +Assignee: <%= sanitize_name(@merge_request.assignee_name) %> <%= @merge_request.description %> diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index 5acd45b74a7..db23447dd39 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -7,7 +7,7 @@ - if @merge_request.assignee_id.present? %p - Assignee: #{@merge_request.assignee_name} + Assignee: #{sanitize_name(@merge_request.assignee_name)} = render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter diff --git a/app/views/notify/new_ssh_key_email.html.haml b/app/views/notify/new_ssh_key_email.html.haml index 63b0cbbd205..d031842be95 100644 --- a/app/views/notify/new_ssh_key_email.html.haml +++ b/app/views/notify/new_ssh_key_email.html.haml @@ -1,5 +1,5 @@ %p - Hi #{@user.name}! + Hi #{sanitize_name(@user.name)}! %p A new public key was added to your account: %p diff --git a/app/views/notify/new_ssh_key_email.text.erb b/app/views/notify/new_ssh_key_email.text.erb index 05b551c89a0..690357d69ed 100644 --- a/app/views/notify/new_ssh_key_email.text.erb +++ b/app/views/notify/new_ssh_key_email.text.erb @@ -1,4 +1,4 @@ -Hi <%= @user.name %>! +Hi <%= sanitize_name(@user.name) %>! A new public key was added to your account: diff --git a/app/views/notify/new_user_email.html.haml b/app/views/notify/new_user_email.html.haml index db4424a01f9..dfbb5c75bd3 100644 --- a/app/views/notify/new_user_email.html.haml +++ b/app/views/notify/new_user_email.html.haml @@ -1,5 +1,5 @@ %p - Hi #{@user['name']}! + Hi #{sanitize_name(@user['name'])}! %p - if Gitlab::CurrentSettings.allow_signup? Your account has been created successfully. diff --git a/app/views/notify/new_user_email.text.erb b/app/views/notify/new_user_email.text.erb index dd9b71e3b84..f3f20f3bfba 100644 --- a/app/views/notify/new_user_email.text.erb +++ b/app/views/notify/new_user_email.text.erb @@ -1,4 +1,4 @@ -Hi <%= @user.name %>! +Hi <%= sanitize_name(@user.name) %>! The Administrator created an account for you. Now you are a member of the company GitLab application. diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb index 294238eee51..722eedf90be 100644 --- a/app/views/notify/pipeline_failed_email.text.erb +++ b/app/views/notify/pipeline_failed_email.text.erb @@ -10,20 +10,20 @@ Commit: <%= @pipeline.short_sha %> ( <%= commit_url(@pipeline) %> ) Commit Message: <%= @pipeline.git_commit_message.truncate(50) %> <% commit = @pipeline.commit -%> <% if commit.author -%> -Commit Author: <%= commit.author.name %> ( <%= user_url(commit.author) %> ) +Commit Author: <%= sanitize_name(commit.author.name) %> ( <%= user_url(commit.author) %> ) <% else -%> Commit Author: <%= commit.author_name %> <% end -%> <% if commit.different_committer? -%> <% if commit.committer -%> -Committed by: <%= commit.committer.name %> ( <%= user_url(commit.committer) %> ) +Committed by: <%= sanitize_name(commit.committer.name) %> ( <%= user_url(commit.committer) %> ) <% else -%> Committed by: <%= commit.committer_name %> <% end -%> <% end -%> <% if @pipeline.user -%> -Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> ) +Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> ) <% else -%> Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API <% end -%> diff --git a/app/views/notify/pipeline_success_email.text.erb b/app/views/notify/pipeline_success_email.text.erb index 39622cf7f02..9aadf380f79 100644 --- a/app/views/notify/pipeline_success_email.text.erb +++ b/app/views/notify/pipeline_success_email.text.erb @@ -10,13 +10,13 @@ Commit: <%= @pipeline.short_sha %> ( <%= commit_url(@pipeline) %> ) Commit Message: <%= @pipeline.git_commit_message.truncate(50) %> <% commit = @pipeline.commit -%> <% if commit.author -%> -Commit Author: <%= commit.author.name %> ( <%= user_url(commit.author) %> ) +Commit Author: <%= sanitize_name(commit.author.name) %> ( <%= user_url(commit.author) %> ) <% else -%> Commit Author: <%= commit.author_name %> <% end -%> <% if commit.different_committer? -%> <% if commit.committer -%> -Committed by: <%= commit.committer.name %> ( <%= user_url(commit.committer) %> ) +Committed by: <%= sanitize_name(commit.committer.name) %> ( <%= user_url(commit.committer) %> ) <% else -%> Committed by: <%= commit.committer_name %> <% end -%> @@ -25,7 +25,7 @@ Committed by: <%= commit.committer_name %> <% job_count = @pipeline.total_size -%> <% stage_count = @pipeline.stages_count -%> <% if @pipeline.user -%> -Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> ) +Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> ) <% else -%> Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API <% end -%> diff --git a/app/views/notify/push_to_merge_request_email.html.haml b/app/views/notify/push_to_merge_request_email.html.haml index 67744ec1cee..97258833cfc 100644 --- a/app/views/notify/push_to_merge_request_email.html.haml +++ b/app/views/notify/push_to_merge_request_email.html.haml @@ -1,5 +1,5 @@ %h3 - = @updated_by_user.name + = sanitize_name(@updated_by_user.name) pushed new commits to merge request = link_to(@merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request)) diff --git a/app/views/notify/push_to_merge_request_email.text.haml b/app/views/notify/push_to_merge_request_email.text.haml index 95759d127e2..10c8e158846 100644 --- a/app/views/notify/push_to_merge_request_email.text.haml +++ b/app/views/notify/push_to_merge_request_email.text.haml @@ -1,4 +1,4 @@ -#{@updated_by_user.name} pushed new commits to merge request #{@merge_request.to_reference} +#{sanitize_name(@updated_by_user.name)} pushed new commits to merge request #{@merge_request.to_reference} \ #{url_for(project_merge_request_url(@merge_request.target_project, @merge_request))} \ diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml index ee2f40e1683..6d25488a7e2 100644 --- a/app/views/notify/reassigned_issue_email.html.haml +++ b/app/views/notify/reassigned_issue_email.html.haml @@ -2,7 +2,7 @@ Assignee changed - if @previous_assignees.any? from - %strong= @previous_assignees.map(&:name).to_sentence + %strong= sanitize_name(@previous_assignees.map(&:name).to_sentence) to - if @issue.assignees.any? %strong= @issue.assignee_list diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb index 6c357f1074a..7bf2e8e6ce3 100644 --- a/app/views/notify/reassigned_issue_email.text.erb +++ b/app/views/notify/reassigned_issue_email.text.erb @@ -2,5 +2,5 @@ Reassigned Issue <%= @issue.iid %> <%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %> -Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%> +Assignee changed <%= "from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%> to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %> diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml index 24c2b08810b..e4f19bc3200 100644 --- a/app/views/notify/reassigned_merge_request_email.html.haml +++ b/app/views/notify/reassigned_merge_request_email.html.haml @@ -2,9 +2,9 @@ Assignee changed - if @previous_assignee from - %strong= @previous_assignee.name + %strong= sanitize_name(@previous_assignee.name) to - if @merge_request.assignee_id - %strong= @merge_request.assignee_name + %strong= sanitize_name(@merge_request.assignee_name) - else %strong Unassigned diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb index 998a40fefde..96c770b5219 100644 --- a/app/views/notify/reassigned_merge_request_email.text.erb +++ b/app/views/notify/reassigned_merge_request_email.text.erb @@ -2,5 +2,5 @@ Reassigned Merge Request <%= @merge_request.iid %> <%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %> -Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> - to <%= "#{@merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'}" %> +Assignee changed <%= "from #{sanitize_name(@previous_assignee.name)}" if @previous_assignee -%> + to <%= "#{@merge_request.assignee_id ? sanitize_name(@merge_request.assignee_name) : 'Unassigned'}" %> diff --git a/app/views/notify/resolved_all_discussions_email.html.haml b/app/views/notify/resolved_all_discussions_email.html.haml index 522421b7cc3..502b8f21e35 100644 --- a/app/views/notify/resolved_all_discussions_email.html.haml +++ b/app/views/notify/resolved_all_discussions_email.html.haml @@ -1,2 +1,2 @@ %p - All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{@resolved_by.name} + All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{sanitize_name(@resolved_by.name)} diff --git a/app/views/notify/resolved_all_discussions_email.text.erb b/app/views/notify/resolved_all_discussions_email.text.erb index 2881f3e699e..c4b36bfe1a8 100644 --- a/app/views/notify/resolved_all_discussions_email.text.erb +++ b/app/views/notify/resolved_all_discussions_email.text.erb @@ -1,3 +1,3 @@ -All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= @resolved_by.name %> +All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= sanitize_name(@resolved_by.name) %> <%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %> diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index f2d99872401..ec3972ac8db 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -9,8 +9,10 @@ describe Notify do include_context 'gitlab email notification' + let(:current_user_sanitized) { 'www_example_com' } + set(:user) { create(:user) } - set(:current_user) { create(:user, email: "current@email.com") } + set(:current_user) { create(:user, email: "current@email.com", name: 'www.example.com') } set(:assignee) { create(:user, email: 'assignee@example.com', name: 'John Doe') } set(:merge_request) do @@ -182,7 +184,7 @@ describe Notify do aggregate_failures do is_expected.to have_referable_subject(issue, reply: true) is_expected.to have_body_text(status) - is_expected.to have_body_text(current_user.name) + is_expected.to have_body_text(current_user_sanitized) is_expected.to have_body_text(project_issue_path project, issue) end end @@ -361,7 +363,7 @@ describe Notify do aggregate_failures do is_expected.to have_referable_subject(merge_request, reply: true) is_expected.to have_body_text(status) - is_expected.to have_body_text(current_user.name) + is_expected.to have_body_text(current_user_sanitized) is_expected.to have_body_text(project_merge_request_path(project, merge_request)) end end -- cgit v1.2.3 From 7b8b2ad69c600e3eaa84509d93dc8e0722f72762 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Tue, 22 Jan 2019 18:59:42 +0530 Subject: Add changelog entry --- changelogs/unreleased/security-22076-sanitize-url-in-names.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelogs/unreleased/security-22076-sanitize-url-in-names.yml diff --git a/changelogs/unreleased/security-22076-sanitize-url-in-names.yml b/changelogs/unreleased/security-22076-sanitize-url-in-names.yml new file mode 100644 index 00000000000..4e0ad4dd4c4 --- /dev/null +++ b/changelogs/unreleased/security-22076-sanitize-url-in-names.yml @@ -0,0 +1,6 @@ +--- +title: Sanitize user full name to clean up any URL to prevent mail clients from auto-linking + URLs +merge_request: 2793 +author: +type: security -- cgit v1.2.3 From 1a8100cff59d983191b43dacdddbdab65ea23c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Trzci=C5=84ski?= Date: Wed, 2 Jan 2019 20:01:11 +0100 Subject: Extract GitLab Pages using RubyZip RubyZip allows us to perform strong validation of expanded paths where we do extract file. We introduce the following additional checks to extract routines: 1. None of path components can be symlinked, 2. We drop privileges support for directories, 3. Symlink source needs to point within the target directory, like `public/`, 4. The symlink source needs to exist ahead of time. --- Gemfile | 1 + Gemfile.lock | 1 + app/services/projects/update_pages_service.rb | 41 +++-- .../unreleased/extract-pages-with-rubyzip.yml | 5 + lib/safe_zip/entry.rb | 97 ++++++++++ lib/safe_zip/extract.rb | 73 ++++++++ lib/safe_zip/extract_params.rb | 36 ++++ spec/fixtures/pages_non_writeable.zip | Bin 0 -> 727 bytes .../safe_zip/invalid-symlink-does-not-exist.zip | Bin 0 -> 1183 bytes .../fixtures/safe_zip/invalid-symlinks-outside.zip | Bin 0 -> 1309 bytes spec/fixtures/safe_zip/valid-non-writeable.zip | Bin 0 -> 727 bytes spec/fixtures/safe_zip/valid-simple.zip | Bin 0 -> 1144 bytes spec/fixtures/safe_zip/valid-symlinks-first.zip | Bin 0 -> 528 bytes spec/lib/safe_zip/entry_spec.rb | 196 +++++++++++++++++++++ spec/lib/safe_zip/extract_params_spec.rb | 54 ++++++ spec/lib/safe_zip/extract_spec.rb | 80 +++++++++ .../services/projects/update_pages_service_spec.rb | 35 +++- 17 files changed, 594 insertions(+), 25 deletions(-) create mode 100644 changelogs/unreleased/extract-pages-with-rubyzip.yml create mode 100644 lib/safe_zip/entry.rb create mode 100644 lib/safe_zip/extract.rb create mode 100644 lib/safe_zip/extract_params.rb create mode 100644 spec/fixtures/pages_non_writeable.zip create mode 100644 spec/fixtures/safe_zip/invalid-symlink-does-not-exist.zip create mode 100644 spec/fixtures/safe_zip/invalid-symlinks-outside.zip create mode 100644 spec/fixtures/safe_zip/valid-non-writeable.zip create mode 100644 spec/fixtures/safe_zip/valid-simple.zip create mode 100644 spec/fixtures/safe_zip/valid-symlinks-first.zip create mode 100644 spec/lib/safe_zip/entry_spec.rb create mode 100644 spec/lib/safe_zip/extract_params_spec.rb create mode 100644 spec/lib/safe_zip/extract_spec.rb diff --git a/Gemfile b/Gemfile index b3eeb3ec0ec..e632a083acf 100644 --- a/Gemfile +++ b/Gemfile @@ -57,6 +57,7 @@ gem 'u2f', '~> 0.2.1' # GitLab Pages gem 'validates_hostname', '~> 1.0.6' +gem 'rubyzip', '~> 1.2.2', require: false # Browser detection gem 'browser', '~> 2.5' diff --git a/Gemfile.lock b/Gemfile.lock index 419a6831924..d8982614d8b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1137,6 +1137,7 @@ DEPENDENCIES ruby-prof (~> 0.17.0) ruby-progressbar ruby_parser (~> 3.8) + rubyzip (~> 1.2.2) rugged (~> 0.27) sanitize (~> 4.6) sass (~> 3.5) diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index eb2478be3cf..5caeb4cfa5f 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -7,7 +7,11 @@ module Projects BLOCK_SIZE = 32.kilobytes MAX_SIZE = 1.terabyte - SITE_PATH = 'public/'.freeze + PUBLIC_DIR = 'public'.freeze + + # this has to be invalid group name, + # as it shares the namespace with groups + TMP_EXTRACT_PATH = '@pages.tmp'.freeze attr_reader :build @@ -27,12 +31,11 @@ module Projects raise InvalidStateError, 'pages are outdated' unless latest? # Create temporary directory in which we will extract the artifacts - FileUtils.mkdir_p(tmp_path) - Dir.mktmpdir(nil, tmp_path) do |archive_path| + make_secure_tmp_dir(tmp_path) do |archive_path| extract_archive!(archive_path) # Check if we did extract public directory - archive_public_path = File.join(archive_path, 'public') + archive_public_path = File.join(archive_path, PUBLIC_DIR) raise InvalidStateError, 'pages miss the public folder' unless Dir.exist?(archive_public_path) raise InvalidStateError, 'pages are outdated' unless latest? @@ -85,22 +88,18 @@ module Projects raise InvalidStateError, 'missing artifacts metadata' unless build.artifacts_metadata? # Calculate page size after extract - public_entry = build.artifacts_metadata_entry(SITE_PATH, recursive: true) + public_entry = build.artifacts_metadata_entry(PUBLIC_DIR + '/', recursive: true) if public_entry.total_size > max_size raise InvalidStateError, "artifacts for pages are too large: #{public_entry.total_size}" end - # Requires UnZip at least 6.00 Info-ZIP. - # -qq be (very) quiet - # -n never overwrite existing files - # We add * to end of SITE_PATH, because we want to extract SITE_PATH and all subdirectories - site_path = File.join(SITE_PATH, '*') build.artifacts_file.use_file do |artifacts_path| - unless system(*%W(unzip -n #{artifacts_path} #{site_path} -d #{temp_path})) - raise FailedToExtractError, 'pages failed to extract' - end + SafeZip::Extract.new(artifacts_path) + .extract(directories: [PUBLIC_DIR], to: temp_path) end + rescue SafeZip::Extract::Error => e + raise FailedToExtractError, e.message end def deploy_page!(archive_public_path) @@ -139,7 +138,7 @@ module Projects end def tmp_path - @tmp_path ||= File.join(::Settings.pages.path, 'tmp') + @tmp_path ||= File.join(::Settings.pages.path, TMP_EXTRACT_PATH) end def pages_path @@ -147,11 +146,11 @@ module Projects end def public_path - @public_path ||= File.join(pages_path, 'public') + @public_path ||= File.join(pages_path, PUBLIC_DIR) end def previous_public_path - @previous_public_path ||= File.join(pages_path, "public.#{SecureRandom.hex}") + @previous_public_path ||= File.join(pages_path, "#{PUBLIC_DIR}.#{SecureRandom.hex}") end def ref @@ -188,5 +187,15 @@ module Projects def pages_deployments_failed_total_counter @pages_deployments_failed_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_failed_total, "Counter of GitLab Pages deployments which failed") end + + def make_secure_tmp_dir(tmp_path) + FileUtils.mkdir_p(tmp_path) + path = Dir.mktmpdir(nil, tmp_path) + begin + yield(path) + ensure + FileUtils.remove_entry_secure(path) + end + end end end diff --git a/changelogs/unreleased/extract-pages-with-rubyzip.yml b/changelogs/unreleased/extract-pages-with-rubyzip.yml new file mode 100644 index 00000000000..8352e79d3e5 --- /dev/null +++ b/changelogs/unreleased/extract-pages-with-rubyzip.yml @@ -0,0 +1,5 @@ +--- +title: Extract GitLab Pages using RubyZip +merge_request: +author: +type: security diff --git a/lib/safe_zip/entry.rb b/lib/safe_zip/entry.rb new file mode 100644 index 00000000000..664e2f52f91 --- /dev/null +++ b/lib/safe_zip/entry.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module SafeZip + class Entry + attr_reader :zip_archive, :zip_entry + attr_reader :path, :params + + def initialize(zip_archive, zip_entry, params) + @zip_archive = zip_archive + @zip_entry = zip_entry + @params = params + @path = ::File.expand_path(zip_entry.name, params.extract_path) + end + + def path_dir + ::File.dirname(path) + end + + def real_path_dir + ::File.realpath(path_dir) + end + + def exist? + ::File.exist?(path) + end + + def extract + # do not extract if file is not part of target directory + return false unless matching_target_directory + + # do not overwrite existing file + raise SafeZip::Extract::AlreadyExistsError, "File already exists #{zip_entry.name}" if exist? + + create_path_dir + + if zip_entry.file? + extract_file + elsif zip_entry.directory? + extract_dir + elsif zip_entry.symlink? + extract_symlink + else + raise SafeZip::Extract::UnsupportedEntryError, "File #{zip_entry.name} cannot be extracted" + end + rescue SafeZip::Extract::Error + raise + rescue => e + raise SafeZip::Extract::ExtractError, e.message + end + + private + + def extract_file + zip_archive.extract(zip_entry, path) + end + + def extract_dir + FileUtils.mkdir(path) + end + + def extract_symlink + source_path = read_symlink + real_source_path = expand_symlink(source_path) + + # ensure that source path of symlink is within target directories + unless real_source_path.start_with?(matching_target_directory) + raise SafeZip::Extract::PermissionDeniedError, "Symlink cannot be created targeting: #{source_path}" + end + + ::File.symlink(source_path, path) + end + + def create_path_dir + # Create all directories, but ignore permissions + FileUtils.mkdir_p(path_dir) + + # disallow to make path dirs to point to another directories + unless path_dir == real_path_dir + raise SafeZip::Extract::PermissionDeniedError, "Directory of #{zip_entry.name} points to another directory" + end + end + + def matching_target_directory + params.matching_target_directory(path) + end + + def read_symlink + zip_archive.read(zip_entry) + end + + def expand_symlink(source_path) + ::File.realpath(source_path, path_dir) + rescue + raise SafeZip::Extract::SymlinkSourceDoesNotExistError, "Symlink source #{source_path} does not exist" + end + end +end diff --git a/lib/safe_zip/extract.rb b/lib/safe_zip/extract.rb new file mode 100644 index 00000000000..3bd4935ef34 --- /dev/null +++ b/lib/safe_zip/extract.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module SafeZip + class Extract + Error = Class.new(StandardError) + PermissionDeniedError = Class.new(Error) + SymlinkSourceDoesNotExistError = Class.new(Error) + UnsupportedEntryError = Class.new(Error) + AlreadyExistsError = Class.new(Error) + NoMatchingError = Class.new(Error) + ExtractError = Class.new(Error) + + attr_reader :archive_path + + def initialize(archive_file) + @archive_path = archive_file + end + + def extract(opts = {}) + params = SafeZip::ExtractParams.new(**opts) + + if Feature.enabled?(:safezip_use_rubyzip, default_enabled: true) + extract_with_ruby_zip(params) + else + legacy_unsafe_extract_with_system_zip(params) + end + end + + private + + def extract_with_ruby_zip(params) + Zip::File.open(archive_path) do |zip_archive| + # Extract all files in the following order: + # 1. Directories first, + # 2. Files next, + # 3. Symlinks last (or anything else) + extracted = extract_all_entries(zip_archive, params, + zip_archive.lazy.select(&:directory?)) + + extracted += extract_all_entries(zip_archive, params, + zip_archive.lazy.select(&:file?)) + + extracted += extract_all_entries(zip_archive, params, + zip_archive.lazy.reject(&:directory?).reject(&:file?)) + + raise NoMatchingError, 'No entries extracted' unless extracted > 0 + end + end + + def extract_all_entries(zip_archive, params, entries) + entries.count do |zip_entry| + SafeZip::Entry.new(zip_archive, zip_entry, params) + .extract + end + end + + def legacy_unsafe_extract_with_system_zip(params) + # Requires UnZip at least 6.00 Info-ZIP. + # -n never overwrite existing files + args = %W(unzip -n -qq #{archive_path}) + + # We add * to end of directory, because we want to extract directory and all subdirectories + args += params.directories_wildcard + + # Target directory where we extract + args += %W(-d #{params.extract_path}) + + unless system(*args) + raise Error, 'archive failed to extract' + end + end + end +end diff --git a/lib/safe_zip/extract_params.rb b/lib/safe_zip/extract_params.rb new file mode 100644 index 00000000000..bd3b788bac9 --- /dev/null +++ b/lib/safe_zip/extract_params.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module SafeZip + class ExtractParams + include Gitlab::Utils::StrongMemoize + + attr_reader :directories, :extract_path + + def initialize(directories:, to:) + @directories = directories + @extract_path = ::File.realpath(to) + end + + def matching_target_directory(path) + target_directories.find do |directory| + path.start_with?(directory) + end + end + + def target_directories + strong_memoize(:target_directories) do + directories.map do |directory| + ::File.join(::File.expand_path(directory, extract_path), '') + end + end + end + + def directories_wildcard + strong_memoize(:directories_wildcard) do + directories.map do |directory| + ::File.join(directory, '*') + end + end + end + end +end diff --git a/spec/fixtures/pages_non_writeable.zip b/spec/fixtures/pages_non_writeable.zip new file mode 100644 index 00000000000..69f175d8504 Binary files /dev/null and b/spec/fixtures/pages_non_writeable.zip differ diff --git a/spec/fixtures/safe_zip/invalid-symlink-does-not-exist.zip b/spec/fixtures/safe_zip/invalid-symlink-does-not-exist.zip new file mode 100644 index 00000000000..b9ae1548713 Binary files /dev/null and b/spec/fixtures/safe_zip/invalid-symlink-does-not-exist.zip differ diff --git a/spec/fixtures/safe_zip/invalid-symlinks-outside.zip b/spec/fixtures/safe_zip/invalid-symlinks-outside.zip new file mode 100644 index 00000000000..c184a1dafe2 Binary files /dev/null and b/spec/fixtures/safe_zip/invalid-symlinks-outside.zip differ diff --git a/spec/fixtures/safe_zip/valid-non-writeable.zip b/spec/fixtures/safe_zip/valid-non-writeable.zip new file mode 100644 index 00000000000..69f175d8504 Binary files /dev/null and b/spec/fixtures/safe_zip/valid-non-writeable.zip differ diff --git a/spec/fixtures/safe_zip/valid-simple.zip b/spec/fixtures/safe_zip/valid-simple.zip new file mode 100644 index 00000000000..a56b8b41dcc Binary files /dev/null and b/spec/fixtures/safe_zip/valid-simple.zip differ diff --git a/spec/fixtures/safe_zip/valid-symlinks-first.zip b/spec/fixtures/safe_zip/valid-symlinks-first.zip new file mode 100644 index 00000000000..f5952ef71c9 Binary files /dev/null and b/spec/fixtures/safe_zip/valid-symlinks-first.zip differ diff --git a/spec/lib/safe_zip/entry_spec.rb b/spec/lib/safe_zip/entry_spec.rb new file mode 100644 index 00000000000..115e28c5994 --- /dev/null +++ b/spec/lib/safe_zip/entry_spec.rb @@ -0,0 +1,196 @@ +require 'spec_helper' + +describe SafeZip::Entry do + let(:target_path) { Dir.mktmpdir('safe-zip') } + let(:directories) { %w(public folder/with/subfolder) } + let(:params) { SafeZip::ExtractParams.new(directories: directories, to: target_path) } + + let(:entry) { described_class.new(zip_archive, zip_entry, params) } + let(:entry_name) { 'public/folder/index.html' } + let(:entry_path_dir) { File.join(target_path, File.dirname(entry_name)) } + let(:entry_path) { File.join(target_path, entry_name) } + let(:zip_archive) { double } + + let(:zip_entry) do + double( + name: entry_name, + file?: false, + directory?: false, + symlink?: false) + end + + after do + FileUtils.remove_entry_secure(target_path) + end + + context '#path_dir' do + subject { entry.path_dir } + + it { is_expected.to eq(target_path + '/public/folder') } + end + + context '#exist?' do + subject { entry.exist? } + + context 'when entry does not exist' do + it { is_expected.not_to be_truthy } + end + + context 'when entry does exist' do + before do + create_entry + end + + it { is_expected.to be_truthy } + end + end + + describe '#extract' do + subject { entry.extract } + + context 'when entry does not match the filtered directories' do + using RSpec::Parameterized::TableSyntax + + where(:entry_name) do + [ + 'assets/folder/index.html', + 'public/../folder/index.html', + 'public/../../../../../index.html', + '../../../../../public/index.html', + '/etc/passwd' + ] + end + + with_them do + it 'does not extract file' do + is_expected.to be_falsey + end + end + end + + context 'when entry does exist' do + before do + create_entry + end + + it 'raises an exception' do + expect { subject }.to raise_error(SafeZip::Extract::AlreadyExistsError) + end + end + + context 'when entry type is unknown' do + it 'raises an exception' do + expect { subject }.to raise_error(SafeZip::Extract::UnsupportedEntryError) + end + end + + context 'when entry is valid' do + shared_examples 'secured symlinks' do + context 'when we try to extract entry into symlinked folder' do + before do + FileUtils.mkdir_p(File.join(target_path, "source")) + File.symlink("source", File.join(target_path, "public")) + end + + it 'raises an exception' do + expect { subject }.to raise_error(SafeZip::Extract::PermissionDeniedError) + end + end + end + + context 'and is file' do + before do + allow(zip_entry).to receive(:file?) { true } + end + + it 'does extract file' do + expect(zip_archive).to receive(:extract) + .with(zip_entry, entry_path) + .and_return(true) + + is_expected.to be_truthy + end + + it_behaves_like 'secured symlinks' + end + + context 'and is directory' do + let(:entry_name) { 'public/folder/assets' } + + before do + allow(zip_entry).to receive(:directory?) { true } + end + + it 'does create directory' do + is_expected.to be_truthy + + expect(File.exist?(entry_path)).to eq(true) + end + + it_behaves_like 'secured symlinks' + end + + context 'and is symlink' do + let(:entry_name) { 'public/folder/assets' } + + before do + allow(zip_entry).to receive(:symlink?) { true } + allow(zip_archive).to receive(:read).with(zip_entry) { entry_symlink } + end + + shared_examples 'a valid symlink' do + it 'does create symlink' do + is_expected.to be_truthy + + expect(File.exist?(entry_path)).to eq(true) + end + end + + context 'when source is within target' do + let(:entry_symlink) { '../images' } + + context 'but does not exist' do + it 'raises an exception' do + expect { subject }.to raise_error(SafeZip::Extract::SymlinkSourceDoesNotExistError) + end + end + + context 'and does exist' do + before do + FileUtils.mkdir_p(File.join(target_path, 'public', 'images')) + end + + it_behaves_like 'a valid symlink' + end + end + + context 'when source points outside of target' do + let(:entry_symlink) { '../../images' } + + before do + FileUtils.mkdir(File.join(target_path, 'images')) + end + + it 'raises an exception' do + expect { subject }.to raise_error(SafeZip::Extract::PermissionDeniedError) + end + end + + context 'when source points to /etc/passwd' do + let(:entry_symlink) { '/etc/passwd' } + + it 'raises an exception' do + expect { subject }.to raise_error(SafeZip::Extract::PermissionDeniedError) + end + end + end + end + end + + private + + def create_entry + FileUtils.mkdir_p(entry_path_dir) + FileUtils.touch(entry_path) + end +end diff --git a/spec/lib/safe_zip/extract_params_spec.rb b/spec/lib/safe_zip/extract_params_spec.rb new file mode 100644 index 00000000000..85e22cfa495 --- /dev/null +++ b/spec/lib/safe_zip/extract_params_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe SafeZip::ExtractParams do + let(:target_path) { Dir.mktmpdir("safe-zip") } + let(:params) { described_class.new(directories: directories, to: target_path) } + let(:directories) { %w(public folder/with/subfolder) } + + after do + FileUtils.remove_entry_secure(target_path) + end + + describe '#extract_path' do + subject { params.extract_path } + + it { is_expected.to eq(target_path) } + end + + describe '#matching_target_directory' do + using RSpec::Parameterized::TableSyntax + + subject { params.matching_target_directory(target_path + path) } + + where(:path, :result) do + '/public/index.html' | '/public/' + '/non/existing/path' | nil + '/public' | nil + '/folder/with/index.html' | nil + end + + with_them do + it { is_expected.to eq(result ? target_path + result : nil) } + end + end + + describe '#target_directories' do + subject { params.target_directories } + + it 'starts with target_path' do + is_expected.to all(start_with(target_path + '/')) + end + + it 'ends with / for all paths' do + is_expected.to all(end_with('/')) + end + end + + describe '#directories_wildcard' do + subject { params.directories_wildcard } + + it 'adds * for all paths' do + is_expected.to all(end_with('/*')) + end + end +end diff --git a/spec/lib/safe_zip/extract_spec.rb b/spec/lib/safe_zip/extract_spec.rb new file mode 100644 index 00000000000..dc7e25c0cf6 --- /dev/null +++ b/spec/lib/safe_zip/extract_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' + +describe SafeZip::Extract do + let(:target_path) { Dir.mktmpdir('safe-zip') } + let(:directories) { %w(public) } + let(:object) { described_class.new(archive) } + let(:archive) { Rails.root.join('spec', 'fixtures', 'safe_zip', archive_name) } + + after do + FileUtils.remove_entry_secure(target_path) + end + + context '#extract' do + subject { object.extract(directories: directories, to: target_path) } + + shared_examples 'extracts archive' do |param| + before do + stub_feature_flags(safezip_use_rubyzip: param) + end + + it 'does extract archive' do + subject + + expect(File.exist?(File.join(target_path, 'public', 'index.html'))).to eq(true) + expect(File.exist?(File.join(target_path, 'source'))).to eq(false) + end + end + + shared_examples 'fails to extract archive' do |param| + before do + stub_feature_flags(safezip_use_rubyzip: param) + end + + it 'does not extract archive' do + expect { subject }.to raise_error(SafeZip::Extract::Error) + end + end + + %w(valid-simple.zip valid-symlinks-first.zip valid-non-writeable.zip).each do |name| + context "when using #{name} archive" do + let(:archive_name) { name } + + context 'for RubyZip' do + it_behaves_like 'extracts archive', true + end + + context 'for UnZip' do + it_behaves_like 'extracts archive', false + end + end + end + + %w(invalid-symlink-does-not-exist.zip invalid-symlinks-outside.zip).each do |name| + context "when using #{name} archive" do + let(:archive_name) { name } + + context 'for RubyZip' do + it_behaves_like 'fails to extract archive', true + end + + context 'for UnZip (UNSAFE)' do + it_behaves_like 'extracts archive', false + end + end + end + + context 'when no matching directories are found' do + let(:archive_name) { 'valid-simple.zip' } + let(:directories) { %w(non/existing) } + + context 'for RubyZip' do + it_behaves_like 'fails to extract archive', true + end + + context 'for UnZip' do + it_behaves_like 'fails to extract archive', false + end + end + end +end diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb index 36b619ba9be..8b70845befe 100644 --- a/spec/services/projects/update_pages_service_spec.rb +++ b/spec/services/projects/update_pages_service_spec.rb @@ -5,24 +5,27 @@ describe Projects::UpdatePagesService do set(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit('HEAD').sha) } set(:build) { create(:ci_build, pipeline: pipeline, ref: 'HEAD') } let(:invalid_file) { fixture_file_upload('spec/fixtures/dk.png') } - let(:extension) { 'zip' } - let(:file) { fixture_file_upload("spec/fixtures/pages.#{extension}") } - let(:empty_file) { fixture_file_upload("spec/fixtures/pages_empty.#{extension}") } - let(:metadata) do - filename = "spec/fixtures/pages.#{extension}.meta" - fixture_file_upload(filename) if File.exist?(filename) - end + let(:file) { fixture_file_upload("spec/fixtures/pages.zip") } + let(:empty_file) { fixture_file_upload("spec/fixtures/pages_empty.zip") } + let(:metadata_filename) { "spec/fixtures/pages.zip.meta" } + let(:metadata) { fixture_file_upload(metadata_filename) if File.exist?(metadata_filename) } subject { described_class.new(project, build) } before do + stub_feature_flags(safezip_use_rubyzip: true) + project.remove_pages end - context 'legacy artifacts' do - let(:extension) { 'zip' } + context '::TMP_EXTRACT_PATH' do + subject { described_class::TMP_EXTRACT_PATH } + it { is_expected.not_to match(Gitlab::PathRegex.namespace_format_regex) } + end + + context 'legacy artifacts' do before do build.update(legacy_artifacts_file: file) build.update(legacy_artifacts_metadata: metadata) @@ -132,6 +135,20 @@ describe Projects::UpdatePagesService do end end + context 'when using pages with non-writeable public' do + let(:file) { fixture_file_upload("spec/fixtures/pages_non_writeable.zip") } + + context 'when using RubyZip' do + before do + stub_feature_flags(safezip_use_rubyzip: true) + end + + it 'succeeds to extract' do + expect(execute).to eq(:success) + end + end + end + context 'when timeout happens by DNS error' do before do allow_any_instance_of(described_class) -- cgit v1.2.3 From 87c5abfc0fb7dccf7f94c0b56ef93501506952c5 Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Tue, 8 Jan 2019 17:57:58 +0000 Subject: Verify that LFS upload requests are genuine LFS uploads are handled in concert by workhorse and rails. In normal use, workhorse: * Authorizes the request with rails (upload_authorize) * Handles the upload of the file to a tempfile - disk or object storage * Validates the file size and contents * Hands off to rails to complete the upload (upload_finalize) In `upload_finalize`, the LFS object is linked to the project. As LFS objects are deduplicated across all projects, it may already exist. If not, the temporary file is copied to the correct place, and will be used by all future LFS objects with the same OID. Workhorse uses the Content-Type of the request to decide to follow this routine, as the URLs are ambiguous. If the Content-Type is anything but "application/octet-stream", the request is proxied directly to rails, on the assumption that this is a normal file edit request. If it's an actual LFS request with a different content-type, however, it is routed to the Rails `upload_finalize` action, which treats it as an LFS upload just as it would a workhorse-modified request. The outcome is that users can upload LFS objects that don't match the declared size or OID. They can also create links to LFS objects they don't really own, allowing them to read the contents of files if they know just the size or OID. We can close this hole by requiring requests to `upload_finalize` to be sourced from Workhorse. The mechanism to do this already exists. --- GITLAB_WORKHORSE_VERSION | 2 +- app/controllers/projects/lfs_storage_controller.rb | 2 +- ...ity-2767-verify-lfs-finalize-from-workhorse.yml | 5 +++++ spec/requests/lfs_http_spec.rb | 23 +++++++++++++++++----- 4 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 changelogs/unreleased/security-2767-verify-lfs-finalize-from-workhorse.yml diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION index da156181014..0e79152459e 100644 --- a/GITLAB_WORKHORSE_VERSION +++ b/GITLAB_WORKHORSE_VERSION @@ -1 +1 @@ -8.1.0 \ No newline at end of file +8.1.1 diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb index babeee48ef3..013e01b82aa 100644 --- a/app/controllers/projects/lfs_storage_controller.rb +++ b/app/controllers/projects/lfs_storage_controller.rb @@ -5,7 +5,7 @@ class Projects::LfsStorageController < Projects::GitHttpClientController include WorkhorseRequest include SendFileUpload - skip_before_action :verify_workhorse_api!, only: [:download, :upload_finalize] + skip_before_action :verify_workhorse_api!, only: :download def download lfs_object = LfsObject.find_by_oid(oid) diff --git a/changelogs/unreleased/security-2767-verify-lfs-finalize-from-workhorse.yml b/changelogs/unreleased/security-2767-verify-lfs-finalize-from-workhorse.yml new file mode 100644 index 00000000000..e79e3263df7 --- /dev/null +++ b/changelogs/unreleased/security-2767-verify-lfs-finalize-from-workhorse.yml @@ -0,0 +1,5 @@ +--- +title: Verify that LFS upload requests are genuine +merge_request: +author: +type: security diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb index f1514e90eb2..1781759c54b 100644 --- a/spec/requests/lfs_http_spec.rb +++ b/spec/requests/lfs_http_spec.rb @@ -1086,6 +1086,12 @@ describe 'Git LFS API and storage' do end end + context 'and request to finalize the upload is not sent by gitlab-workhorse' do + it 'fails with a JWT decode error' do + expect { put_finalize(lfs_tmp_file, verified: false) }.to raise_error(JWT::DecodeError) + end + end + context 'and workhorse requests upload finalize for a new lfs object' do before do lfs_object.destroy @@ -1347,9 +1353,13 @@ describe 'Git LFS API and storage' do context 'when pushing the same lfs object to the second project' do before do + finalize_headers = headers + .merge('X-Gitlab-Lfs-Tmp' => lfs_tmp_file) + .merge(workhorse_internal_api_request_header) + put "#{second_project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}", params: {}, - headers: headers.merge('X-Gitlab-Lfs-Tmp' => lfs_tmp_file).compact + headers: finalize_headers end it 'responds with status 200' do @@ -1370,7 +1380,7 @@ describe 'Git LFS API and storage' do put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}/authorize", params: {}, headers: authorize_headers end - def put_finalize(lfs_tmp = lfs_tmp_file, with_tempfile: false, args: {}) + def put_finalize(lfs_tmp = lfs_tmp_file, with_tempfile: false, verified: true, args: {}) upload_path = LfsObjectUploader.workhorse_local_upload_path file_path = upload_path + '/' + lfs_tmp if lfs_tmp @@ -1384,11 +1394,14 @@ describe 'Git LFS API and storage' do 'file.name' => File.basename(file_path) } - put_finalize_with_args(args.merge(extra_args).compact) + put_finalize_with_args(args.merge(extra_args).compact, verified: verified) end - def put_finalize_with_args(args) - put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}", params: args, headers: headers + def put_finalize_with_args(args, verified:) + finalize_headers = headers + finalize_headers.merge!(workhorse_internal_api_request_header) if verified + + put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}", params: args, headers: finalize_headers end def lfs_tmp_file -- cgit v1.2.3 From 6d57b2fdb803547ac2bb65911373c311ca94a677 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Tue, 22 Jan 2019 09:38:08 -0800 Subject: Alias GitHub and BitBucket OAuth2 callback URLs To prevent an OAuth2 covert redirect vulnerability, this commit adds and uses an alias for the GitHub and BitBucket OAuth2 callback URLs to the following paths: GitHub: /users/auth/-/import/github Bitbucket: /users/auth/-/import/bitbucket This allows admins to put a more restrictive callback URL in the OAuth2 configuration settings. Instead of https://example.com, admins can now use: https://example.com/users/auth It's possible but not trivial to change Devise and OmniAuth to use a different prefix for callback URLs instead of /users/auth. For now, aliasing the import URLs under the /users/auth namespace should suffice. Closes https://gitlab.com/gitlab-org/gitlab-ce/issues/56663 --- app/controllers/import/bitbucket_controller.rb | 4 ++-- app/controllers/import/github_controller.rb | 2 +- .../unreleased/sh-fix-import-redirect-vulnerability.yml | 5 +++++ config/routes/import.rb | 9 +++++++++ doc/integration/bitbucket.md | 6 +++++- doc/integration/github.md | 6 +++++- spec/controllers/import/bitbucket_controller_spec.rb | 11 +++++++++-- spec/controllers/import/github_controller_spec.rb | 8 +++++++- 8 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 changelogs/unreleased/sh-fix-import-redirect-vulnerability.yml diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 1b30b4dda36..2b1395f364f 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -8,7 +8,7 @@ class Import::BitbucketController < Import::BaseController rescue_from Bitbucket::Error::Unauthorized, with: :bitbucket_unauthorized def callback - response = client.auth_code.get_token(params[:code], redirect_uri: callback_import_bitbucket_url) + response = client.auth_code.get_token(params[:code], redirect_uri: users_import_bitbucket_callback_url) session[:bitbucket_token] = response.token session[:bitbucket_expires_at] = response.expires_at @@ -89,7 +89,7 @@ class Import::BitbucketController < Import::BaseController end def go_to_bitbucket_for_permissions - redirect_to client.auth_code.authorize_url(redirect_uri: callback_import_bitbucket_url) + redirect_to client.auth_code.authorize_url(redirect_uri: users_import_bitbucket_callback_url) end def bitbucket_unauthorized diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 34c7dbdc2fe..3fbc0817e95 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -83,7 +83,7 @@ class Import::GithubController < Import::BaseController end def callback_import_url - public_send("callback_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend + public_send("users_import_#{provider}_callback_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend end def provider_unauthorized diff --git a/changelogs/unreleased/sh-fix-import-redirect-vulnerability.yml b/changelogs/unreleased/sh-fix-import-redirect-vulnerability.yml new file mode 100644 index 00000000000..addf327b69d --- /dev/null +++ b/changelogs/unreleased/sh-fix-import-redirect-vulnerability.yml @@ -0,0 +1,5 @@ +--- +title: Alias GitHub and BitBucket OAuth2 callback URLs +merge_request: +author: +type: security diff --git a/config/routes/import.rb b/config/routes/import.rb index 3998d977c81..69df82611f2 100644 --- a/config/routes/import.rb +++ b/config/routes/import.rb @@ -1,3 +1,12 @@ +# Alias import callbacks under the /users/auth endpoint so that +# the OAuth2 callback URL can be restricted under http://example.com/users/auth +# instead of http://example.com. +Devise.omniauth_providers.each do |provider| + next if provider == 'ldapmain' + + get "/users/auth/-/import/#{provider}/callback", to: "import/#{provider}#callback", as: "users_import_#{provider}_callback" +end + namespace :import do resource :github, only: [:create, :new], controller: :github do post :personal_access_token diff --git a/doc/integration/bitbucket.md b/doc/integration/bitbucket.md index a69db1d1a6e..68ec8c4b5c2 100644 --- a/doc/integration/bitbucket.md +++ b/doc/integration/bitbucket.md @@ -43,9 +43,13 @@ you to use. | :--- | :---------- | | **Name** | This can be anything. Consider something like `'s GitLab` or `'s GitLab` or something else descriptive. | | **Application description** | Fill this in if you wish. | - | **Callback URL** | The URL to your GitLab installation, e.g., `https://gitlab.example.com`. | + | **Callback URL** | The URL to your GitLab installation, e.g., `https://gitlab.example.com/users/auth`. | | **URL** | The URL to your GitLab installation, e.g., `https://gitlab.example.com`. | + NOTE: Be sure to append `/users/auth` to the end of the callback URL + to prevent a [OAuth2 convert + redirect](http://tetraph.com/covert_redirect/) vulnerability. + NOTE: Starting in GitLab 8.15, you MUST specify a callback URL, or you will see an "Invalid redirect_uri" message. For more details, see [the Bitbucket documentation](https://confluence.atlassian.com/bitbucket/oauth-faq-338365710.html). diff --git a/doc/integration/github.md b/doc/integration/github.md index b8156b2b593..eca9aa16499 100644 --- a/doc/integration/github.md +++ b/doc/integration/github.md @@ -21,9 +21,13 @@ To get the credentials (a pair of Client ID and Client Secret), you must registe - Application name: This can be anything. Consider something like `'s GitLab` or `'s GitLab` or something else descriptive. - Homepage URL: the URL to your GitLab installation. e.g., `https://gitlab.company.com` - Application description: Fill this in if you wish. - - Authorization callback URL: `http(s)://${YOUR_DOMAIN}`. Please make sure the port is included if your GitLab instance is not configured on default port. + - Authorization callback URL: `http(s)://${YOUR_DOMAIN}/users/auth`. Please make sure the port is included if your GitLab instance is not configured on default port. ![Register OAuth App](img/github_register_app.png) + NOTE: Be sure to append `/users/auth` to the end of the callback URL + to prevent a [OAuth2 convert + redirect](http://tetraph.com/covert_redirect/) vulnerability. + 1. Select **Register application**. 1. You should now see a pair of **Client ID** and **Client Secret** near the top right of the page (see screenshot). diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb index 51793f2c048..0bc09c86939 100644 --- a/spec/controllers/import/bitbucket_controller_spec.rb +++ b/spec/controllers/import/bitbucket_controller_spec.rb @@ -8,6 +8,7 @@ describe Import::BitbucketController do let(:secret) { "sekrettt" } let(:refresh_token) { SecureRandom.hex(15) } let(:access_params) { { token: token, expires_at: nil, expires_in: nil, refresh_token: nil } } + let(:code) { SecureRandom.hex(8) } def assign_session_tokens session[:bitbucket_token] = token @@ -32,10 +33,16 @@ describe Import::BitbucketController do expires_in: expires_in, refresh_token: refresh_token) allow_any_instance_of(OAuth2::Client) - .to receive(:get_token).and_return(access_token) + .to receive(:get_token) + .with(hash_including( + 'grant_type' => 'authorization_code', + 'code' => code, + redirect_uri: users_import_bitbucket_callback_url), + {}) + .and_return(access_token) stub_omniauth_provider('bitbucket') - get :callback + get :callback, params: { code: code } expect(session[:bitbucket_token]).to eq(token) expect(session[:bitbucket_refresh_token]).to eq(refresh_token) diff --git a/spec/controllers/import/github_controller_spec.rb b/spec/controllers/import/github_controller_spec.rb index 780e49f7b93..bca5f3f6589 100644 --- a/spec/controllers/import/github_controller_spec.rb +++ b/spec/controllers/import/github_controller_spec.rb @@ -12,9 +12,15 @@ describe Import::GithubController do it "redirects to GitHub for an access token if logged in with GitHub" do allow(controller).to receive(:logged_in_with_provider?).and_return(true) - expect(controller).to receive(:go_to_provider_for_permissions) + expect(controller).to receive(:go_to_provider_for_permissions).and_call_original + allow_any_instance_of(Gitlab::LegacyGithubImport::Client) + .to receive(:authorize_url) + .with(users_import_github_callback_url) + .and_call_original get :new + + expect(response).to have_http_status(302) end it "prompts for an access token if GitHub not configured" do -- cgit v1.2.3 From dbbe36ef1b7ea8e415c97ddc6c271f83d9d85364 Mon Sep 17 00:00:00 2001 From: Patrick Galbraith Date: Wed, 23 Jan 2019 06:35:45 +0000 Subject: Update dotNet test task to upload test results on failure --- lib/gitlab/ci/templates/dotNET.gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/gitlab/ci/templates/dotNET.gitlab-ci.yml b/lib/gitlab/ci/templates/dotNET.gitlab-ci.yml index fc3d4ecdbba..25a32ba0f74 100644 --- a/lib/gitlab/ci/templates/dotNET.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/dotNET.gitlab-ci.yml @@ -57,6 +57,7 @@ test_job: script: - '& "$env:NUNIT_PATH" ".\$env:TEST_FOLDER\Tests.dll"' # running NUnit tests artifacts: + when: always # save test results even when the task fails expire_in: 1 week # save gitlab server space, we copy the files we need to deploy folder later on paths: - '.\TestResult.xml' # saving NUnit results to copy to deploy folder -- cgit v1.2.3 From e7aaada259b5dc38ce4b63273ff2b935ad04d539 Mon Sep 17 00:00:00 2001 From: Dennis Tang Date: Tue, 22 Jan 2019 22:10:48 +0000 Subject: Use sanitized user status message for user popover --- .../vue_shared/components/user_popover/user_popover.vue | 8 ++++---- .../unreleased/security-55320-stored-xss-in-user-status.yml | 5 +++++ .../vue_shared/components/user_popover/user_popover_spec.js | 6 +++--- 3 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 changelogs/unreleased/security-55320-stored-xss-in-user-status.yml diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index d24fe1b547e..f9773622001 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -28,10 +28,10 @@ export default { }, computed: { statusHtml() { - if (this.user.status.emoji && this.user.status.message) { - return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message}`; - } else if (this.user.status.message) { - return this.user.status.message; + if (this.user.status.emoji && this.user.status.message_html) { + return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message_html}`; + } else if (this.user.status.message_html) { + return this.user.status.message_html; } return ''; }, diff --git a/changelogs/unreleased/security-55320-stored-xss-in-user-status.yml b/changelogs/unreleased/security-55320-stored-xss-in-user-status.yml new file mode 100644 index 00000000000..8ea9ae0ccdf --- /dev/null +++ b/changelogs/unreleased/security-55320-stored-xss-in-user-status.yml @@ -0,0 +1,5 @@ +--- +title: Use sanitized user status message for user popover +merge_request: +author: +type: security diff --git a/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js index de3e0c149de..e8b41e8eeff 100644 --- a/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js @@ -122,7 +122,7 @@ describe('User Popover Component', () => { describe('status data', () => { it('should show only message', () => { const testProps = Object.assign({}, DEFAULT_PROPS); - testProps.user.status = { message: 'Hello World' }; + testProps.user.status = { message_html: 'Hello World' }; vm = mountComponent(UserPopover, { ...DEFAULT_PROPS, @@ -134,12 +134,12 @@ describe('User Popover Component', () => { it('should show message and emoji', () => { const testProps = Object.assign({}, DEFAULT_PROPS); - testProps.user.status = { emoji: 'basketball_player', message: 'Hello World' }; + testProps.user.status = { emoji: 'basketball_player', message_html: 'Hello World' }; vm = mountComponent(UserPopover, { ...DEFAULT_PROPS, target: document.querySelector('.js-user-link'), - status: { emoji: 'basketball_player', message: 'Hello World' }, + status: { emoji: 'basketball_player', message_html: 'Hello World' }, }); expect(vm.$el.textContent).toContain('Hello World'); -- cgit v1.2.3 From 022f60e80144058bcb4b42b0b3b9e4da130983d0 Mon Sep 17 00:00:00 2001 From: Jan Provaznik Date: Mon, 14 Jan 2019 22:53:37 +0100 Subject: Sent notification only to authorized users When moving a project, it's possible that some users who had access to the project in old path can not access the project in the new path. Because `project_authorizations` records are updated asynchronously, when we send the notification about moved project the list of project team members contains old project members, we want to notify all these members except the old users who can not access the new location. --- app/models/member.rb | 2 ++ app/models/project_team.rb | 12 +++++++++ app/services/notification_service.rb | 3 ++- .../unreleased/security-project-move-users.yml | 5 ++++ spec/models/project_team_spec.rb | 15 +++++++++++ spec/services/notification_service_spec.rb | 29 +++++++++++++++++----- 6 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 changelogs/unreleased/security-project-move-users.yml diff --git a/app/models/member.rb b/app/models/member.rb index 9fc95ea00c3..82d207b79de 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -84,6 +84,8 @@ class Member < ActiveRecord::Base scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) } scope :order_oldest_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'ASC')) } + scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) } + before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } after_create :send_invite, if: :invite?, unless: :importing? diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 33bc6a561f9..aeba2843e5d 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -74,6 +74,14 @@ class ProjectTeam end alias_method :users, :members + # `members` method uses project_authorizations table which + # is updated asynchronously, on project move it still contains + # old members who may not have access to the new location, + # so we filter out only members of project or project's group + def members_in_project_and_ancestors + members.where(id: member_user_ids) + end + def guests @guests ||= fetch_members(Gitlab::Access::GUEST) end @@ -191,4 +199,8 @@ class ProjectTeam def group project.group end + + def member_user_ids + Member.on_project_and_ancestors(project).select(:user_id) + end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index e1cf327209b..1a65561dd70 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -373,7 +373,8 @@ class NotificationService end def project_was_moved(project, old_path_with_namespace) - recipients = notifiable_users(project.team.members, :mention, project: project) + recipients = project.private? ? project.team.members_in_project_and_ancestors : project.team.members + recipients = notifiable_users(recipients, :mention, project: project) recipients.each do |recipient| mailer.project_was_moved_email( diff --git a/changelogs/unreleased/security-project-move-users.yml b/changelogs/unreleased/security-project-move-users.yml new file mode 100644 index 00000000000..744df68651f --- /dev/null +++ b/changelogs/unreleased/security-project-move-users.yml @@ -0,0 +1,5 @@ +--- +title: Notify only users who can access the project on project move. +merge_request: +author: +type: security diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index c4af17f4726..3537dead5d1 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -178,6 +178,21 @@ describe ProjectTeam do end end + describe '#members_in_project_and_ancestors' do + context 'group project' do + it 'filters out users who are not members of the project' do + group = create(:group) + project = create(:project, group: group) + group_member = create(:group_member, group: group) + old_user = create(:user) + + ProjectAuthorization.create!(project: project, user: old_user, access_level: Gitlab::Access::GUEST) + + expect(project.team.members_in_project_and_ancestors).to contain_exactly(group_member.user) + end + end + end + describe "#human_max_access" do it 'returns Maintainer role' do user = create(:user) diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index d20e712d365..6a5a6989607 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -1646,6 +1646,23 @@ describe NotificationService, :mailer do should_not_email(@u_guest_custom) should_not_email(@u_disabled) end + + context 'users not having access to the new location' do + it 'does not send email' do + old_user = create(:user) + ProjectAuthorization.create!(project: project, user: old_user, access_level: Gitlab::Access::GUEST) + + build_group(project) + reset_delivered_emails! + + notification.project_was_moved(project, "gitlab/gitlab") + + should_email(@g_watcher) + should_email(@g_global_watcher) + should_email(project.creator) + should_not_email(old_user) + end + end end context 'user with notifications disabled' do @@ -2232,8 +2249,8 @@ describe NotificationService, :mailer do # Users in the project's group but not part of project's team # with different notification settings - def build_group(project) - group = create_nested_group + def build_group(project, visibility: :public) + group = create_nested_group(visibility) project.update(namespace_id: group.id) # Group member: global=disabled, group=watch @@ -2249,10 +2266,10 @@ describe NotificationService, :mailer do # Creates a nested group only if supported # to avoid errors on MySQL - def create_nested_group + def create_nested_group(visibility) if Group.supports_nested_objects? - parent_group = create(:group, :public) - child_group = create(:group, :public, parent: parent_group) + parent_group = create(:group, visibility) + child_group = create(:group, visibility, parent: parent_group) # Parent group member: global=disabled, parent_group=watch, child_group=global @pg_watcher ||= create_user_with_notification(:watch, 'parent_group_watcher', parent_group) @@ -2272,7 +2289,7 @@ describe NotificationService, :mailer do child_group else - create(:group, :public) + create(:group, visibility) end end -- cgit v1.2.3 From 04d773d82632d08753fc6c10a193ef6178bc54ec Mon Sep 17 00:00:00 2001 From: Steve Azzopardi Date: Tue, 22 Jan 2019 14:37:49 +0100 Subject: Stop showing ci for guest users When a user is a guest user, and the "Public Pipeline" is set to false inside of "Settings > CI/CD > General" the commit status in the project dashboard should not be shown. --- app/views/shared/projects/_project.html.haml | 2 +- .../security-commit-status-shown-for-guest-user.yml | 5 +++++ spec/features/dashboard/projects_spec.rb | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/security-commit-status-shown-for-guest-user.yml diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index fea7e17be3d..e1564d57426 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -84,7 +84,7 @@ title: _('Issues'), data: { container: 'body', placement: 'top' } do = sprite_icon('issues', size: 14, css_class: 'append-right-4') = number_with_delimiter(project.open_issues_count) - - if pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? + - if pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project) %span.icon-wrapper.pipeline-status = render_project_pipeline_status(project.pipeline_status, tooltip_placement: 'top') .updated-note diff --git a/changelogs/unreleased/security-commit-status-shown-for-guest-user.yml b/changelogs/unreleased/security-commit-status-shown-for-guest-user.yml new file mode 100644 index 00000000000..a80170091d0 --- /dev/null +++ b/changelogs/unreleased/security-commit-status-shown-for-guest-user.yml @@ -0,0 +1,5 @@ +--- +title: Fix showing ci status for guest users when public pipline are not set +merge_request: +author: +type: security diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb index edca8f9df08..6c4b04ab76b 100644 --- a/spec/features/dashboard/projects_spec.rb +++ b/spec/features/dashboard/projects_spec.rb @@ -147,6 +147,27 @@ describe 'Dashboard Projects' do expect(page).to have_link('Commit: passed') end end + + context 'guest user of project and project has private pipelines' do + let(:guest_user) { create(:user) } + + before do + project.update(public_builds: false) + project.add_guest(guest_user) + sign_in(guest_user) + end + + it 'shows that the last pipeline passed' do + visit dashboard_projects_path + + page.within('.controls') do + expect(page).not_to have_xpath("//a[@href='#{pipelines_project_commit_path(project, project.commit, ref: pipeline.ref)}']") + expect(page).not_to have_css('.ci-status-link') + expect(page).not_to have_css('.ci-status-icon-success') + expect(page).not_to have_link('Commit: passed') + end + end + end end context 'last push widget', :use_clean_rails_memory_store_caching do -- cgit v1.2.3 From 19f9666abf847efd4a5cb50d38faee77f91ec233 Mon Sep 17 00:00:00 2001 From: James Lopez Date: Wed, 19 Dec 2018 12:49:59 +0100 Subject: Fix tree restorer visibility level --- .../security-import-project-visibility.yml | 5 ++ ...30552_update_project_import_visibility_level.rb | 60 +++++++++++++++ lib/gitlab/import_export/project_tree_restorer.rb | 9 ++- .../import_export/project_tree_restorer_spec.rb | 61 ++++++++++++++- .../update_project_import_visibility_level_spec.rb | 86 ++++++++++++++++++++++ 5 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/security-import-project-visibility.yml create mode 100644 db/post_migrate/20181219130552_update_project_import_visibility_level.rb create mode 100644 spec/migrations/update_project_import_visibility_level_spec.rb diff --git a/changelogs/unreleased/security-import-project-visibility.yml b/changelogs/unreleased/security-import-project-visibility.yml new file mode 100644 index 00000000000..04ae172a9a1 --- /dev/null +++ b/changelogs/unreleased/security-import-project-visibility.yml @@ -0,0 +1,5 @@ +--- +title: Restrict project import visibility based on its group +merge_request: +author: +type: security diff --git a/db/post_migrate/20181219130552_update_project_import_visibility_level.rb b/db/post_migrate/20181219130552_update_project_import_visibility_level.rb new file mode 100644 index 00000000000..6209de88b31 --- /dev/null +++ b/db/post_migrate/20181219130552_update_project_import_visibility_level.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class UpdateProjectImportVisibilityLevel < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + BATCH_SIZE = 100 + + PRIVATE = 0 + INTERNAL = 10 + + disable_ddl_transaction! + + class Namespace < ActiveRecord::Base + self.table_name = 'namespaces' + end + + class Project < ActiveRecord::Base + include EachBatch + + belongs_to :namespace + + IMPORT_TYPE = 'gitlab_project' + + scope :with_group_visibility, ->(visibility) do + joins(:namespace) + .where(namespaces: { type: 'Group', visibility_level: visibility }) + .where(import_type: IMPORT_TYPE) + .where('projects.visibility_level > namespaces.visibility_level') + end + + self.table_name = 'projects' + end + + def up + # Update project's visibility to be the same as the group + # if it is more restrictive than `PUBLIC`. + update_projects_visibility(PRIVATE) + update_projects_visibility(INTERNAL) + end + + def down + # no-op: unrecoverable data migration + end + + private + + def update_projects_visibility(visibility) + say_with_time("Updating project visibility to #{visibility} on #{Project::IMPORT_TYPE} imports.") do + Project.with_group_visibility(visibility).select(:id).each_batch(of: BATCH_SIZE) do |batch, _index| + batch_sql = Gitlab::Database.mysql? ? batch.pluck(:id).join(', ') : batch.select(:id).to_sql + + say("Updating #{batch.size} items.", true) + + execute("UPDATE projects SET visibility_level = '#{visibility}' WHERE id IN (#{batch_sql})") + end + end + end +end diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb index a56ec65b9f1..51001750a6c 100644 --- a/lib/gitlab/import_export/project_tree_restorer.rb +++ b/lib/gitlab/import_export/project_tree_restorer.rb @@ -107,7 +107,7 @@ module Gitlab def project_params @project_params ||= begin - attrs = json_params.merge(override_params) + attrs = json_params.merge(override_params).merge(visibility_level) # Cleaning all imported and overridden params Gitlab::ImportExport::AttributeCleaner.clean(relation_hash: attrs, @@ -127,6 +127,13 @@ module Gitlab end end + def visibility_level + level = override_params['visibility_level'] || json_params['visibility_level'] || @project.visibility_level + level = @project.group.visibility_level if @project.group && level > @project.group.visibility_level + + { 'visibility_level' => level } + end + # Given a relation hash containing one or more models and its relationships, # loops through each model and each object from a model type and # and assigns its correspondent attributes hash from +tree_hash+ diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index 242c16c4bdc..9b0da882390 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -273,6 +273,11 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do it 'has group milestone' do expect(project.group.milestones.size).to eq(results.fetch(:milestones, 0)) end + + it 'has the correct visibility level' do + # INTERNAL in the `project.json`, group's is PRIVATE + expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) + end end context 'Light JSON' do @@ -347,7 +352,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do :issues_disabled, name: 'project', path: 'project', - group: create(:group)) + group: create(:group, visibility_level: Gitlab::VisibilityLevel::PRIVATE)) end before do @@ -434,4 +439,58 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do end end end + + describe '#restored_project' do + let(:project) { create(:project) } + let(:shared) { project.import_export_shared } + let(:tree_hash) { { 'visibility_level' => visibility } } + let(:restorer) { described_class.new(user: nil, shared: shared, project: project) } + + before do + restorer.instance_variable_set(:@tree_hash, tree_hash) + end + + context 'no group visibility' do + let(:visibility) { Gitlab::VisibilityLevel::PRIVATE } + + it 'uses the project visibility' do + expect(restorer.restored_project.visibility_level).to eq(visibility) + end + end + + context 'with group visibility' do + before do + group = create(:group, visibility_level: group_visibility) + + project.update(group: group) + end + + context 'private group visibility' do + let(:group_visibility) { Gitlab::VisibilityLevel::PRIVATE } + let(:visibility) { Gitlab::VisibilityLevel::PUBLIC } + + it 'uses the group visibility' do + expect(restorer.restored_project.visibility_level).to eq(group_visibility) + end + end + + context 'public group visibility' do + let(:group_visibility) { Gitlab::VisibilityLevel::PUBLIC } + let(:visibility) { Gitlab::VisibilityLevel::PRIVATE } + + it 'uses the project visibility' do + expect(restorer.restored_project.visibility_level).to eq(visibility) + end + end + + context 'internal group visibility' do + let(:group_visibility) { Gitlab::VisibilityLevel::INTERNAL } + let(:visibility) { Gitlab::VisibilityLevel::PUBLIC } + + it 'uses the group visibility' do + expect(restorer.restored_project.visibility_level).to eq(group_visibility) + end + end + end + end end diff --git a/spec/migrations/update_project_import_visibility_level_spec.rb b/spec/migrations/update_project_import_visibility_level_spec.rb new file mode 100644 index 00000000000..9ea9b956f67 --- /dev/null +++ b/spec/migrations/update_project_import_visibility_level_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20181219130552_update_project_import_visibility_level.rb') + +describe UpdateProjectImportVisibilityLevel, :migration do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:project) { projects.find_by_name(name) } + + before do + stub_const("#{described_class}::BATCH_SIZE", 1) + end + + context 'private visibility level' do + let(:name) { 'private-public' } + + it 'updates the project visibility' do + create_namespace(name, Gitlab::VisibilityLevel::PRIVATE) + create_project(name, Gitlab::VisibilityLevel::PUBLIC) + + expect { migrate! }.to change { project.reload.visibility_level }.to(Gitlab::VisibilityLevel::PRIVATE) + end + end + + context 'internal visibility level' do + let(:name) { 'internal-public' } + + it 'updates the project visibility' do + create_namespace(name, Gitlab::VisibilityLevel::INTERNAL) + create_project(name, Gitlab::VisibilityLevel::PUBLIC) + + expect { migrate! }.to change { project.reload.visibility_level }.to(Gitlab::VisibilityLevel::INTERNAL) + end + end + + context 'public visibility level' do + let(:name) { 'public-public' } + + it 'does not update the project visibility' do + create_namespace(name, Gitlab::VisibilityLevel::PUBLIC) + create_project(name, Gitlab::VisibilityLevel::PUBLIC) + + expect { migrate! }.not_to change { project.reload.visibility_level } + end + end + + context 'private project visibility level' do + let(:name) { 'public-private' } + + it 'does not update the project visibility' do + create_namespace(name, Gitlab::VisibilityLevel::PUBLIC) + create_project(name, Gitlab::VisibilityLevel::PRIVATE) + + expect { migrate! }.not_to change { project.reload.visibility_level } + end + end + + context 'no namespace' do + let(:name) { 'no-namespace' } + + it 'does not update the project visibility' do + create_namespace(name, Gitlab::VisibilityLevel::PRIVATE, type: nil) + create_project(name, Gitlab::VisibilityLevel::PUBLIC) + + expect { migrate! }.not_to change { project.reload.visibility_level } + end + end + + def create_namespace(name, visibility, options = {}) + namespaces.create({ + name: name, + path: name, + type: 'Group', + visibility_level: visibility + }.merge(options)) + end + + def create_project(name, visibility) + projects.create!(namespace_id: namespaces.find_by_name(name).id, + name: name, + path: name, + import_type: 'gitlab_project', + visibility_level: visibility) + end +end -- cgit v1.2.3 From 446a1da00e9cee53c91b06763f5b3992c21a7c9f Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Tue, 22 Jan 2019 12:49:14 +0000 Subject: Disable git v2 protocol temporarily --- GITALY_SERVER_VERSION | 2 +- changelogs/unreleased/security-2780-disable-git-v2-protocol.yml | 5 +++++ doc/administration/git_protocol.md | 7 +++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/security-2780-disable-git-v2-protocol.yml diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index cd99d386a8d..63e799cf451 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -1.14.0 \ No newline at end of file +1.14.1 diff --git a/changelogs/unreleased/security-2780-disable-git-v2-protocol.yml b/changelogs/unreleased/security-2780-disable-git-v2-protocol.yml new file mode 100644 index 00000000000..30a08a98e83 --- /dev/null +++ b/changelogs/unreleased/security-2780-disable-git-v2-protocol.yml @@ -0,0 +1,5 @@ +--- +title: Disable git v2 protocol temporarily +merge_request: +author: +type: security diff --git a/doc/administration/git_protocol.md b/doc/administration/git_protocol.md index 341a00009e5..11b2adeeeb8 100644 --- a/doc/administration/git_protocol.md +++ b/doc/administration/git_protocol.md @@ -5,6 +5,13 @@ description: "Set and configure Git protocol v2" # Configuring Git Protocol v2 > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/46555) in GitLab 11.4. +> [Temporarily disabled](https://gitlab.com/gitlab-org/gitlab-ce/issues/55769) in GitLab 11.5.8, 11.6.6, 11.7.1, and 11.8+ + +NOTE: **Note:** +Git protocol v2 support has been [temporarily disabled](https://gitlab.com/gitlab-org/gitlab-ce/issues/55769), +as a feature used to hide certain internal references does not function when it +is enabled, and this has a security impact. Once this problem has been resolved, +protocol v2 support will be re-enabled. Git protocol v2 improves the v1 wire protocol in several ways and is enabled by default in GitLab for HTTP requests. In order to enable SSH, -- cgit v1.2.3 From 2dfa614c83ce1a4bfb818d1f040924b4d6e7e0c6 Mon Sep 17 00:00:00 2001 From: Constance Okoghenun Date: Thu, 24 Jan 2019 14:41:42 +0000 Subject: [master] Resolve "[Security] Stored XSS via KaTeX" --- .../unreleased/security-stored-xss-via-katex.yml | 5 +++++ spec/features/markdown/math_spec.rb | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/security-stored-xss-via-katex.yml diff --git a/changelogs/unreleased/security-stored-xss-via-katex.yml b/changelogs/unreleased/security-stored-xss-via-katex.yml new file mode 100644 index 00000000000..a71ae1123f2 --- /dev/null +++ b/changelogs/unreleased/security-stored-xss-via-katex.yml @@ -0,0 +1,5 @@ +--- +title: Fixed XSS content in KaTex links +merge_request: +author: +type: security diff --git a/spec/features/markdown/math_spec.rb b/spec/features/markdown/math_spec.rb index 678ce80b382..53abb5e3722 100644 --- a/spec/features/markdown/math_spec.rb +++ b/spec/features/markdown/math_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe 'Math rendering', :js do + let!(:project) { create(:project, :public) } + it 'renders inline and display math correctly' do description = <<~MATH This math is inline $`a^2+b^2=c^2`$. @@ -11,7 +13,6 @@ describe 'Math rendering', :js do ``` MATH - project = create(:project, :public) issue = create(:issue, project: project, description: description) visit project_issue_path(project, issue) @@ -19,4 +20,19 @@ describe 'Math rendering', :js do expect(page).to have_selector('.katex .mord.mathdefault', text: 'b') expect(page).to have_selector('.katex-display .mord.mathdefault', text: 'b') end + + it 'only renders non XSS links' do + description = <<~MATH + This link is valid $`\\href{javascript:alert('xss');}{xss}`$. + + This link is valid $`\\href{https://gitlab.com}{Gitlab}`$. + MATH + + issue = create(:issue, project: project, description: description) + + visit project_issue_path(project, issue) + + expect(page).to have_selector('.katex-error', text: "\href{javascript:alert('xss');}{xss}") + expect(page).to have_selector('.katex-html a', text: 'Gitlab') + end end -- cgit v1.2.3 From ba0509ed9dfd8db4b0faa1c34e3dc2735fa85aa3 Mon Sep 17 00:00:00 2001 From: Hiroyuki Sato Date: Fri, 25 Jan 2019 10:42:44 +0900 Subject: Make presence_of_commits_in_merge_request private method --- app/models/ci/pipeline.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 09fcdc03577..8881e420735 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -346,12 +346,6 @@ module Ci end end - def presence_of_commits_in_merge_request - if merge_request&.has_no_commits? - errors.add(:merge_request, "must have commits") - end - end - def git_author_name strong_memoize(:git_author_name) do commit.try(:author_name) @@ -757,5 +751,11 @@ module Ci project.repository.keep_around(self.sha, self.before_sha) end + + def presence_of_commits_in_merge_request + if merge_request&.has_no_commits? + errors.add(:merge_request, "must have commits") + end + end end end -- cgit v1.2.3 From 0034119e5094474dc7d283b2e278dff9a37dc232 Mon Sep 17 00:00:00 2001 From: James Lopez Date: Fri, 16 Nov 2018 16:09:32 +0100 Subject: Add subresources removal to member destroy service --- app/controllers/concerns/membership_actions.rb | 4 +- app/helpers/members_helper.rb | 15 +++++- app/models/member.rb | 1 + app/models/members/group_member.rb | 2 + app/models/members/project_member.rb | 4 ++ app/services/members/destroy_service.rb | 28 +++++++++- .../unreleased/fix-security-group-user-removal.yml | 5 ++ .../groups/group_members_controller_spec.rb | 2 +- spec/helpers/members_helper_spec.rb | 4 +- spec/services/members/destroy_service_spec.rb | 60 ++++++++++++++++++++-- 10 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 changelogs/unreleased/fix-security-group-user-removal.yml diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index ca713192c9e..6402e01ddc0 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -35,7 +35,9 @@ module MembershipActions respond_to do |format| format.html do - message = "User was successfully removed from #{source_type}." + source = source_type == 'group' ? 'group and any subresources' : source_type + + message = "User was successfully removed from #{source}." redirect_to members_page_url, notice: message end diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index ab4a1ccc0d1..11d5591d509 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -18,12 +18,13 @@ module MembersHelper "remove #{member.user.name} from" end - "#{text} #{action} the #{member.source.human_name} #{member.real_source_type.humanize(capitalize: false)}?" + "#{text} #{action} the #{member.source.human_name} #{source_text(member)}?" end def remove_member_title(member) action = member.request? ? 'Deny access request' : 'Remove user' - "#{action} from #{member.real_source_type.humanize(capitalize: false)}" + + "#{action} from #{source_text(member)}" end def leave_confirmation_message(member_source) @@ -35,4 +36,14 @@ module MembersHelper options = params.slice(:search, :sort).merge(options).permit! "#{request.path}?#{options.to_param}" end + + private + + def source_text(member) + type = member.real_source_type.humanize(capitalize: false) + + return type if member.request? || member.invite? || type != 'group' + + 'group and any subresources' + end end diff --git a/app/models/member.rb b/app/models/member.rb index 82d207b79de..18667b2e986 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -78,6 +78,7 @@ class Member < ActiveRecord::Base scope :owners, -> { active.where(access_level: OWNER) } scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) } scope :owners_and_masters, -> { owners_and_maintainers } # @deprecated + scope :with_user, -> (user) { where(user: user) } scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) } scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) } diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index fc49ee7ac8c..2c9e1ba1d80 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -12,6 +12,8 @@ class GroupMember < Member validates :source_type, format: { with: /\ANamespace\z/ } default_scope { where(source_type: SOURCE_TYPE) } + scope :in_groups, ->(groups) { where(source_id: groups.select(:id)) } + after_create :update_two_factor_requirement, unless: :invite? after_destroy :update_two_factor_requirement, unless: :invite? diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 016c18ce6c8..5372c6084f4 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -12,6 +12,10 @@ class ProjectMember < Member default_scope { where(source_type: SOURCE_TYPE) } scope :in_project, ->(project) { where(source_id: project.id) } + scope :in_namespaces, ->(groups) do + joins('INNER JOIN projects ON projects.id = members.source_id') + .where('projects.namespace_id in (?)', groups.select(:id)) + end class << self # Add users to projects with passed access option diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index ae0c644e6c0..f9717a9426b 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -2,9 +2,11 @@ module Members class DestroyService < Members::BaseService - def execute(member, skip_authorization: false) + def execute(member, skip_authorization: false, skip_subresources: false) raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_destroy_member?(member) + @skip_auth = skip_authorization + return member if member.is_a?(GroupMember) && member.source.last_owner?(member.user) member.destroy @@ -15,6 +17,7 @@ module Members notification_service.decline_access_request(member) end + delete_subresources(member) unless skip_subresources enqueue_delete_todos(member) after_execute(member: member) @@ -24,6 +27,29 @@ module Members private + def delete_subresources(member) + return unless member.is_a?(GroupMember) && member.user && member.group + + delete_project_members(member) + delete_subgroup_members(member) if Group.supports_nested_objects? + end + + def delete_project_members(member) + groups = member.group.self_and_descendants + + ProjectMember.in_namespaces(groups).with_user(member.user).each do |project_member| + self.class.new(current_user).execute(project_member, skip_authorization: @skip_auth) + end + end + + def delete_subgroup_members(member) + groups = member.group.descendants + + GroupMember.in_groups(groups).with_user(member.user).each do |group_member| + self.class.new(current_user).execute(group_member, skip_authorization: @skip_auth, skip_subresources: true) + end + end + def can_destroy_member?(member) can?(current_user, destroy_member_permission(member), member) end diff --git a/changelogs/unreleased/fix-security-group-user-removal.yml b/changelogs/unreleased/fix-security-group-user-removal.yml new file mode 100644 index 00000000000..09d09a96f84 --- /dev/null +++ b/changelogs/unreleased/fix-security-group-user-removal.yml @@ -0,0 +1,5 @@ +--- +title: Add subresources removal to member destroy service +merge_request: +author: +type: security diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb index ed38dadfd6b..3a801fabafc 100644 --- a/spec/controllers/groups/group_members_controller_spec.rb +++ b/spec/controllers/groups/group_members_controller_spec.rb @@ -126,7 +126,7 @@ describe Groups::GroupMembersController do it '[HTML] removes user from members' do delete :destroy, params: { group_id: group, id: member } - expect(response).to set_flash.to 'User was successfully removed from group.' + expect(response).to set_flash.to 'User was successfully removed from group and any subresources.' expect(response).to redirect_to(group_group_members_path(group)) expect(group.members).not_to include member end diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb index 4590904c93d..908e8960f37 100644 --- a/spec/helpers/members_helper_spec.rb +++ b/spec/helpers/members_helper_spec.rb @@ -16,7 +16,7 @@ describe MembersHelper do it { expect(remove_member_message(project_member_invite)).to eq "Are you sure you want to revoke the invitation for #{project_member_invite.invite_email} to join the #{project.full_name} project?" } it { expect(remove_member_message(project_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{project.full_name} project?" } it { expect(remove_member_message(project_member_request, user: requester)).to eq "Are you sure you want to withdraw your access request for the #{project.full_name} project?" } - it { expect(remove_member_message(group_member)).to eq "Are you sure you want to remove #{group_member.user.name} from the #{group.name} group?" } + it { expect(remove_member_message(group_member)).to eq "Are you sure you want to remove #{group_member.user.name} from the #{group.name} group and any subresources?" } it { expect(remove_member_message(group_member_invite)).to eq "Are you sure you want to revoke the invitation for #{group_member_invite.invite_email} to join the #{group.name} group?" } it { expect(remove_member_message(group_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{group.name} group?" } it { expect(remove_member_message(group_member_request, user: requester)).to eq "Are you sure you want to withdraw your access request for the #{group.name} group?" } @@ -33,7 +33,7 @@ describe MembersHelper do it { expect(remove_member_title(project_member)).to eq 'Remove user from project' } it { expect(remove_member_title(project_member_request)).to eq 'Deny access request from project' } - it { expect(remove_member_title(group_member)).to eq 'Remove user from group' } + it { expect(remove_member_title(group_member)).to eq 'Remove user from group and any subresources' } it { expect(remove_member_title(group_member_request)).to eq 'Deny access request from group' } end diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb index 5aa7165e135..e872a537761 100644 --- a/spec/services/members/destroy_service_spec.rb +++ b/spec/services/members/destroy_service_spec.rb @@ -69,14 +69,14 @@ describe Members::DestroyService do it 'calls Member#after_decline_request' do expect_any_instance_of(NotificationService).to receive(:decline_access_request).with(member) - described_class.new(current_user).execute(member) + described_class.new(current_user).execute(member, opts) end context 'when current user is the member' do it 'does not call Member#after_decline_request' do expect_any_instance_of(NotificationService).not_to receive(:decline_access_request).with(member) - described_class.new(member_user).execute(member) + described_class.new(member_user).execute(member, opts) end end end @@ -159,7 +159,7 @@ describe Members::DestroyService do end it_behaves_like 'a service destroying a member' do - let(:opts) { { skip_authorization: true } } + let(:opts) { { skip_authorization: true, skip_subresources: true } } let(:member) { group_project.requesters.find_by(user_id: member_user.id) } end @@ -168,12 +168,14 @@ describe Members::DestroyService do end it_behaves_like 'a service destroying a member' do - let(:opts) { { skip_authorization: true } } + let(:opts) { { skip_authorization: true, skip_subresources: true } } let(:member) { group.requesters.find_by(user_id: member_user.id) } end end context 'when current user can destroy the given access requester' do + let(:opts) { { skip_subresources: true } } + before do group_project.add_maintainer(current_user) group.add_owner(current_user) @@ -229,4 +231,54 @@ describe Members::DestroyService do end end end + + context 'subresources' do + let(:user) { create(:user) } + let(:member_user) { create(:user) } + let(:opts) { {} } + + let(:group) { create(:group, :public) } + let(:subgroup) { create(:group, parent: group) } + let(:subsubgroup) { create(:group, parent: subgroup) } + let(:subsubproject) { create(:project, group: subsubgroup) } + + let(:group_project) { create(:project, :public, group: group) } + let(:control_project) { create(:project, group: subsubgroup) } + + before do + create(:group_member, :developer, group: subsubgroup, user: member_user) + + subsubproject.add_developer(member_user) + control_project.add_maintainer(user) + group.add_owner(user) + + group_member = create(:group_member, :developer, group: group, user: member_user) + + described_class.new(user).execute(group_member, opts) + end + + it 'removes the project membership' do + expect(group_project.members.map(&:user)).not_to include(member_user) + end + + it 'removes the group membership' do + expect(group.members.map(&:user)).not_to include(member_user) + end + + it 'removes the subgroup membership', :postgresql do + expect(subgroup.members.map(&:user)).not_to include(member_user) + end + + it 'removes the subsubgroup membership', :postgresql do + expect(subsubgroup.members.map(&:user)).not_to include(member_user) + end + + it 'removes the subsubproject membership', :postgresql do + expect(subsubproject.members.map(&:user)).not_to include(member_user) + end + + it 'does not remove the user from the control project' do + expect(control_project.members.map(&:user)).to include(user) + end + end end -- cgit v1.2.3 From 6bd8e4cb867712326e658d11d4c530a350a5eefb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20L=C3=B3pez?= Date: Fri, 25 Jan 2019 12:11:36 +0000 Subject: [master] Check access rights when creating/updating ProtectedRefs --- app/services/protected_branches/api_service.rb | 8 -------- spec/lib/gitlab/git_access_spec.rb | 23 ++++++++--------------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/app/services/protected_branches/api_service.rb b/app/services/protected_branches/api_service.rb index 4340d3e8260..9b85e13107b 100644 --- a/app/services/protected_branches/api_service.rb +++ b/app/services/protected_branches/api_service.rb @@ -6,8 +6,6 @@ module ProtectedBranches @push_params = AccessLevelParams.new(:push, params) @merge_params = AccessLevelParams.new(:merge, params) - verify_params! - protected_branch_params = { name: params[:name], push_access_levels_attributes: @push_params.access_levels, @@ -16,11 +14,5 @@ module ProtectedBranches ::ProtectedBranches::CreateService.new(@project, @current_user, protected_branch_params).execute end - - private - - def verify_params! - # EE-only - end end end diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index 3e34dd592f2..634c370d211 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -776,10 +776,13 @@ describe Gitlab::GitAccess do it "has the correct permissions for #{role}s" do if role == :admin user.update_attribute(:admin, true) + project.add_guest(user) else project.add_role(user, role) end + protected_branch.save + aggregate_failures do matrix.each do |action, allowed| check = -> { push_changes(changes[action]) } @@ -861,25 +864,19 @@ describe Gitlab::GitAccess do [%w(feature exact), ['feat*', 'wildcard']].each do |protected_branch_name, protected_branch_type| context do - before do - create(:protected_branch, name: protected_branch_name, project: project) - end + let(:protected_branch) { create(:protected_branch, :maintainers_can_push, name: protected_branch_name, project: project) } run_permission_checks(permissions_matrix) end context "when developers are allowed to push into the #{protected_branch_type} protected branch" do - before do - create(:protected_branch, :developers_can_push, name: protected_branch_name, project: project) - end + let(:protected_branch) { create(:protected_branch, :developers_can_push, name: protected_branch_name, project: project) } run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true })) end context "developers are allowed to merge into the #{protected_branch_type} protected branch" do - before do - create(:protected_branch, :developers_can_merge, name: protected_branch_name, project: project) - end + let(:protected_branch) { create(:protected_branch, :developers_can_merge, name: protected_branch_name, project: project) } context "when a merge request exists for the given source/target branch" do context "when the merge request is in progress" do @@ -906,17 +903,13 @@ describe Gitlab::GitAccess do end context "when developers are allowed to push and merge into the #{protected_branch_type} protected branch" do - before do - create(:protected_branch, :developers_can_merge, :developers_can_push, name: protected_branch_name, project: project) - end + let(:protected_branch) { create(:protected_branch, :developers_can_merge, :developers_can_push, name: protected_branch_name, project: project) } run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: true, push_all: true, merge_into_protected_branch: true })) end context "when no one is allowed to push to the #{protected_branch_name} protected branch" do - before do - create(:protected_branch, :no_one_can_push, name: protected_branch_name, project: project) - end + let(:protected_branch) { build(:protected_branch, :no_one_can_push, name: protected_branch_name, project: project) } run_permission_checks(permissions_matrix.deep_merge(developer: { push_protected_branch: false, push_all: false, merge_into_protected_branch: false }, maintainer: { push_protected_branch: false, push_all: false, merge_into_protected_branch: false }, -- cgit v1.2.3 From b57cf4ae3ff3f39c69f6076f4bd10dafb4a238ea Mon Sep 17 00:00:00 2001 From: Imre Farkas Date: Sun, 27 Jan 2019 22:12:00 +0100 Subject: Backport of ee/9235: Add LDAP integration to smartcard authentication --- app/views/devise/sessions/_new_ldap.html.haml | 2 ++ lib/gitlab/auth/ldap/adapter.rb | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml index 796c0cadda8..f856773526d 100644 --- a/app/views/devise/sessions/_new_ldap.html.haml +++ b/app/views/devise/sessions/_new_ldap.html.haml @@ -1,3 +1,5 @@ +- server = local_assigns.fetch(:server) + = form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do .form-group = label_tag :username, "#{server['label']} Username" diff --git a/lib/gitlab/auth/ldap/adapter.rb b/lib/gitlab/auth/ldap/adapter.rb index 42c657afe6a..15b9d5ad6e9 100644 --- a/lib/gitlab/auth/ldap/adapter.rb +++ b/lib/gitlab/auth/ldap/adapter.rb @@ -30,14 +30,7 @@ module Gitlab def users(fields, value, limit = nil) options = user_options(Array(fields), value, limit) - - entries = ldap_search(options).select do |entry| - entry.respond_to? config.uid - end - - entries.map do |entry| - Gitlab::Auth::LDAP::Person.new(entry, provider) - end + users_search(options) end def user(*args) @@ -90,6 +83,16 @@ module Gitlab SEARCH_RETRY_FACTOR[retry_number] * config.timeout end + def users_search(options) + entries = ldap_search(options).select do |entry| + entry.respond_to? config.uid + end + + entries.map do |entry| + Gitlab::Auth::LDAP::Person.new(entry, provider) + end + end + def user_options(fields, value, limit) options = { attributes: Gitlab::Auth::LDAP::Person.ldap_attributes(config), -- cgit v1.2.3 From 74946c19b4056052da4f5a9059ae73b2c0771d03 Mon Sep 17 00:00:00 2001 From: Hiroyuki Sato Date: Mon, 28 Jan 2019 14:54:33 +0900 Subject: Move validation logic to service layer --- app/models/ci/pipeline.rb | 7 ------- app/services/merge_requests/base_service.rb | 1 + spec/models/ci/pipeline_spec.rb | 13 +++---------- spec/models/merge_request_spec.rb | 2 +- spec/services/ci/create_pipeline_service_spec.rb | 9 --------- spec/services/merge_requests/create_service_spec.rb | 18 ++++++++++++++++++ spec/services/merge_requests/refresh_service_spec.rb | 6 +++--- 7 files changed, 26 insertions(+), 30 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 8881e420735..30a957b4117 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -54,7 +54,6 @@ module Ci validates :ref, presence: { unless: :importing? } validates :merge_request, presence: { if: :merge_request? } validates :merge_request, absence: { unless: :merge_request? } - validate :presence_of_commits_in_merge_request, if: :merge_request? validates :tag, inclusion: { in: [false], if: :merge_request? } validates :status, presence: { unless: :importing? } validate :valid_commit_sha, unless: :importing? @@ -751,11 +750,5 @@ module Ci project.repository.keep_around(self.sha, self.before_sha) end - - def presence_of_commits_in_merge_request - if merge_request&.has_no_commits? - errors.add(:merge_request, "must have commits") - end - end end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index fe19abf50f6..ac51fee0b3f 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -63,6 +63,7 @@ module MergeRequests # UpdateMergeRequestsWorker could be retried by an exception. # MR pipelines should not be recreated in such case. return if merge_request.merge_request_pipeline_exists? + return if merge_request.has_no_commits? Ci::CreatePipelineService .new(merge_request.source_project, user, ref: merge_request.source_branch) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 2869022f2f6..17f33785fda 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Ci::Pipeline, :mailer do let(:user) { create(:user) } - set(:project) { create(:project, :repository) } + set(:project) { create(:project) } let(:pipeline) do create(:ci_empty_pipeline, status: :created, project: project) @@ -131,18 +131,12 @@ describe Ci::Pipeline, :mailer do context 'when source is merge request' do let(:source) { :merge_request } - context 'when merge request with commits is specified' do + context 'when merge request is specified' do let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_project: project, target_branch: 'master') } it { expect(pipeline).to be_valid } end - context 'when merge request without commits is specified' do - let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'not-merged-branch', target_project: project, target_branch: 'master') } - - it { expect(pipeline).not_to be_valid } - end - context 'when merge request is empty' do let(:merge_request) { nil } @@ -1042,8 +1036,6 @@ describe Ci::Pipeline, :mailer do end context 'when repository does not exist' do - let(:project) { create(:project) } - let(:pipeline) do create(:ci_empty_pipeline, project: project, ref: 'master') end @@ -2079,6 +2071,7 @@ describe Ci::Pipeline, :mailer do end describe "#all_merge_requests" do + let(:project) { create(:project) } let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: 'master') } it "returns all merge requests having the same source branch" do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index c9643b34fa0..bfc9035cb56 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1378,7 +1378,7 @@ describe MergeRequest do source_project: project, source_branch: source_ref, target_project: project, - target_branch: 'merge-test') + target_branch: 'stable') end before do diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index be6bdc21b81..8497e90bd8b 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -736,15 +736,6 @@ describe Ci::CreatePipelineService do end end - context 'when there are no commits between source branch and target branch' do - let(:ref_name) { 'refs/heads/not-merged-branch' } - - it 'does not create a merge request pipeline' do - expect(pipeline).not_to be_persisted - expect(pipeline.errors[:merge_request]).to eq(["must have commits"]) - end - end - context 'when merge request is created from a forked project' do let(:merge_request) do create(:merge_request, diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index 308f99dc0da..723cc860d0c 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -226,6 +226,24 @@ describe MergeRequests::CreateService do end end + context 'when there are no commits between source branch and target branch' do + let(:opts) do + { + title: 'Awesome merge_request', + description: 'please fix', + source_branch: 'not-merged-branch', + target_branch: 'master' + } + end + + it 'does not create a merge request pipeline' do + expect(merge_request).to be_persisted + + merge_request.reload + expect(merge_request.merge_request_pipelines.count).to eq(0) + end + end + context "when .gitlab-ci.yml does not have merge_requests keywords" do let(:config) do { diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 57b8e71c2d5..9e9dc5a576c 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -156,9 +156,9 @@ describe MergeRequests::RefreshService do .and change { @fork_merge_request.merge_request_pipelines.count }.by(1) .and change { @another_merge_request.merge_request_pipelines.count }.by(0) - expect(@merge_request.has_commits?).to be true - expect(@fork_merge_request.has_commits?).to be true - expect(@another_merge_request.has_commits?).to be false + expect(@merge_request.has_commits?).to be_truthy + expect(@fork_merge_request.has_commits?).to be_truthy + expect(@another_merge_request.has_commits?).to be_falsy end context "when branch pipeline was created before a merge request pipline has been created" do -- cgit v1.2.3 From e4db1a615b9f30574c063bda9f6183654f74586e Mon Sep 17 00:00:00 2001 From: Marin Jankovski Date: Mon, 28 Jan 2019 12:21:42 +0100 Subject: Create security release MR template Improve existing issue templates for security releases --- .gitlab/issue_templates/Security Release.md | 24 +++++++++---------- .../issue_templates/Security developer workflow.md | 13 ++++------ .../merge_request_templates/Security Release.md | 28 ++++++++++++++++++++++ bin/secpick | 4 ++-- 4 files changed, 47 insertions(+), 22 deletions(-) create mode 100644 .gitlab/merge_request_templates/Security Release.md diff --git a/.gitlab/issue_templates/Security Release.md b/.gitlab/issue_templates/Security Release.md index 1734e915ad2..ae469d3b125 100644 --- a/.gitlab/issue_templates/Security Release.md +++ b/.gitlab/issue_templates/Security Release.md @@ -32,12 +32,12 @@ Set the title to: `Security Release: 11.4.X, 11.3.X, and 11.2.X` - {https://dev.gitlab.org/gitlab/gitlabhq/issues link} -| Version | MR | Status| -|---------|----|-------| -| 11.4 | {https://dev.gitlab.org/gitlab/gitlabhq/merge_requests/ link} | | -| 11.3 | {https://dev.gitlab.org/gitlab/gitlabhq/merge_requests/ link} | | -| 11.2 | {https://dev.gitlab.org/gitlab/gitlabhq/merge_requests/ link} | | -| master | {https://dev.gitlab.org/gitlab/gitlabhq/merge_requests/ link} | | +| Version | MR | +|---------|----| +| 11.4 | {https://dev.gitlab.org/gitlab/gitlabhq/merge_requests/ link} | +| 11.3 | {https://dev.gitlab.org/gitlab/gitlabhq/merge_requests/ link} | +| 11.2 | {https://dev.gitlab.org/gitlab/gitlabhq/merge_requests/ link} | +| master | {https://dev.gitlab.org/gitlab/gitlabhq/merge_requests/ link} | @@ -46,12 +46,12 @@ Set the title to: `Security Release: 11.4.X, 11.3.X, and 11.2.X` * {https://dev.gitlab.org/gitlab/gitlabhq/issues/ link} -| Version | MR | Status| -|---------|----|-------| -| 11.4| {https://dev.gitlab.org/gitlab/gitlab-ee/merge_requests/ link} | | -| 11.3 | {https://dev.gitlab.org/gitlab/gitlab-ee/merge_requests/ link} | | -| 11.2 | {https://dev.gitlab.org/gitlab/gitlab-ee/merge_requests/ link} | | -| master | {https://dev.gitlab.org/gitlab/gitlab-ee/merge_requests/ link} | | +| Version | MR | +|---------|----| +| 11.4| {https://dev.gitlab.org/gitlab/gitlab-ee/merge_requests/ link} | +| 11.3 | {https://dev.gitlab.org/gitlab/gitlab-ee/merge_requests/ link} | +| 11.2 | {https://dev.gitlab.org/gitlab/gitlab-ee/merge_requests/ link} | +| master | {https://dev.gitlab.org/gitlab/gitlab-ee/merge_requests/ link} | ## QA diff --git a/.gitlab/issue_templates/Security developer workflow.md b/.gitlab/issue_templates/Security developer workflow.md index f9bf700f809..4bc4215d21b 100644 --- a/.gitlab/issue_templates/Security developer workflow.md +++ b/.gitlab/issue_templates/Security developer workflow.md @@ -3,20 +3,17 @@ Create this issue under https://dev.gitlab.org/gitlab/gitlabhq -Set the title to: `[Security] Description of the original issue` +Set the title to: `Description of the original issue` --> -### Prior to the security release +### Prior to starting the security release work - [ ] Read the [security process for developers] if you are not familiar with it. - [ ] Link to the original issue adding it to the [links section](#links) - [ ] Run `scripts/security-harness` in the CE, EE, and/or Omnibus to prevent pushing to any remote besides `dev.gitlab.org` -- [ ] Create an MR targetting `org` `master`, prefixing your branch with `security-` -- [ ] Label your MR with the ~security label, prefix the title with `WIP: [master]` -- [ ] Add a link to the MR to the [links section](#links) -- [ ] Add a link to an EE MR if required -- [ ] Make sure the MR remains in-progress and gets approved after the review cycle, **but never merged**. -- [ ] Add a link to this issue on the original security issue. +- [ ] Create a new branch prefixing it with `security-` +- [ ] Create a MR targeting `dev.gitlab.org` `master` +- [ ] Add a link to this issue in the original security issue on `gitlab.com`. #### Backports diff --git a/.gitlab/merge_request_templates/Security Release.md b/.gitlab/merge_request_templates/Security Release.md new file mode 100644 index 00000000000..df760052ff4 --- /dev/null +++ b/.gitlab/merge_request_templates/Security Release.md @@ -0,0 +1,28 @@ + +## Related issues + + + +## Author's checklist + +- [ ] Link to the developer security workflow issue on `dev.gitlab.org` +- [ ] MR targets `master` or `security-X-Y` for backports +- [ ] Milestone is set for the version this MR applies to +- [ ] Title of this MR is the same as for all backports +- [ ] A [CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html) is added without a `merge_request` value, with `type` set to `security` +- [ ] Add a link this MR in the `links` section of related issue +- [ ] Add a link to an EE MR if required +- [ ] Assign to a reviewer + +## Reviewers checklist + +- [ ] Correct milestone is applied and the title is matching across all backports +- [ ] Assigned to `@gitlab-release-tools-bot` with passing CI pipelines + +/label ~security ~"Merge into Security" diff --git a/bin/secpick b/bin/secpick index 3d032f696a2..be120a304c9 100755 --- a/bin/secpick +++ b/bin/secpick @@ -57,8 +57,8 @@ module Secpick merge_request: { source_branch: source_branch, target_branch: security_branch, - title: "WIP: [#{@options[:version].tr('-', '.')}] ", - description: '/label ~security' + title: "[#{@options[:version].tr('-', '.')}] ", + description: '/label ~security ~"Merge into Security"' } } end -- cgit v1.2.3 From a0383ab43ec2c885aae6602ffa47ffde79c76786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Trzci=C5=84ski?= Date: Mon, 28 Jan 2019 12:12:30 +0000 Subject: [master] Pipelines section is available to unauthorized users --- .../merge_requests/application_controller.rb | 9 ++-- app/controllers/projects/pipelines_controller.rb | 1 + app/helpers/projects_helper.rb | 3 +- app/models/commit.rb | 5 +- app/models/project.rb | 8 ++++ app/policies/ci/pipeline_policy.rb | 9 ++++ app/policies/project_policy.rb | 20 ++++++-- app/presenters/commit_presenter.rb | 13 ++++++ app/presenters/merge_request_presenter.rb | 4 ++ app/serializers/merge_request_widget_entity.rb | 2 +- app/views/projects/commit/_ci_menu.html.haml | 4 +- app/views/projects/commit/_commit_box.html.haml | 4 +- app/views/projects/commit/show.html.haml | 5 +- app/views/projects/commits/_commit.html.haml | 5 +- .../projects/issues/_merge_requests.html.haml | 3 +- .../projects/issues/_related_branches.html.haml | 2 +- .../merge_requests/_merge_request.html.haml | 2 +- app/views/projects/pipelines/_info.html.haml | 33 +++++++------ changelogs/unreleased/test-permissions.yml | 5 ++ lib/api/pipelines.rb | 6 +-- .../projects/pipeline_schedules_controller_spec.rb | 11 ++++- .../projects/pipelines_controller_spec.rb | 47 +++++++++++++------ .../security/project/internal_access_spec.rb | 6 +-- .../security/project/private_access_spec.rb | 2 +- .../security/project/public_access_spec.rb | 10 ++-- spec/helpers/projects_helper_spec.rb | 16 ++++++- .../import_export/project_tree_restorer_spec.rb | 4 +- spec/models/commit_spec.rb | 1 + spec/models/project_spec.rb | 24 ++++++++++ spec/policies/ci/pipeline_policy_spec.rb | 8 ++++ spec/policies/project_policy_spec.rb | 44 +++++++++++++----- spec/presenters/commit_presenter_spec.rb | 54 ++++++++++++++++++++++ .../merge_request_widget_entity_spec.rb | 39 +++++++++++----- .../projects/commit/_commit_box.html.haml_spec.rb | 6 ++- .../issues/_related_branches.html.haml_spec.rb | 4 ++ 35 files changed, 324 insertions(+), 95 deletions(-) create mode 100644 app/presenters/commit_presenter.rb create mode 100644 changelogs/unreleased/test-permissions.yml create mode 100644 spec/presenters/commit_presenter_spec.rb diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 368ee89ff5c..54ff7ded8e5 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -39,8 +39,11 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont end def set_pipeline_variables - @pipelines = @merge_request.all_pipelines - @pipeline = @merge_request.head_pipeline - @statuses_count = @pipeline.present? ? @pipeline.statuses.relevant.count : 0 + @pipelines = + if can?(current_user, :read_pipeline, @project) + @merge_request.all_pipelines + else + Ci::Pipeline.none + end end end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 67827b1d3bb..df43d9994a1 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -4,6 +4,7 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :whitelist_query_limiting, only: [:create, :retry] before_action :pipeline, except: [:index, :new, :create, :charts] before_action :authorize_read_pipeline! + before_action :authorize_read_build!, only: [:index] before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index b0d3d509f5d..85248a16f50 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -305,7 +305,8 @@ module ProjectsHelper nav_tabs << :container_registry end - if project.builds_enabled? && can?(current_user, :read_pipeline, project) + # Pipelines feature is tied to presence of builds + if can?(current_user, :read_build, project) nav_tabs << :pipelines end diff --git a/app/models/commit.rb b/app/models/commit.rb index 01f4c58daa1..982e13e2845 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -11,6 +11,7 @@ class Commit include Mentionable include Referable include StaticModel + include Presentable include ::Gitlab::Utils::StrongMemoize attr_mentionable :safe_message, pipeline: :single_line @@ -304,7 +305,9 @@ class Commit end def last_pipeline - @last_pipeline ||= pipelines.last + strong_memoize(:last_pipeline) do + pipelines.last + end end def status(ref = nil) diff --git a/app/models/project.rb b/app/models/project.rb index 1023b40a608..cecfee82334 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -578,6 +578,14 @@ class Project < ActiveRecord::Base end end + def all_pipelines + if builds_enabled? + super + else + super.external + end + end + # returns all ancestor-groups upto but excluding the given namespace # when no namespace is given, all ancestors upto the top are returned def ancestors_upto(top = nil, hierarchy_order: nil) diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index e42d78f47c5..2c90b8a73cd 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -10,6 +10,15 @@ module Ci @subject.project.branch_allows_collaboration?(@user, @subject.ref) end + condition(:external_pipeline, scope: :subject, score: 0) do + @subject.external? + end + + # Disallow users without permissions from accessing internal pipelines + rule { ~can?(:read_build) & ~external_pipeline }.policy do + prevent :read_pipeline + end + rule { protected_ref }.prevent :update_pipeline rule { can?(:public_access) & branch_allows_collaboration }.policy do diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 95ae85ed60c..cadbc5ae009 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -108,6 +108,10 @@ class ProjectPolicy < BasePolicy condition(:has_clusters, scope: :subject) { clusterable_has_clusters? } condition(:can_have_multiple_clusters) { multiple_clusters_available? } + condition(:internal_builds_disabled) do + !@subject.builds_enabled? + end + features = %w[ merge_requests issues @@ -196,7 +200,6 @@ class ProjectPolicy < BasePolicy enable :read_build enable :read_container_image enable :read_pipeline - enable :read_pipeline_schedule enable :read_environment enable :read_deployment enable :read_merge_request @@ -235,6 +238,7 @@ class ProjectPolicy < BasePolicy enable :update_build enable :create_pipeline enable :update_pipeline + enable :read_pipeline_schedule enable :create_pipeline_schedule enable :create_merge_request_from enable :create_wiki @@ -320,7 +324,6 @@ class ProjectPolicy < BasePolicy end rule { builds_disabled | repository_disabled }.policy do - prevent(*create_update_admin_destroy(:pipeline)) prevent(*create_read_update_admin_destroy(:build)) prevent(*create_read_update_admin_destroy(:pipeline_schedule)) prevent(*create_read_update_admin_destroy(:environment)) @@ -328,11 +331,22 @@ class ProjectPolicy < BasePolicy prevent(*create_read_update_admin_destroy(:deployment)) end + # There's two separate cases when builds_disabled is true: + # 1. When internal CI is disabled - builds_disabled && internal_builds_disabled + # - We do not prevent the user from accessing Pipelines to allow him to access external CI + # 2. When the user is not allowed to access CI - builds_disabled && ~internal_builds_disabled + # - We prevent the user from accessing Pipelines + rule { (builds_disabled & ~internal_builds_disabled) | repository_disabled }.policy do + prevent(*create_read_update_admin_destroy(:pipeline)) + prevent(*create_read_update_admin_destroy(:commit_status)) + end + rule { repository_disabled }.policy do prevent :push_code prevent :download_code prevent :fork_project prevent :read_commit_status + prevent :read_pipeline prevent(*create_read_update_admin_destroy(:release)) end @@ -359,7 +373,6 @@ class ProjectPolicy < BasePolicy enable :read_merge_request enable :read_note enable :read_pipeline - enable :read_pipeline_schedule enable :read_commit_status enable :read_container_image enable :download_code @@ -378,7 +391,6 @@ class ProjectPolicy < BasePolicy rule { public_builds & can?(:guest_access) }.policy do enable :read_pipeline - enable :read_pipeline_schedule end # These rules are included to allow maintainers of projects to push to certain diff --git a/app/presenters/commit_presenter.rb b/app/presenters/commit_presenter.rb new file mode 100644 index 00000000000..05adbe1d4f5 --- /dev/null +++ b/app/presenters/commit_presenter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CommitPresenter < Gitlab::View::Presenter::Simple + presents :commit + + def status_for(ref) + can?(current_user, :read_commit_status, commit.project) && commit.status(ref) + end + + def any_pipelines? + can?(current_user, :read_pipeline, commit.project) && commit.pipelines.any? + end +end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 44b6ca299ae..c59e73f824c 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -170,6 +170,10 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated source_branch_exists? && merge_request.can_remove_source_branch?(current_user) end + def can_read_pipeline? + pipeline && can?(current_user, :read_pipeline, pipeline) + end + def mergeable_discussions_state # This avoids calling MergeRequest#mergeable_discussions_state without # considering the state of the MR first. If a MR isn't mergeable, we can diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 9361c9f987b..f42abf06e1e 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -57,7 +57,7 @@ class MergeRequestWidgetEntity < IssuableEntity end expose :merge_commit_message - expose :actual_head_pipeline, with: PipelineDetailsEntity, as: :pipeline + expose :actual_head_pipeline, with: PipelineDetailsEntity, as: :pipeline, if: -> (mr, _) { presenter(mr).can_read_pipeline? } expose :merge_pipeline, with: PipelineDetailsEntity, if: ->(mr, _) { mr.merged? && can?(request.current_user, :read_pipeline, mr.target_project)} # Booleans diff --git a/app/views/projects/commit/_ci_menu.html.haml b/app/views/projects/commit/_ci_menu.html.haml index f6666921a25..8b6e3e42ea1 100644 --- a/app/views/projects/commit/_ci_menu.html.haml +++ b/app/views/projects/commit/_ci_menu.html.haml @@ -1,9 +1,11 @@ +- any_pipelines = @commit.present(current_user: current_user).any_pipelines? + %ul.nav-links.no-top.no-bottom.commit-ci-menu.nav.nav-tabs = nav_link(path: 'commit#show') do = link_to project_commit_path(@project, @commit.id) do Changes %span.badge.badge-pill= @diffs.size - - if can?(current_user, :read_pipeline, @project) + - if any_pipelines = nav_link(path: 'commit#pipelines') do = link_to pipelines_project_commit_path(@project, @commit.id) do Pipelines diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 2a919a767c0..3971ca473e0 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -74,8 +74,8 @@ %span.commit-info.merge-requests{ 'data-project-commit-path' => merge_requests_project_commit_path(@project, @commit.id, format: :json) } = icon('spinner spin') - - if @commit.last_pipeline - - last_pipeline = @commit.last_pipeline + - last_pipeline = @commit.last_pipeline + - if can?(current_user, :read_pipeline, last_pipeline) .well-segment.pipeline-info .status-icon-container = link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index 79e32949db9..06f0cd9675e 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -9,10 +9,7 @@ .container-fluid{ class: [limited_container_width, container_class] } = render "commit_box" - - if @commit.status - = render "ci_menu" - - else - .block-connector + = render "ci_menu" = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, is_commit: true .limited-width-notes diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 1a74b120c26..0d3c6e7027c 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -6,6 +6,7 @@ - merge_request = local_assigns.fetch(:merge_request, nil) - project = local_assigns.fetch(:project) { merge_request&.project } - ref = local_assigns.fetch(:ref) { merge_request&.source_branch } +- commit_status = commit.present(current_user: current_user).status_for(ref) - link = commit_path(project, commit, merge_request: merge_request) %li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" } @@ -22,7 +23,7 @@ %span.commit-row-message.d-block.d-sm-none · = commit.short_id - - if commit.status(ref) + - if commit_status .d-block.d-sm-none = render_commit_status(commit, ref: ref) - if commit.description? @@ -45,7 +46,7 @@ - else = render partial: 'projects/commit/ajax_signature', locals: { commit: commit } - - if commit.status(ref) + - if commit_status = render_commit_status(commit, ref: ref) .js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } } diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml index eb46bf5c6a3..6f652391be3 100644 --- a/app/views/projects/issues/_merge_requests.html.haml +++ b/app/views/projects/issues/_merge_requests.html.haml @@ -12,6 +12,7 @@ %ul.content-list.related-items-list - has_any_head_pipeline = @merge_requests.any?(&:head_pipeline_id) - @merge_requests.each do |merge_request| + - merge_request = merge_request.present(current_user: current_user) %li.list-item.py-0.px-0 .item-body.issuable-info-container.py-lg-3.px-lg-3.pl-md-3 .item-contents @@ -25,7 +26,7 @@ = merge_request.target_project.full_path = merge_request.to_reference %span.mr-ci-status.flex-md-grow-1.justify-content-end.d-flex.ml-md-2 - - if merge_request.head_pipeline + - if merge_request.can_read_pipeline? = render_pipeline_status(merge_request.head_pipeline, tooltip_placement: 'bottom') - elsif has_any_head_pipeline = icon('blank fw') diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 1df38db9fd4..ffdd96870ef 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -6,7 +6,7 @@ %li - target = @project.repository.find_branch(branch).dereferenced_target - pipeline = @project.pipeline_for(branch, target.sha) if target - - if pipeline + - if can?(current_user, :read_pipeline, pipeline) %span.related-branch-ci-status = render_pipeline_status(pipeline) %span.related-branch-info diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index faa070d0389..d7994909366 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -46,7 +46,7 @@ %li.issuable-status.d-none.d-sm-inline-block = icon('ban') CLOSED - - if merge_request.head_pipeline + - if can?(current_user, :read_pipeline, merge_request.head_pipeline) %li.issuable-pipeline-status.d-none.d-sm-inline-block = render_pipeline_status(merge_request.head_pipeline) - if merge_request.open? && merge_request.broken? diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 0f0114d513c..69a47faabed 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -6,23 +6,22 @@ = preserve(markdown(commit.description, pipeline: :single_line)) .info-well - - if commit.status - .well-segment.pipeline-info - .icon-container - = icon('clock-o') - = pluralize @pipeline.total_size, "job" - - if @pipeline.ref - from - - if @pipeline.ref_exists? - = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" - - else - %span.ref-name - = @pipeline.ref - - if @pipeline.duration - in - = time_interval_in_words(@pipeline.duration) - - if @pipeline.queued_duration - = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" + .well-segment.pipeline-info + .icon-container + = icon('clock-o') + = pluralize @pipeline.total_size, "job" + - if @pipeline.ref + from + - if @pipeline.ref_exists? + = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name" + - else + %span.ref-name + = @pipeline.ref + - if @pipeline.duration + in + = time_interval_in_words(@pipeline.duration) + - if @pipeline.queued_duration + = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})" .well-segment .icon-container diff --git a/changelogs/unreleased/test-permissions.yml b/changelogs/unreleased/test-permissions.yml new file mode 100644 index 00000000000..cfb69fdcb1e --- /dev/null +++ b/changelogs/unreleased/test-permissions.yml @@ -0,0 +1,5 @@ +--- +title: Disallows unauthorized users from accessing the pipelines section. +merge_request: +author: +type: security diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb index 7a7b23d2bbb..0317d69edde 100644 --- a/lib/api/pipelines.rb +++ b/lib/api/pipelines.rb @@ -76,7 +76,7 @@ module API requires :pipeline_id, type: Integer, desc: 'The pipeline ID' end get ':id/pipelines/:pipeline_id' do - authorize! :read_pipeline, user_project + authorize! :read_pipeline, pipeline present pipeline, with: Entities::Pipeline end @@ -104,7 +104,7 @@ module API requires :pipeline_id, type: Integer, desc: 'The pipeline ID' end post ':id/pipelines/:pipeline_id/retry' do - authorize! :update_pipeline, user_project + authorize! :update_pipeline, pipeline pipeline.retry_failed(current_user) @@ -119,7 +119,7 @@ module API requires :pipeline_id, type: Integer, desc: 'The pipeline ID' end post ':id/pipelines/:pipeline_id/cancel' do - authorize! :update_pipeline, user_project + authorize! :update_pipeline, pipeline pipeline.cancel_running diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index 80506249ea9..fa732437fc1 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -3,9 +3,14 @@ require 'spec_helper' describe Projects::PipelineSchedulesController do include AccessMatchersForController + set(:user) { create(:user) } set(:project) { create(:project, :public, :repository) } set(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) } + before do + project.add_developer(user) + end + describe 'GET #index' do render_views @@ -14,6 +19,10 @@ describe Projects::PipelineSchedulesController do create(:ci_pipeline_schedule, :inactive, project: project) end + before do + sign_in(user) + end + it 'renders the index view' do visit_pipelines_schedules @@ -21,7 +30,7 @@ describe Projects::PipelineSchedulesController do expect(response).to render_template(:index) end - it 'avoids N + 1 queries' do + it 'avoids N + 1 queries', :request_store do control_count = ActiveRecord::QueryRecorder.new { visit_pipelines_schedules }.count create_list(:ci_pipeline_schedule, 2, project: project) diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 0bb3ef76a3b..740b28f0f46 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -5,7 +5,7 @@ describe Projects::PipelinesController do set(:user) { create(:user) } let(:project) { create(:project, :public, :repository) } - let(:feature) { ProjectFeature::DISABLED } + let(:feature) { ProjectFeature::ENABLED } before do stub_not_protect_default_branch @@ -186,6 +186,27 @@ describe Projects::PipelinesController do end end + context 'when builds are disabled' do + let(:feature) { ProjectFeature::DISABLED } + + it 'users can not see internal pipelines' do + get_pipeline_json + + expect(response).to have_gitlab_http_status(:not_found) + end + + context 'when pipeline is external' do + let(:pipeline) { create(:ci_pipeline, source: :external, project: project) } + + it 'users can see the external pipeline' do + get_pipeline_json + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['id']).to be(pipeline.id) + end + end + end + def get_pipeline_json get :show, params: { namespace_id: project.namespace, project_id: project, id: pipeline }, format: :json end @@ -326,16 +347,14 @@ describe Projects::PipelinesController do format: :json end - context 'when builds are enabled' do - let(:feature) { ProjectFeature::ENABLED } - - it 'retries a pipeline without returning any content' do - expect(response).to have_gitlab_http_status(:no_content) - expect(build.reload).to be_retried - end + it 'retries a pipeline without returning any content' do + expect(response).to have_gitlab_http_status(:no_content) + expect(build.reload).to be_retried end context 'when builds are disabled' do + let(:feature) { ProjectFeature::DISABLED } + it 'fails to retry pipeline' do expect(response).to have_gitlab_http_status(:not_found) end @@ -355,16 +374,14 @@ describe Projects::PipelinesController do format: :json end - context 'when builds are enabled' do - let(:feature) { ProjectFeature::ENABLED } - - it 'cancels a pipeline without returning any content' do - expect(response).to have_gitlab_http_status(:no_content) - expect(pipeline.reload).to be_canceled - end + it 'cancels a pipeline without returning any content' do + expect(response).to have_gitlab_http_status(:no_content) + expect(pipeline.reload).to be_canceled end context 'when builds are disabled' do + let(:feature) { ProjectFeature::DISABLED } + it 'fails to retry pipeline' do expect(response).to have_gitlab_http_status(:not_found) end diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 001e6c10eb2..9ee87563ecf 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -452,9 +452,9 @@ describe "Internal Project Access" do it { is_expected.to be_allowed_for(:owner).of(project) } it { is_expected.to be_allowed_for(:maintainer).of(project) } it { is_expected.to be_allowed_for(:developer).of(project) } - it { is_expected.to be_allowed_for(:reporter).of(project) } - it { is_expected.to be_allowed_for(:guest).of(project) } - it { is_expected.to be_allowed_for(:user) } + it { is_expected.to be_denied_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } it { is_expected.to be_denied_for(:external) } it { is_expected.to be_denied_for(:visitor) } end diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index c6618355eea..12613c39307 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -485,7 +485,7 @@ describe "Private Project Access" do it { is_expected.to be_allowed_for(:owner).of(project) } it { is_expected.to be_allowed_for(:maintainer).of(project) } it { is_expected.to be_allowed_for(:developer).of(project) } - it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:reporter).of(project) } it { is_expected.to be_denied_for(:guest).of(project) } it { is_expected.to be_denied_for(:user) } it { is_expected.to be_denied_for(:external) } diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index 3717dc13f1e..57cc0db1f38 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -272,11 +272,11 @@ describe "Public Project Access" do it { is_expected.to be_allowed_for(:owner).of(project) } it { is_expected.to be_allowed_for(:maintainer).of(project) } it { is_expected.to be_allowed_for(:developer).of(project) } - it { is_expected.to be_allowed_for(:reporter).of(project) } - it { is_expected.to be_allowed_for(:guest).of(project) } - it { is_expected.to be_allowed_for(:user) } - it { is_expected.to be_allowed_for(:external) } - it { is_expected.to be_allowed_for(:visitor) } + it { is_expected.to be_denied_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } end describe "GET /:project_path/environments" do diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index c2dd666f9df..10f61731206 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -354,8 +354,20 @@ describe ProjectsHelper do allow(project).to receive(:builds_enabled?).and_return(false) end - it "do not include pipelines tab" do - is_expected.not_to include(:pipelines) + context 'when user has access to builds' do + it "does include pipelines tab" do + is_expected.to include(:pipelines) + end + end + + context 'when user does not have access to builds' do + before do + allow(helper).to receive(:can?) { false } + end + + it "does not include pipelines tab" do + is_expected.not_to include(:pipelines) + end end end diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index 9b0da882390..6084dc96410 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -12,7 +12,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do ] RSpec::Mocks.with_temporary_scope do - @project = create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') + @project = create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') @shared = @project.import_export_shared allow(@shared).to receive(:export_path).and_return('spec/lib/gitlab/import_export/') @@ -40,7 +40,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do project = Project.find_by_path('project') expect(project.project_feature.issues_access_level).to eq(ProjectFeature::DISABLED) - expect(project.project_feature.builds_access_level).to eq(ProjectFeature::DISABLED) + expect(project.project_feature.builds_access_level).to eq(ProjectFeature::ENABLED) expect(project.project_feature.snippets_access_level).to eq(ProjectFeature::ENABLED) expect(project.project_feature.wiki_access_level).to eq(ProjectFeature::ENABLED) expect(project.project_feature.merge_requests_access_level).to eq(ProjectFeature::ENABLED) diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index a2d2d77746d..baad8352185 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -11,6 +11,7 @@ describe Commit do it { is_expected.to include_module(Participable) } it { is_expected.to include_module(Referable) } it { is_expected.to include_module(StaticModel) } + it { is_expected.to include_module(Presentable) } end describe '.lazy' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 585dfe46189..8a45f9856bc 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -405,6 +405,30 @@ describe Project do end end + describe '#all_pipelines' do + let(:project) { create(:project) } + + before do + create(:ci_pipeline, project: project, ref: 'master', source: :web) + create(:ci_pipeline, project: project, ref: 'master', source: :external) + end + + it 'has all pipelines' do + expect(project.all_pipelines.size).to eq(2) + end + + context 'when builds are disabled' do + before do + project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) + end + + it 'should return .external pipelines' do + expect(project.all_pipelines).to all(have_attributes(source: 'external')) + expect(project.all_pipelines.size).to eq(1) + end + end + end + describe 'project token' do it 'sets an random token if none provided' do project = FactoryBot.create(:project, runners_token: '') diff --git a/spec/policies/ci/pipeline_policy_spec.rb b/spec/policies/ci/pipeline_policy_spec.rb index 8022f61e67d..844d96017de 100644 --- a/spec/policies/ci/pipeline_policy_spec.rb +++ b/spec/policies/ci/pipeline_policy_spec.rb @@ -75,6 +75,14 @@ describe Ci::PipelinePolicy, :models do end end + context 'when user does not have access to internal CI' do + let(:project) { create(:project, :builds_disabled, :public) } + + it 'disallows the user from reading the pipeline' do + expect(policy).to be_disallowed :read_pipeline + end + end + describe 'destroy_pipeline' do let(:project) { create(:project, :public) } diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index 6c854bab5a5..49226a01846 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -175,21 +175,41 @@ describe ProjectPolicy do end context 'builds feature' do - subject { described_class.new(owner, project) } + context 'when builds are disabled' do + subject { described_class.new(owner, project) } - it 'disallows all permissions when the feature is disabled' do - project.project_feature.update(builds_access_level: ProjectFeature::DISABLED) + before do + project.project_feature.update(builds_access_level: ProjectFeature::DISABLED) + end - builds_permissions = [ - :create_pipeline, :update_pipeline, :admin_pipeline, :destroy_pipeline, - :create_build, :read_build, :update_build, :admin_build, :destroy_build, - :create_pipeline_schedule, :read_pipeline_schedule, :update_pipeline_schedule, :admin_pipeline_schedule, :destroy_pipeline_schedule, - :create_environment, :read_environment, :update_environment, :admin_environment, :destroy_environment, - :create_cluster, :read_cluster, :update_cluster, :admin_cluster, - :create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment - ] + it 'disallows all permissions except pipeline when the feature is disabled' do + builds_permissions = [ + :create_build, :read_build, :update_build, :admin_build, :destroy_build, + :create_pipeline_schedule, :read_pipeline_schedule, :update_pipeline_schedule, :admin_pipeline_schedule, :destroy_pipeline_schedule, + :create_environment, :read_environment, :update_environment, :admin_environment, :destroy_environment, + :create_cluster, :read_cluster, :update_cluster, :admin_cluster, :destroy_cluster, + :create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment + ] + + expect_disallowed(*builds_permissions) + end + end + + context 'when builds are disabled only for some users' do + subject { described_class.new(guest, project) } - expect_disallowed(*builds_permissions) + before do + project.project_feature.update(builds_access_level: ProjectFeature::PRIVATE) + end + + it 'disallows pipeline and commit_status permissions' do + builds_permissions = [ + :create_pipeline, :update_pipeline, :admin_pipeline, :destroy_pipeline, + :create_commit_status, :update_commit_status, :admin_commit_status, :destroy_commit_status + ] + + expect_disallowed(*builds_permissions) + end end end diff --git a/spec/presenters/commit_presenter_spec.rb b/spec/presenters/commit_presenter_spec.rb new file mode 100644 index 00000000000..4a0d3a28c32 --- /dev/null +++ b/spec/presenters/commit_presenter_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe CommitPresenter do + let(:project) { create(:project, :repository) } + let(:commit) { project.commit } + let(:user) { create(:user) } + let(:presenter) { described_class.new(commit, current_user: user) } + + describe '#status_for' do + subject { presenter.status_for('ref') } + + context 'when user can read_commit_status' do + before do + allow(presenter).to receive(:can?).with(user, :read_commit_status, project).and_return(true) + end + + it 'returns commit status for ref' do + expect(commit).to receive(:status).with('ref').and_return('test') + + expect(subject).to eq('test') + end + end + + context 'when user can not read_commit_status' do + it 'is false' do + is_expected.to eq(false) + end + end + end + + describe '#any_pipelines?' do + subject { presenter.any_pipelines? } + + context 'when user can read pipeline' do + before do + allow(presenter).to receive(:can?).with(user, :read_pipeline, project).and_return(true) + end + + it 'returns if there are any pipelines for commit' do + expect(commit).to receive_message_chain(:pipelines, :any?).and_return(true) + + expect(subject).to eq(true) + end + end + + context 'when user can not read pipeline' do + it 'is false' do + is_expected.to eq(false) + end + end + end +end diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb index 561421d5ac8..376698a16df 100644 --- a/spec/serializers/merge_request_widget_entity_spec.rb +++ b/spec/serializers/merge_request_widget_entity_spec.rb @@ -31,23 +31,40 @@ describe MergeRequestWidgetEntity do describe 'pipeline' do let(:pipeline) { create(:ci_empty_pipeline, project: project, ref: resource.source_branch, sha: resource.source_branch_sha, head_pipeline_of: resource) } - context 'when is up to date' do - let(:req) { double('request', current_user: user, project: project) } + before do + allow_any_instance_of(MergeRequestPresenter).to receive(:can?).and_call_original + allow_any_instance_of(MergeRequestPresenter).to receive(:can?).with(user, :read_pipeline, anything).and_return(result) + end - it 'returns pipeline' do - pipeline_payload = PipelineDetailsEntity - .represent(pipeline, request: req) - .as_json + context 'when user has access to pipelines' do + let(:result) { true } + + context 'when is up to date' do + let(:req) { double('request', current_user: user, project: project) } + + it 'returns pipeline' do + pipeline_payload = PipelineDetailsEntity + .represent(pipeline, request: req) + .as_json + + expect(subject[:pipeline]).to eq(pipeline_payload) + end + end + + context 'when is not up to date' do + it 'returns nil' do + pipeline.update(sha: "not up to date") - expect(subject[:pipeline]).to eq(pipeline_payload) + expect(subject[:pipeline]).to eq(nil) + end end end - context 'when is not up to date' do - it 'returns nil' do - pipeline.update(sha: "not up to date") + context 'when user does not have access to pipelines' do + let(:result) { false } - expect(subject[:pipeline]).to be_nil + it 'does not have pipeline' do + expect(subject[:pipeline]).to eq(nil) end end end diff --git a/spec/views/projects/commit/_commit_box.html.haml_spec.rb b/spec/views/projects/commit/_commit_box.html.haml_spec.rb index 2fdd28a3be4..1086546c10d 100644 --- a/spec/views/projects/commit/_commit_box.html.haml_spec.rb +++ b/spec/views/projects/commit/_commit_box.html.haml_spec.rb @@ -9,6 +9,7 @@ describe 'projects/commit/_commit_box.html.haml' do assign(:commit, project.commit) allow(view).to receive(:current_user).and_return(user) allow(view).to receive(:can_collaborate_with_project?).and_return(false) + project.add_developer(user) end it 'shows the commit SHA' do @@ -48,7 +49,6 @@ describe 'projects/commit/_commit_box.html.haml' do context 'viewing a commit' do context 'as a developer' do before do - project.add_developer(user) allow(view).to receive(:can_collaborate_with_project?).and_return(true) end @@ -60,6 +60,10 @@ describe 'projects/commit/_commit_box.html.haml' do end context 'as a non-developer' do + before do + project.add_guest(user) + end + it 'does not have a link to create a new tag' do render diff --git a/spec/views/projects/issues/_related_branches.html.haml_spec.rb b/spec/views/projects/issues/_related_branches.html.haml_spec.rb index 8c845251765..5cff7694029 100644 --- a/spec/views/projects/issues/_related_branches.html.haml_spec.rb +++ b/spec/views/projects/issues/_related_branches.html.haml_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe 'projects/issues/_related_branches' do include Devise::Test::ControllerHelpers + let(:user) { create(:user) } let(:project) { create(:project, :repository) } let(:branch) { project.repository.find_branch('feature') } let!(:pipeline) { create(:ci_pipeline, project: project, sha: branch.dereferenced_target.id, ref: 'feature') } @@ -11,6 +12,9 @@ describe 'projects/issues/_related_branches' do assign(:project, project) assign(:related_branches, ['feature']) + project.add_developer(user) + allow(view).to receive(:current_user).and_return(user) + render end -- cgit v1.2.3 From b5e10cd3ac4e15e7421ebc9acc5d4f9ca9e8e3ea Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Fri, 25 Jan 2019 17:53:56 +0000 Subject: Merge branch '56860-fix-spec-race-condition-upside-the-head' into 'master' Fix a JS race in a spec Closes #56860 See merge request gitlab-org/gitlab-ce!24684 --- spec/features/projects/settings/user_changes_default_branch_spec.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/features/projects/settings/user_changes_default_branch_spec.rb b/spec/features/projects/settings/user_changes_default_branch_spec.rb index fcf05e04a5c..7dc18601f50 100644 --- a/spec/features/projects/settings/user_changes_default_branch_spec.rb +++ b/spec/features/projects/settings/user_changes_default_branch_spec.rb @@ -15,6 +15,9 @@ describe 'Projects > Settings > User changes default branch' do let(:project) { create(:project, :repository, namespace: user.namespace) } it 'allows to change the default branch', :js do + # Otherwise, running JS may overwrite our change to project_default_branch + wait_for_requests + select2('fix', from: '#project_default_branch') page.within '#default-branch-settings' do -- cgit v1.2.3 From 02c3c9dad5bc497e416040734bde90951d8f13a8 Mon Sep 17 00:00:00 2001 From: Mark Lapierre Date: Fri, 25 Jan 2019 16:47:06 -0500 Subject: Add git credentials to .netrc when needed Avoid having to remember to call try_add_credentials_to_netrc after setting credentials. --- qa/qa/git/repository.rb | 47 ++++++++---- qa/qa/resource/repository/push.rb | 2 - qa/spec/git/repository_spec.rb | 145 +++++++++++++++++++++++++++----------- 3 files changed, 137 insertions(+), 57 deletions(-) diff --git a/qa/qa/git/repository.rb b/qa/qa/git/repository.rb index ac8dcbf0d83..df254ef288b 100644 --- a/qa/qa/git/repository.rb +++ b/qa/qa/git/repository.rb @@ -5,15 +5,19 @@ require 'uri' require 'open3' require 'fileutils' require 'tmpdir' +require 'tempfile' +require 'securerandom' module QA module Git class Repository include Scenario::Actable - attr_writer :password, :use_lfs + attr_writer :use_lfs attr_accessor :env_vars + InvalidCredentialsError = Class.new(RuntimeError) + def initialize # We set HOME to the current working directory (which is a # temporary directory created in .perform()) so the temporarily dropped @@ -28,6 +32,14 @@ module QA end end + def password=(password) + @password = password + + raise InvalidCredentialsError, "Please provide a username when setting a password" unless username + + try_add_credentials_to_netrc + end + def uri=(address) @uri = URI(address) end @@ -105,6 +117,8 @@ module QA File.binwrite(private_key_file, key.private_key) File.chmod(0700, private_key_file) + try_add_credentials_to_netrc + @known_hosts_file = Tempfile.new("known_hosts_#{SecureRandom.hex(8)}") keyscan_params = ['-H'] keyscan_params << "-p #{uri.port}" if uri.port @@ -148,16 +162,7 @@ module QA return unless add_credentials? return if netrc_already_contains_content? - # Despite libcurl supporting a custom .netrc location through the - # CURLOPT_NETRC_FILE environment variable, git does not support it :( - # Info: https://curl.haxx.se/libcurl/c/CURLOPT_NETRC_FILE.html - # - # This will create a .netrc in the correct working directory, which is - # a temporary directory created in .perform() - # - FileUtils.mkdir_p(tmp_home_dir) - File.open(netrc_file_path, 'a') { |file| file.puts(netrc_content) } - File.chmod(0600, netrc_file_path) + save_netrc_content end private @@ -214,6 +219,23 @@ module QA end end + def read_netrc_content + File.exist?(netrc_file_path) ? File.readlines(netrc_file_path) : [] + end + + def save_netrc_content + # Despite libcurl supporting a custom .netrc location through the + # CURLOPT_NETRC_FILE environment variable, git does not support it :( + # Info: https://curl.haxx.se/libcurl/c/CURLOPT_NETRC_FILE.html + # + # This will create a .netrc in the correct working directory, which is + # a temporary directory created in .perform() + # + FileUtils.mkdir_p(tmp_home_dir) + File.open(netrc_file_path, 'a') { |file| file.puts(netrc_content) } + File.chmod(0600, netrc_file_path) + end + def tmp_home_dir @tmp_home_dir ||= File.join(Dir.tmpdir, "qa-netrc-credentials", $$.to_s) end @@ -227,8 +249,7 @@ module QA end def netrc_already_contains_content? - File.exist?(netrc_file_path) && - File.readlines(netrc_file_path).grep(/^#{netrc_content}$/).any? + read_netrc_content.grep(/^#{netrc_content}$/).any? end end end diff --git a/qa/qa/resource/repository/push.rb b/qa/qa/resource/repository/push.rb index 32f15547da2..a5827fb6e73 100644 --- a/qa/qa/resource/repository/push.rb +++ b/qa/qa/resource/repository/push.rb @@ -67,8 +67,6 @@ module QA email = user.email end - repository.try_add_credentials_to_netrc - @output += repository.clone repository.configure_identity(username, email) diff --git a/qa/spec/git/repository_spec.rb b/qa/spec/git/repository_spec.rb index faa154c78da..48bcc8e8276 100644 --- a/qa/spec/git/repository_spec.rb +++ b/qa/spec/git/repository_spec.rb @@ -1,69 +1,130 @@ describe QA::Git::Repository do include Support::StubENV - let(:repository) { described_class.new } + shared_context 'git directory' do + let(:repository) { described_class.new } + let(:tmp_git_dir) { Dir.mktmpdir } + let(:tmp_netrc_dir) { Dir.mktmpdir } - before do - stub_env('GITLAB_USERNAME', 'root') - cd_empty_temp_directory - set_bad_uri - repository.use_default_credentials - end + before do + stub_env('GITLAB_USERNAME', 'root') + cd_empty_temp_directory + set_bad_uri - describe '#clone' do - it 'is unable to resolve host' do - expect(repository.clone).to include("fatal: unable to access 'http://root@foo/bar.git/'") + allow(repository).to receive(:tmp_home_dir).and_return(tmp_netrc_dir) end - end - describe '#push_changes' do - before do - `git init` # need a repo to push from + after do + # Switch to a safe dir before deleting tmp dirs to avoid dir access errors + FileUtils.cd __dir__ + FileUtils.remove_entry_secure(tmp_git_dir, true) + FileUtils.remove_entry_secure(tmp_netrc_dir, true) end - it 'fails to push changes' do - expect(repository.push_changes).to include("error: failed to push some refs to 'http://root@foo/bar.git'") + def cd_empty_temp_directory + FileUtils.cd tmp_git_dir + end + + def set_bad_uri + repository.uri = 'http://foo/bar.git' end end - describe '#git_protocol=' do - [0, 1, 2].each do |version| - it "configures git to use protocol version #{version}" do - expect(repository).to receive(:run).with("git config protocol.version #{version}") - repository.git_protocol = version + context 'with default credentials' do + include_context 'git directory' do + before do + repository.use_default_credentials end end - it 'raises an error if the version is unsupported' do - expect { repository.git_protocol = 'foo' }.to raise_error(ArgumentError, "Please specify the protocol you would like to use: 0, 1, or 2") + describe '#clone' do + it 'is unable to resolve host' do + expect(repository.clone).to include("fatal: unable to access 'http://root@foo/bar.git/'") + end end - end - describe '#fetch_supported_git_protocol' do - it "reports the detected version" do - expect(repository).to receive(:run).and_return("packet: git< version 2") - expect(repository.fetch_supported_git_protocol).to eq('2') + describe '#push_changes' do + before do + `git init` # need a repo to push from + end + + it 'fails to push changes' do + expect(repository.push_changes).to include("error: failed to push some refs to 'http://root@foo/bar.git'") + end end - it 'reports unknown if version is unknown' do - expect(repository).to receive(:run).and_return("packet: git< version -1") - expect(repository.fetch_supported_git_protocol).to eq('unknown') + describe '#git_protocol=' do + [0, 1, 2].each do |version| + it "configures git to use protocol version #{version}" do + expect(repository).to receive(:run).with("git config protocol.version #{version}") + repository.git_protocol = version + end + end + + it 'raises an error if the version is unsupported' do + expect { repository.git_protocol = 'foo' }.to raise_error(ArgumentError, "Please specify the protocol you would like to use: 0, 1, or 2") + end + end + + describe '#fetch_supported_git_protocol' do + it "reports the detected version" do + expect(repository).to receive(:run).and_return("packet: git< version 2") + expect(repository.fetch_supported_git_protocol).to eq('2') + end + + it 'reports unknown if version is unknown' do + expect(repository).to receive(:run).and_return("packet: git< version -1") + expect(repository.fetch_supported_git_protocol).to eq('unknown') + end + + it 'reports unknown if content does not identify a version' do + expect(repository).to receive(:run).and_return("foo") + expect(repository.fetch_supported_git_protocol).to eq('unknown') + end end - it 'reports unknown if content does not identify a version' do - expect(repository).to receive(:run).and_return("foo") - expect(repository.fetch_supported_git_protocol).to eq('unknown') + describe '#use_default_credentials' do + it 'adds credentials to .netrc' do + expect(File.read(File.join(tmp_netrc_dir, '.netrc'))) + .to eq("machine foo login #{QA::Runtime::User.default_username} password #{QA::Runtime::User.default_password}\n") + end end end - def cd_empty_temp_directory - tmp_dir = 'tmp/git-repository-spec/' - FileUtils.rm_rf(tmp_dir) if ::File.exist?(tmp_dir) - FileUtils.mkdir_p tmp_dir - FileUtils.cd tmp_dir - end + context 'with specific credentials' do + include_context 'git directory' + + context 'before setting credentials' do + it 'does not add credentials to .netrc' do + expect(repository).not_to receive(:save_netrc_content) + end + end + + describe '#password=' do + it 'raises an error if no username was given' do + expect { repository.password = 'foo' } + .to raise_error(QA::Git::Repository::InvalidCredentialsError, + "Please provide a username when setting a password") + end + + it 'adds credentials to .netrc' do + repository.username = 'user' + repository.password = 'foo' + + expect(File.read(File.join(tmp_netrc_dir, '.netrc'))) + .to eq("machine foo login user password foo\n") + end + end - def set_bad_uri - repository.uri = 'http://foo/bar.git' + describe '#use_ssh_key' do + it 'does not add credentials to .netrc' do + key = Struct.new(:private_key).new('foo') + + expect(repository).to receive(:try_add_credentials_to_netrc).and_call_original + expect(repository).not_to receive(:save_netrc_content) + + repository.use_ssh_key(key) + end + end end end -- cgit v1.2.3 From 13b9cf4f650a23f319a6f366491b50f68856314b Mon Sep 17 00:00:00 2001 From: GitLab Release Tools Bot Date: Mon, 28 Jan 2019 21:19:19 +0000 Subject: Update CHANGELOG.md for 11.7.1 [ci skip] --- CHANGELOG.md | 30 ++++++++++++++++++++++ .../unreleased/extract-pages-with-rubyzip.yml | 5 ---- .../unreleased/fix-security-group-user-removal.yml | 5 ---- ...ity-2767-verify-lfs-finalize-from-workhorse.yml | 5 ---- .../security-2769-idn-homograph-attack.yml | 5 ---- .../security-2776-fix-add-reaction-permissions.yml | 5 ---- ...ty-2779-fix-email-comment-permissions-check.yml | 5 ---- .../security-2780-disable-git-v2-protocol.yml | 5 ---- ...security-commit-status-shown-for-guest-user.yml | 5 ---- .../unreleased/security-contributed-projects.yml | 5 ---- .../security-do-not-process-mr-ref-for-guests.yml | 5 ---- ...ecurity-fix-lfs-import-project-ssrf-forgery.yml | 5 ---- .../security-fix-new-issues-login-message.yml | 5 ---- changelogs/unreleased/security-fix-regex-dos.yml | 5 ---- .../security-fix-user-email-tag-push-leak.yml | 5 ---- ...ki-access-rights-with-external-wiki-enabled.yml | 5 ---- ...urity-guests-can-see-list-of-merge-requests.yml | 6 ----- .../unreleased/security-import-path-logging.yml | 5 ---- .../security-import-project-visibility.yml | 5 ---- .../security-pipeline-trigger-tokens-exposure.yml | 5 ---- .../unreleased/security-project-move-users.yml | 5 ---- 21 files changed, 30 insertions(+), 101 deletions(-) delete mode 100644 changelogs/unreleased/extract-pages-with-rubyzip.yml delete mode 100644 changelogs/unreleased/fix-security-group-user-removal.yml delete mode 100644 changelogs/unreleased/security-2767-verify-lfs-finalize-from-workhorse.yml delete mode 100644 changelogs/unreleased/security-2769-idn-homograph-attack.yml delete mode 100644 changelogs/unreleased/security-2776-fix-add-reaction-permissions.yml delete mode 100644 changelogs/unreleased/security-2779-fix-email-comment-permissions-check.yml delete mode 100644 changelogs/unreleased/security-2780-disable-git-v2-protocol.yml delete mode 100644 changelogs/unreleased/security-commit-status-shown-for-guest-user.yml delete mode 100644 changelogs/unreleased/security-contributed-projects.yml delete mode 100644 changelogs/unreleased/security-do-not-process-mr-ref-for-guests.yml delete mode 100644 changelogs/unreleased/security-fix-lfs-import-project-ssrf-forgery.yml delete mode 100644 changelogs/unreleased/security-fix-new-issues-login-message.yml delete mode 100644 changelogs/unreleased/security-fix-regex-dos.yml delete mode 100644 changelogs/unreleased/security-fix-user-email-tag-push-leak.yml delete mode 100644 changelogs/unreleased/security-fix-wiki-access-rights-with-external-wiki-enabled.yml delete mode 100644 changelogs/unreleased/security-guests-can-see-list-of-merge-requests.yml delete mode 100644 changelogs/unreleased/security-import-path-logging.yml delete mode 100644 changelogs/unreleased/security-import-project-visibility.yml delete mode 100644 changelogs/unreleased/security-pipeline-trigger-tokens-exposure.yml delete mode 100644 changelogs/unreleased/security-project-move-users.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index c1deab58d38..e84aa126c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 11.7.1 (2019-01-28) + +### Security (24 changes) + +- Make potentially malicious links more visible in the UI and scrub RTLO chars from links. !2770 +- Don't process MR refs for guests in the notes. !2771 +- Sanitize user full name to clean up any URL to prevent mail clients from auto-linking URLs. !2828 +- Fixed XSS content in KaTex links. +- Disallows unauthorized users from accessing the pipelines section. +- Verify that LFS upload requests are genuine. +- Extract GitLab Pages using RubyZip. +- Prevent awarding emojis to notes whose parent is not visible to user. +- Prevent unauthorized replies when discussion is locked or confidential. +- Disable git v2 protocol temporarily. +- Fix showing ci status for guest users when public pipline are not set. +- Fix contributed projects info still visible when user enable private profile. +- Add subresources removal to member destroy service. +- Add more LFS validations to prevent forgery. +- Use common error for unauthenticated users when creating issues. +- Fix slow regex in project reference pattern. +- Fix private user email being visible in push (and tag push) webhooks. +- Fix wiki access rights when external wiki is enabled. +- Group guests are no longer able to see merge requests they don't have access to at group level. +- Fix path disclosure on project import error. +- Restrict project import visibility based on its group. +- Expose CI/CD trigger token only to the trigger owner. +- Notify only users who can access the project on project move. +- Alias GitHub and BitBucket OAuth2 callback URLs. + + ## 11.7.0 (2019-01-22) ### Security (14 changes, 1 of them is from the community) diff --git a/changelogs/unreleased/extract-pages-with-rubyzip.yml b/changelogs/unreleased/extract-pages-with-rubyzip.yml deleted file mode 100644 index 8352e79d3e5..00000000000 --- a/changelogs/unreleased/extract-pages-with-rubyzip.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Extract GitLab Pages using RubyZip -merge_request: -author: -type: security diff --git a/changelogs/unreleased/fix-security-group-user-removal.yml b/changelogs/unreleased/fix-security-group-user-removal.yml deleted file mode 100644 index 09d09a96f84..00000000000 --- a/changelogs/unreleased/fix-security-group-user-removal.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Add subresources removal to member destroy service -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-2767-verify-lfs-finalize-from-workhorse.yml b/changelogs/unreleased/security-2767-verify-lfs-finalize-from-workhorse.yml deleted file mode 100644 index e79e3263df7..00000000000 --- a/changelogs/unreleased/security-2767-verify-lfs-finalize-from-workhorse.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Verify that LFS upload requests are genuine -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-2769-idn-homograph-attack.yml b/changelogs/unreleased/security-2769-idn-homograph-attack.yml deleted file mode 100644 index a014b522c96..00000000000 --- a/changelogs/unreleased/security-2769-idn-homograph-attack.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Make potentially malicious links more visible in the UI and scrub RTLO chars from links -merge_request: 2770 -author: -type: security diff --git a/changelogs/unreleased/security-2776-fix-add-reaction-permissions.yml b/changelogs/unreleased/security-2776-fix-add-reaction-permissions.yml deleted file mode 100644 index 3ad92578c44..00000000000 --- a/changelogs/unreleased/security-2776-fix-add-reaction-permissions.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Prevent awarding emojis to notes whose parent is not visible to user -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-2779-fix-email-comment-permissions-check.yml b/changelogs/unreleased/security-2779-fix-email-comment-permissions-check.yml deleted file mode 100644 index 2f76064d8a4..00000000000 --- a/changelogs/unreleased/security-2779-fix-email-comment-permissions-check.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Prevent unauthorized replies when discussion is locked or confidential -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-2780-disable-git-v2-protocol.yml b/changelogs/unreleased/security-2780-disable-git-v2-protocol.yml deleted file mode 100644 index 30a08a98e83..00000000000 --- a/changelogs/unreleased/security-2780-disable-git-v2-protocol.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Disable git v2 protocol temporarily -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-commit-status-shown-for-guest-user.yml b/changelogs/unreleased/security-commit-status-shown-for-guest-user.yml deleted file mode 100644 index a80170091d0..00000000000 --- a/changelogs/unreleased/security-commit-status-shown-for-guest-user.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix showing ci status for guest users when public pipline are not set -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-contributed-projects.yml b/changelogs/unreleased/security-contributed-projects.yml deleted file mode 100644 index f745a2255ca..00000000000 --- a/changelogs/unreleased/security-contributed-projects.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix contributed projects info still visible when user enable private profile -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-do-not-process-mr-ref-for-guests.yml b/changelogs/unreleased/security-do-not-process-mr-ref-for-guests.yml deleted file mode 100644 index 0281dde11e6..00000000000 --- a/changelogs/unreleased/security-do-not-process-mr-ref-for-guests.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Don't process MR refs for guests in the notes -merge_request: 2771 -author: -type: security diff --git a/changelogs/unreleased/security-fix-lfs-import-project-ssrf-forgery.yml b/changelogs/unreleased/security-fix-lfs-import-project-ssrf-forgery.yml deleted file mode 100644 index b6315ec29d8..00000000000 --- a/changelogs/unreleased/security-fix-lfs-import-project-ssrf-forgery.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Add more LFS validations to prevent forgery -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-fix-new-issues-login-message.yml b/changelogs/unreleased/security-fix-new-issues-login-message.yml deleted file mode 100644 index 9dabf2438c9..00000000000 --- a/changelogs/unreleased/security-fix-new-issues-login-message.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Use common error for unauthenticated users when creating issues -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-fix-regex-dos.yml b/changelogs/unreleased/security-fix-regex-dos.yml deleted file mode 100644 index b08566d2f15..00000000000 --- a/changelogs/unreleased/security-fix-regex-dos.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix slow regex in project reference pattern -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-fix-user-email-tag-push-leak.yml b/changelogs/unreleased/security-fix-user-email-tag-push-leak.yml deleted file mode 100644 index 915ea7b5216..00000000000 --- a/changelogs/unreleased/security-fix-user-email-tag-push-leak.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix private user email being visible in push (and tag push) webhooks -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-fix-wiki-access-rights-with-external-wiki-enabled.yml b/changelogs/unreleased/security-fix-wiki-access-rights-with-external-wiki-enabled.yml deleted file mode 100644 index d5f20b87a90..00000000000 --- a/changelogs/unreleased/security-fix-wiki-access-rights-with-external-wiki-enabled.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix wiki access rights when external wiki is enabled -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-guests-can-see-list-of-merge-requests.yml b/changelogs/unreleased/security-guests-can-see-list-of-merge-requests.yml deleted file mode 100644 index f5b74011829..00000000000 --- a/changelogs/unreleased/security-guests-can-see-list-of-merge-requests.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Group guests are no longer able to see merge requests they don't have access - to at group level -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-import-path-logging.yml b/changelogs/unreleased/security-import-path-logging.yml deleted file mode 100644 index 2ba2d88d82a..00000000000 --- a/changelogs/unreleased/security-import-path-logging.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix path disclosure on project import error -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-import-project-visibility.yml b/changelogs/unreleased/security-import-project-visibility.yml deleted file mode 100644 index 04ae172a9a1..00000000000 --- a/changelogs/unreleased/security-import-project-visibility.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Restrict project import visibility based on its group -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-pipeline-trigger-tokens-exposure.yml b/changelogs/unreleased/security-pipeline-trigger-tokens-exposure.yml deleted file mode 100644 index 97d743eead1..00000000000 --- a/changelogs/unreleased/security-pipeline-trigger-tokens-exposure.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Expose CI/CD trigger token only to the trigger owner -merge_request: -author: -type: security diff --git a/changelogs/unreleased/security-project-move-users.yml b/changelogs/unreleased/security-project-move-users.yml deleted file mode 100644 index 744df68651f..00000000000 --- a/changelogs/unreleased/security-project-move-users.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Notify only users who can access the project on project move. -merge_request: -author: -type: security -- cgit v1.2.3 From 20d6be4d8c39d6045547c0e7a533258479eebd8d Mon Sep 17 00:00:00 2001 From: GitLab Release Tools Bot Date: Mon, 28 Jan 2019 21:26:14 +0000 Subject: Update CHANGELOG.md for 11.5.8 [ci skip] --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e84aa126c63..98c4c8f9233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -558,6 +558,33 @@ entry. - Enable Rubocop on lib/gitlab. (gfyoung) +## 11.5.8 (2019-01-28) + +### Security (21 changes) + +- Make potentially malicious links more visible in the UI and scrub RTLO chars from links. !2770 +- Don't process MR refs for guests in the notes. !2771 +- Fixed XSS content in KaTex links. +- Verify that LFS upload requests are genuine. +- Extract GitLab Pages using RubyZip. +- Prevent awarding emojis to notes whose parent is not visible to user. +- Prevent unauthorized replies when discussion is locked or confidential. +- Disable git v2 protocol temporarily. +- Fix showing ci status for guest users when public pipline are not set. +- Fix contributed projects info still visible when user enable private profile. +- Disallows unauthorized users from accessing the pipelines section. +- Add more LFS validations to prevent forgery. +- Use common error for unauthenticated users when creating issues. +- Fix slow regex in project reference pattern. +- Fix private user email being visible in push (and tag push) webhooks. +- Fix wiki access rights when external wiki is enabled. +- Fix path disclosure on project import error. +- Restrict project import visibility based on its group. +- Expose CI/CD trigger token only to the trigger owner. +- Notify only users who can access the project on project move. +- Alias GitHub and BitBucket OAuth2 callback URLs. + + ## 11.5.5 (2018-12-20) ### Security (1 change) -- cgit v1.2.3 From ef17cf49cc8576badcc06c0c1cbd6c069e726d79 Mon Sep 17 00:00:00 2001 From: Marin Jankovski Date: Tue, 29 Jan 2019 11:57:21 +0100 Subject: Address docs review feedback --- .gitlab/merge_request_templates/Security Release.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitlab/merge_request_templates/Security Release.md b/.gitlab/merge_request_templates/Security Release.md index df760052ff4..d72b4eb1cb6 100644 --- a/.gitlab/merge_request_templates/Security Release.md +++ b/.gitlab/merge_request_templates/Security Release.md @@ -1,8 +1,8 @@ ## Related issues @@ -16,7 +16,7 @@ See the general developer security release guidelines https://gitlab.com/gitlab- - [ ] Milestone is set for the version this MR applies to - [ ] Title of this MR is the same as for all backports - [ ] A [CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html) is added without a `merge_request` value, with `type` set to `security` -- [ ] Add a link this MR in the `links` section of related issue +- [ ] Add a link to this MR in the `links` section of related issue - [ ] Add a link to an EE MR if required - [ ] Assign to a reviewer -- cgit v1.2.3 From 40a04bc7e6a8d99e837938ff984e4bf4036dae3b Mon Sep 17 00:00:00 2001 From: Tim Zallmann Date: Tue, 29 Jan 2019 10:35:53 +0100 Subject: Wraps Select 2 Import into its own webpack bundle Wraps all imports for select 2 to deferred imports, especially in the main.js we are actually checking if there is any select 2 element on the page or not. --- app/assets/javascripts/commons/jquery.js | 1 - app/assets/javascripts/groups_select.js | 172 ++++++++++--------- .../issuable/auto_width_dropdown_select.js | 12 +- app/assets/javascripts/issuable_context.js | 12 +- app/assets/javascripts/issuable_form.js | 62 +++---- app/assets/javascripts/main.js | 30 ++-- app/assets/javascripts/project_select.js | 174 ++++++++++--------- .../javascripts/project_select_combo_button.js | 10 +- app/assets/javascripts/users_select.js | 190 +++++++++++---------- 9 files changed, 350 insertions(+), 313 deletions(-) diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js index a7ed175f7a4..009153d0703 100644 --- a/app/assets/javascripts/commons/jquery.js +++ b/app/assets/javascripts/commons/jquery.js @@ -7,4 +7,3 @@ import 'vendor/jquery.caret'; import 'vendor/jquery.atwho'; import 'vendor/jquery.scrollTo'; import 'jquery.waitforimages'; -import 'select2/select2'; diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index 2049760fe29..bdadbb1bb2a 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -4,93 +4,97 @@ import Api from './api'; import { normalizeHeaders } from './lib/utils/common_utils'; export default function groupsSelect() { - // Needs to be accessible in rspec - window.GROUP_SELECT_PER_PAGE = 20; - $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { - const $select = $(this); - const allAvailable = $select.data('allAvailable'); - const skipGroups = $select.data('skipGroups') || []; - const parentGroupID = $select.data('parentId'); - const groupsPath = parentGroupID - ? Api.subgroupsPath.replace(':id', parentGroupID) - : Api.groupsPath; + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + // Needs to be accessible in rspec + window.GROUP_SELECT_PER_PAGE = 20; + $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { + const $select = $(this); + const allAvailable = $select.data('allAvailable'); + const skipGroups = $select.data('skipGroups') || []; + const parentGroupID = $select.data('parentId'); + const groupsPath = parentGroupID + ? Api.subgroupsPath.replace(':id', parentGroupID) + : Api.groupsPath; - $select.select2({ - placeholder: 'Search for a group', - allowClear: $select.hasClass('allowClear'), - multiple: $select.hasClass('multiselect'), - minimumInputLength: 0, - ajax: { - url: Api.buildUrl(groupsPath), - dataType: 'json', - quietMillis: 250, - transport(params) { - axios[params.type.toLowerCase()](params.url, { - params: params.data, - }) - .then(res => { - const results = res.data || []; - const headers = normalizeHeaders(res.headers); - const currentPage = parseInt(headers['X-PAGE'], 10) || 0; - const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; - const more = currentPage < totalPages; + $select.select2({ + placeholder: 'Search for a group', + allowClear: $select.hasClass('allowClear'), + multiple: $select.hasClass('multiselect'), + minimumInputLength: 0, + ajax: { + url: Api.buildUrl(groupsPath), + dataType: 'json', + quietMillis: 250, + transport(params) { + axios[params.type.toLowerCase()](params.url, { + params: params.data, + }) + .then(res => { + const results = res.data || []; + const headers = normalizeHeaders(res.headers); + const currentPage = parseInt(headers['X-PAGE'], 10) || 0; + const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; + const more = currentPage < totalPages; - params.success({ - results, - pagination: { - more, - }, - }); - }) - .catch(params.error); - }, - data(search, page) { - return { - search, - page, - per_page: window.GROUP_SELECT_PER_PAGE, - all_available: allAvailable, - }; - }, - results(data, page) { - if (data.length) return { results: [] }; + params.success({ + results, + pagination: { + more, + }, + }); + }) + .catch(params.error); + }, + data(search, page) { + return { + search, + page, + per_page: window.GROUP_SELECT_PER_PAGE, + all_available: allAvailable, + }; + }, + results(data, page) { + if (data.length) return { results: [] }; - const groups = data.length ? data : data.results || []; - const more = data.pagination ? data.pagination.more : false; - const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); + const groups = data.length ? data : data.results || []; + const more = data.pagination ? data.pagination.more : false; + const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); - return { - results, - page, - more, - }; - }, - }, - // eslint-disable-next-line consistent-return - initSelection(element, callback) { - const id = $(element).val(); - if (id !== '') { - return Api.group(id, callback); - } - }, - formatResult(object) { - return `
${ - object.full_name - }
${object.full_path}
`; - }, - formatSelection(object) { - return object.full_name; - }, - dropdownCssClass: 'ajax-groups-dropdown select2-infinite', - // we do not want to escape markup since we are displaying html in results - escapeMarkup(m) { - return m; - }, - }); + return { + results, + page, + more, + }; + }, + }, + // eslint-disable-next-line consistent-return + initSelection(element, callback) { + const id = $(element).val(); + if (id !== '') { + return Api.group(id, callback); + } + }, + formatResult(object) { + return `
${ + object.full_name + }
${object.full_path}
`; + }, + formatSelection(object) { + return object.full_name; + }, + dropdownCssClass: 'ajax-groups-dropdown select2-infinite', + // we do not want to escape markup since we are displaying html in results + escapeMarkup(m) { + return m; + }, + }); - $select.on('select2-loaded', () => { - const dropdown = document.querySelector('.select2-infinite .select2-results'); - dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; - }); - }); + $select.on('select2-loaded', () => { + const dropdown = document.querySelector('.select2-infinite .select2-results'); + dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; + }); + }); + }) + .catch(() => {}); } diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js index 612c524ca1c..e0fb58ef195 100644 --- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js +++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js @@ -11,10 +11,14 @@ class AutoWidthDropdownSelect { init() { const { dropdownClass } = this; - this.$selectElement.select2({ - dropdownCssClass: dropdownClass, - ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), - }); + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + this.$selectElement.select2({ + dropdownCssClass: dropdownClass, + ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), + }); + }) + .catch(() => {}); return this; } diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index f3d722409b0..48e7ed1318d 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -7,10 +7,14 @@ export default class IssuableContext { constructor(currentUser) { this.userSelect = new UsersSelect(currentUser); - $('select.select2').select2({ - width: 'resolve', - dropdownAutoWidth: true, - }); + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('select.select2').select2({ + width: 'resolve', + dropdownAutoWidth: true, + }); + }) + .catch(() => {}); $('.issuable-sidebar .inline-update').on('change', 'select', function onClickSelect() { return $(this).submit(); diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index c81a2230310..4d2533d01f1 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -120,35 +120,39 @@ export default class IssuableForm { } initTargetBranchDropdown() { - this.$targetBranchSelect.select2({ - ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'), - ajax: { - url: this.$targetBranchSelect.data('endpoint'), - dataType: 'JSON', - quietMillis: 250, - data(search) { - return { - search, - }; - }, - results(data) { - return { - // `data` keys are translated so we can't just access them with a string based key - results: data[Object.keys(data)[0]].map(name => ({ - id: name, - text: name, - })), - }; - }, - }, - initSelection(el, callback) { - const val = el.val(); - - callback({ - id: val, - text: val, + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + this.$targetBranchSelect.select2({ + ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'), + ajax: { + url: this.$targetBranchSelect.data('endpoint'), + dataType: 'JSON', + quietMillis: 250, + data(search) { + return { + search, + }; + }, + results(data) { + return { + // `data` keys are translated so we can't just access them with a string based key + results: data[Object.keys(data)[0]].map(name => ({ + id: name, + text: name, + })), + }; + }, + }, + initSelection(el, callback) { + const val = el.val(); + + callback({ + id: val, + text: val, + }); + }, }); - }, - }); + }) + .catch(() => {}); } } diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 4ba3543f9b2..8e10b3ad912 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -100,18 +100,24 @@ function deferredInitialisation() { }); // Initialize select2 selects - $('select.select2').select2({ - width: 'resolve', - dropdownAutoWidth: true, - }); - - // Close select2 on escape - $('.js-select2').on('select2-close', () => { - setTimeout(() => { - $('.select2-container-active').removeClass('select2-container-active'); - $(':focus').blur(); - }, 1); - }); + if ($('select.select2').length) { + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('select.select2').select2({ + width: 'resolve', + dropdownAutoWidth: true, + }); + + // Close select2 on escape + $('.js-select2').on('select2-close', () => { + setTimeout(() => { + $('.select2-container-active').removeClass('select2-container-active'); + $(':focus').blur(); + }, 1); + }); + }) + .catch(() => {}); + } // Initialize tooltips $body.tooltip({ diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index a33835472bb..5ee510eb11d 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -5,97 +5,101 @@ import Api from './api'; import ProjectSelectComboButton from './project_select_combo_button'; export default function projectSelect() { - $('.ajax-project-select').each(function(i, select) { - var placeholder; - const simpleFilter = $(select).data('simpleFilter') || false; - this.groupId = $(select).data('groupId'); - this.includeGroups = $(select).data('includeGroups'); - this.allProjects = $(select).data('allProjects') || false; - this.orderBy = $(select).data('orderBy') || 'id'; - this.withIssuesEnabled = $(select).data('withIssuesEnabled'); - this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); - this.withShared = - $(select).data('withShared') === undefined ? true : $(select).data('withShared'); - this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; - this.allowClear = $(select).data('allowClear') || false; + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('.ajax-project-select').each(function(i, select) { + var placeholder; + const simpleFilter = $(select).data('simpleFilter') || false; + this.groupId = $(select).data('groupId'); + this.includeGroups = $(select).data('includeGroups'); + this.allProjects = $(select).data('allProjects') || false; + this.orderBy = $(select).data('orderBy') || 'id'; + this.withIssuesEnabled = $(select).data('withIssuesEnabled'); + this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); + this.withShared = + $(select).data('withShared') === undefined ? true : $(select).data('withShared'); + this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; + this.allowClear = $(select).data('allowClear') || false; - placeholder = 'Search for project'; - if (this.includeGroups) { - placeholder += ' or group'; - } + placeholder = 'Search for project'; + if (this.includeGroups) { + placeholder += ' or group'; + } - $(select).select2({ - placeholder: placeholder, - minimumInputLength: 0, - query: (function(_this) { - return function(query) { - var finalCallback, projectsCallback; - finalCallback = function(projects) { - var data; - data = { - results: projects, - }; - return query.callback(data); - }; - if (_this.includeGroups) { - projectsCallback = function(projects) { - var groupsCallback; - groupsCallback = function(groups) { + $(select).select2({ + placeholder: placeholder, + minimumInputLength: 0, + query: (function(_this) { + return function(query) { + var finalCallback, projectsCallback; + finalCallback = function(projects) { var data; - data = groups.concat(projects); - return finalCallback(data); + data = { + results: projects, + }; + return query.callback(data); }; - return Api.groups(query.term, {}, groupsCallback); + if (_this.includeGroups) { + projectsCallback = function(projects) { + var groupsCallback; + groupsCallback = function(groups) { + var data; + data = groups.concat(projects); + return finalCallback(data); + }; + return Api.groups(query.term, {}, groupsCallback); + }; + } else { + projectsCallback = finalCallback; + } + if (_this.groupId) { + return Api.groupProjects( + _this.groupId, + query.term, + { + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled, + with_shared: _this.withShared, + include_subgroups: _this.includeProjectsInSubgroups, + }, + projectsCallback, + ); + } else { + return Api.projects( + query.term, + { + order_by: _this.orderBy, + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled, + membership: !_this.allProjects, + }, + projectsCallback, + ); + } }; - } else { - projectsCallback = finalCallback; - } - if (_this.groupId) { - return Api.groupProjects( - _this.groupId, - query.term, - { - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - with_shared: _this.withShared, - include_subgroups: _this.includeProjectsInSubgroups, - }, - projectsCallback, - ); - } else { - return Api.projects( - query.term, - { - order_by: _this.orderBy, - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - membership: !_this.allProjects, - }, - projectsCallback, - ); - } - }; - })(this), - id: function(project) { - if (simpleFilter) return project.id; - return JSON.stringify({ - name: project.name, - url: project.web_url, - }); - }, - text: function(project) { - return project.name_with_namespace || project.name; - }, + })(this), + id: function(project) { + if (simpleFilter) return project.id; + return JSON.stringify({ + name: project.name, + url: project.web_url, + }); + }, + text: function(project) { + return project.name_with_namespace || project.name; + }, - initSelection: function(el, callback) { - return Api.project(el.val()).then(({ data }) => callback(data)); - }, + initSelection: function(el, callback) { + return Api.project(el.val()).then(({ data }) => callback(data)); + }, - allowClear: this.allowClear, + allowClear: this.allowClear, - dropdownCssClass: 'ajax-project-dropdown', - }); - if (simpleFilter) return select; - return new ProjectSelectComboButton(select); - }); + dropdownCssClass: 'ajax-project-dropdown', + }); + if (simpleFilter) return select; + return new ProjectSelectComboButton(select); + }); + }) + .catch(() => {}); } diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js index 3dbac3ff942..d3b5f532dc1 100644 --- a/app/assets/javascripts/project_select_combo_button.js +++ b/app/assets/javascripts/project_select_combo_button.js @@ -44,9 +44,13 @@ export default class ProjectSelectComboButton { // eslint-disable-next-line class-methods-use-this openDropdown(event) { - $(event.currentTarget) - .siblings('.project-item-select') - .select2('open'); + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $(event.currentTarget) + .siblings('.project-item-select') + .select2('open'); + }) + .catch(() => {}); } selectProject() { diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index ce051582299..4017630d6ef 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -579,101 +579,109 @@ function UsersSelect(currentUser, els, options = {}) { }; })(this), ); - $('.ajax-users-select').each( - (function(_this) { - return function(i, select) { - var firstUser, showAnyUser, showEmailUser, showNullUser; - var options = {}; - options.skipLdap = $(select).hasClass('skip_ldap'); - options.projectId = $(select).data('projectId'); - options.groupId = $(select).data('groupId'); - options.showCurrentUser = $(select).data('currentUser'); - options.authorId = $(select).data('authorId'); - options.skipUsers = $(select).data('skipUsers'); - showNullUser = $(select).data('nullUser'); - showAnyUser = $(select).data('anyUser'); - showEmailUser = $(select).data('emailUser'); - firstUser = $(select).data('firstUser'); - return $(select).select2({ - placeholder: 'Search for a user', - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query: function(query) { - return _this.users(query.term, options, function(users) { - var anyUser, data, emailUser, index, len, name, nullUser, obj, ref; - data = { - results: users, - }; - if (query.term.length === 0) { - if (firstUser) { - // Move current user to the front of the list - ref = data.results; - - for (index = 0, len = ref.length; index < len; index += 1) { - obj = ref[index]; - if (obj.username === firstUser) { - data.results.splice(index, 1); - data.results.unshift(obj); - break; + import(/* webpackChunkName: 'select2' */ 'select2/select2') + .then(() => { + $('.ajax-users-select').each( + (function(_this) { + return function(i, select) { + var firstUser, showAnyUser, showEmailUser, showNullUser; + var options = {}; + options.skipLdap = $(select).hasClass('skip_ldap'); + options.projectId = $(select).data('projectId'); + options.groupId = $(select).data('groupId'); + options.showCurrentUser = $(select).data('currentUser'); + options.authorId = $(select).data('authorId'); + options.skipUsers = $(select).data('skipUsers'); + showNullUser = $(select).data('nullUser'); + showAnyUser = $(select).data('anyUser'); + showEmailUser = $(select).data('emailUser'); + firstUser = $(select).data('firstUser'); + return $(select).select2({ + placeholder: 'Search for a user', + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query: function(query) { + return _this.users(query.term, options, function(users) { + var anyUser, data, emailUser, index, len, name, nullUser, obj, ref; + data = { + results: users, + }; + if (query.term.length === 0) { + if (firstUser) { + // Move current user to the front of the list + ref = data.results; + + for (index = 0, len = ref.length; index < len; index += 1) { + obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; + } + } + } + if (showNullUser) { + nullUser = { + name: 'Unassigned', + id: 0, + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = 'Any User'; + } + anyUser = { + name: name, + id: null, + }; + data.results.unshift(anyUser); } } - } - if (showNullUser) { - nullUser = { - name: 'Unassigned', - id: 0, - }; - data.results.unshift(nullUser); - } - if (showAnyUser) { - name = showAnyUser; - if (name === true) { - name = 'Any User'; + if ( + showEmailUser && + data.results.length === 0 && + query.term.match(/^[^@]+@[^@]+$/) + ) { + var trimmed = query.term.trim(); + emailUser = { + name: 'Invite "' + trimmed + '" by email', + username: trimmed, + id: trimmed, + invite: true, + }; + data.results.unshift(emailUser); } - anyUser = { - name: name, - id: null, - }; - data.results.unshift(anyUser); - } - } - if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { - var trimmed = query.term.trim(); - emailUser = { - name: 'Invite "' + trimmed + '" by email', - username: trimmed, - id: trimmed, - invite: true, - }; - data.results.unshift(emailUser); - } - return query.callback(data); + return query.callback(data); + }); + }, + initSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.initSelection.apply(_this, args); + }, + formatResult: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatResult.apply(_this, args); + }, + formatSelection: function() { + var args; + args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return _this.formatSelection.apply(_this, args); + }, + dropdownCssClass: 'ajax-users-dropdown', + // we do not want to escape markup since we are displaying html in results + escapeMarkup: function(m) { + return m; + }, }); - }, - initSelection: function() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.initSelection.apply(_this, args); - }, - formatResult: function() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.formatResult.apply(_this, args); - }, - formatSelection: function() { - var args; - args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.formatSelection.apply(_this, args); - }, - dropdownCssClass: 'ajax-users-dropdown', - // we do not want to escape markup since we are displaying html in results - escapeMarkup: function(m) { - return m; - }, - }); - }; - })(this), - ); + }; + })(this), + ); + }) + .catch(() => {}); } UsersSelect.prototype.initSelection = function(element, callback) { -- cgit v1.2.3 From cf32e2620006d6eb0b2d62a4d26a4e3ca651e478 Mon Sep 17 00:00:00 2001 From: Marcia Ramos Date: Tue, 29 Jan 2019 13:18:08 +0000 Subject: Clarify DNS record for Pages --- doc/user/project/pages/getting_started_part_three.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/doc/user/project/pages/getting_started_part_three.md b/doc/user/project/pages/getting_started_part_three.md index cea9628966d..35e05f9aab2 100644 --- a/doc/user/project/pages/getting_started_part_three.md +++ b/doc/user/project/pages/getting_started_part_three.md @@ -79,11 +79,14 @@ running on your instance). ![DNS A record pointing to GitLab.com Pages server](img/dns_add_new_a_record_example_updated_2018.png) -NOTE: **Note:** -Note that if you use your root domain for your GitLab Pages website **only**, and if -your domain registrar supports this feature, you can add a DNS apex `CNAME` -record instead of an `A` record. The main advantage of doing so is that when GitLab Pages -IP on GitLab.com changes for whatever reason, you don't need to update your `A` record. +CAUTION: **Caution:** +Note that if you use your root domain for your GitLab Pages website +**only**, and if your domain registrar supports this feature, you can +add a DNS apex `CNAME` record instead of an `A` record. The main +advantage of doing so is that when GitLab Pages IP on GitLab.com +changes for whatever reason, you don't need to update your `A` record. +There may be a few exceptions, but **this method is not recommended** +as it most likely won't work if you set an `MX` record for your root domain. #### DNS CNAME record @@ -114,14 +117,16 @@ co-exist, so you need to place the TXT record in a special subdomain of its own. #### TL;DR -If the domain has multiple uses (e.g., you host email on it as well): +For root domains (`domain.com`), set a DNS `A` record and verify your +domain's ownership with a TXT record: | From | DNS Record | To | | ---- | ---------- | -- | | domain.com | A | 35.185.44.232 | | domain.com | TXT | gitlab-pages-verification-code=00112233445566778899aabbccddeeff | -If the domain is dedicated to GitLab Pages use and no other services run on it: +For subdomains (`subdomain.domain.com`), set a DNS `CNAME` record and +verify your domain's ownership with a TXT record: | From | DNS Record | To | | ---- | ---------- | -- | -- cgit v1.2.3 From 49df7b37bce2db3393e4e4f6a03f09797f3163d5 Mon Sep 17 00:00:00 2001 From: Caroline Wainaina Date: Tue, 29 Jan 2019 13:28:22 +0000 Subject: Add that "developer" permissions level can create projects in a group. --- doc/user/permissions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 0c358390046..bf4bd54e25c 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -173,7 +173,7 @@ group. | Browse group | ✓ | ✓ | ✓ | ✓ | ✓ | | Edit group | | | | | ✓ | | Create subgroup | | | | | ✓ | -| Create project in group | | | | ✓ | ✓ | +| Create project in group | | | ✓ | ✓ | ✓ | | Manage group members | | | | | ✓ | | Remove group | | | | | ✓ | | Manage group labels | | ✓ | ✓ | ✓ | ✓ | -- cgit v1.2.3 From 95ede02c798e647884e7b6c1f8c2a91e9c1c13fb Mon Sep 17 00:00:00 2001 From: Takuya Noguchi Date: Thu, 24 Jan 2019 03:17:09 +0900 Subject: Fix CSS grid on a new Project/Group Milestone Signed-off-by: Takuya Noguchi --- app/views/groups/milestones/_form.html.haml | 14 +++++++------- app/views/projects/milestones/_form.html.haml | 6 ++++-- app/views/shared/milestones/_form_dates.html.haml | 6 ++++-- .../56764-poor-ui-on-milestone-validation-error-page.yml | 5 +++++ 4 files changed, 20 insertions(+), 11 deletions(-) create mode 100644 changelogs/unreleased/56764-poor-ui-on-milestone-validation-error-page.yml diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml index b3d13a2dc43..b0ba846f204 100644 --- a/app/views/groups/milestones/_form.html.haml +++ b/app/views/groups/milestones/_form.html.haml @@ -1,20 +1,20 @@ = form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| + = form_errors(@milestone) .row - = form_errors(@milestone) - .col-md-6 .form-group.row - = f.label :title, "Title", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :title, "Title" .col-sm-10 = f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true .form-group.row.milestone-description - = f.label :description, "Description", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :description, "Description" .col-sm-10 = render layout: 'projects/md_preview', locals: { url: group_preview_markdown_path } do = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...', supports_autocomplete: false - .clearfix - .error-alert - + .clearfix + .error-alert = render "shared/milestones/form_dates", f: f .form-actions diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index 4779b5c434e..19f5bba75c4 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -5,11 +5,13 @@ .row .col-md-6 .form-group.row - = f.label :title, _('Title'), class: 'col-form-label col-sm-2' + .col-form-label.col-sm-2 + = f.label :title, _('Title') .col-sm-10 = f.text_field :title, maxlength: 255, class: 'qa-milestone-title form-control', required: true, autofocus: true .form-group.row.milestone-description - = f.label :description, _('Description'), class: 'col-form-label col-sm-2' + .col-form-label.col-sm-2 + = f.label :description, _('Description') .col-sm-10 = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project) } do = render 'projects/zen', f: f, attr: :description, classes: 'qa-milestone-description note-textarea', placeholder: _('Write milestone description...') diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml index 922805958a5..4de89d7c7a0 100644 --- a/app/views/shared/milestones/_form_dates.html.haml +++ b/app/views/shared/milestones/_form_dates.html.haml @@ -1,11 +1,13 @@ .col-md-6 .form-group.row - = f.label :start_date, "Start Date", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :start_date, "Start Date" .col-sm-10 = f.text_field :start_date, class: "datepicker form-control", placeholder: "Select start date", autocomplete: 'off' %a.inline.float-right.prepend-top-5.js-clear-start-date{ href: "#" } Clear start date .form-group.row - = f.label :due_date, "Due Date", class: "col-form-label col-sm-2" + .col-form-label.col-sm-2 + = f.label :due_date, "Due Date" .col-sm-10 = f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date", autocomplete: 'off' %a.inline.float-right.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date diff --git a/changelogs/unreleased/56764-poor-ui-on-milestone-validation-error-page.yml b/changelogs/unreleased/56764-poor-ui-on-milestone-validation-error-page.yml new file mode 100644 index 00000000000..089ffd47321 --- /dev/null +++ b/changelogs/unreleased/56764-poor-ui-on-milestone-validation-error-page.yml @@ -0,0 +1,5 @@ +--- +title: Fix CSS grid on a new Project/Group Milestone +merge_request: 24614 +author: Takuya Noguchi +type: fixed -- cgit v1.2.3 From 6fa5fd8515e0f2d5a6341134560021f353d84362 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Tue, 29 Jan 2019 07:49:59 -0800 Subject: Fix uninitialized constant with GitLab Pages deploy pages:deploy step was failing with the following error: ``` unitialized constant SafeZip::Extract::Zip ``` Since license_finder already pulls in rubyzip, we can make it a required gem. We also use the scope operator to make the reference to Zip::File explicit. --- Gemfile | 2 +- changelogs/unreleased/sh-fix-pages-zip-constant.yml | 5 +++++ lib/safe_zip/extract.rb | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/sh-fix-pages-zip-constant.yml diff --git a/Gemfile b/Gemfile index e632a083acf..f30b7fb3041 100644 --- a/Gemfile +++ b/Gemfile @@ -57,7 +57,7 @@ gem 'u2f', '~> 0.2.1' # GitLab Pages gem 'validates_hostname', '~> 1.0.6' -gem 'rubyzip', '~> 1.2.2', require: false +gem 'rubyzip', '~> 1.2.2' # Browser detection gem 'browser', '~> 2.5' diff --git a/changelogs/unreleased/sh-fix-pages-zip-constant.yml b/changelogs/unreleased/sh-fix-pages-zip-constant.yml new file mode 100644 index 00000000000..fcd8aa45825 --- /dev/null +++ b/changelogs/unreleased/sh-fix-pages-zip-constant.yml @@ -0,0 +1,5 @@ +--- +title: Fix uninitialized constant with GitLab Pages +merge_request: +author: +type: fixed diff --git a/lib/safe_zip/extract.rb b/lib/safe_zip/extract.rb index 3bd4935ef34..679c021c730 100644 --- a/lib/safe_zip/extract.rb +++ b/lib/safe_zip/extract.rb @@ -29,7 +29,7 @@ module SafeZip private def extract_with_ruby_zip(params) - Zip::File.open(archive_path) do |zip_archive| + ::Zip::File.open(archive_path) do |zip_archive| # Extract all files in the following order: # 1. Directories first, # 2. Files next, -- cgit v1.2.3 From 38fcb11f97d02af83cdc5d245e78a3f0d1b146f5 Mon Sep 17 00:00:00 2001 From: Mark Lapierre Date: Tue, 29 Jan 2019 15:56:34 -0500 Subject: Fix flaky wiki create test There's an svg on the page that allows you to create a wiki page. The svg takes a fraction of a second to load after which the "Create your first page" 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. Also update the elements used in the test to conform to our best practice. And replace `act` with `perform` Finally, remove the `before` block and `login` method, making the code slightly simpler. --- app/views/projects/wikis/_form.html.haml | 10 +++--- app/views/shared/empty_states/_wikis.html.haml | 2 +- .../shared/empty_states/_wikis_layout.html.haml | 2 +- qa/qa.rb | 1 + qa/qa/page/component/lazy_loader.rb | 15 ++++++++ qa/qa/page/label/index.rb | 8 ++--- qa/qa/page/project/wiki/new.rb | 40 +++++++++++++++------- .../wiki/create_edit_clone_push_wiki_spec.rb | 17 +++------ 8 files changed, 60 insertions(+), 35 deletions(-) create mode 100644 qa/qa/page/component/lazy_loader.rb diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 7d8826e540c..d1556dbd077 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -16,7 +16,7 @@ .form-group.row .col-sm-12= f.label :title, class: 'control-label-full-width' .col-sm-12 - = f.text_field :title, class: 'form-control', value: @page.title + = f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title - if @page.persisted? %span.edit-wiki-page-slug-tip = icon('lightbulb-o') @@ -31,7 +31,7 @@ .col-sm-12= f.label :content, class: 'control-label-full-width' .col-sm-12 = render layout: 'projects/md_preview', locals: { url: project_wiki_preview_markdown_path(@project, @page.slug) } do - = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: s_("WikiPage|Write your content or drag files here…") + = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea qa-wiki-content-textarea', placeholder: s_("WikiPage|Write your content or drag files here…") = render 'shared/notes/hints' .clearfix @@ -47,14 +47,14 @@ .form-group.row .col-sm-12= f.label :commit_message, class: 'control-label-full-width' - .col-sm-12= f.text_field :message, class: 'form-control', rows: 18, value: commit_message + .col-sm-12= f.text_field :message, class: 'form-control qa-wiki-message-textbox', rows: 18, value: commit_message .form-actions - if @page && @page.persisted? - = f.submit _("Save changes"), class: 'btn-success btn' + = f.submit _("Save changes"), class: 'btn-success btn qa-save-changes-button' .float-right = link_to _("Cancel"), project_wiki_path(@project, @page), class: 'btn btn-cancel btn-grouped' - else - = f.submit s_("Wiki|Create page"), class: 'btn-success btn' + = f.submit s_("Wiki|Create page"), class: 'btn-success btn qa-create-page-button' .float-right = link_to _("Cancel"), project_wiki_path(@project, :home), class: 'btn btn-cancel' diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml index df3308abe0d..73eedcc1dc9 100644 --- a/app/views/shared/empty_states/_wikis.html.haml +++ b/app/views/shared/empty_states/_wikis.html.haml @@ -2,7 +2,7 @@ - if can?(current_user, :create_wiki, @project) - create_path = project_wiki_path(@project, params[:id], { view: 'create' }) - - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn btn-success', title: s_('WikiEmpty|Create your first page') + - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn btn-success qa-create-first-page-link', title: s_('WikiEmpty|Create your first page') = render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do %h4.text-left diff --git a/app/views/shared/empty_states/_wikis_layout.html.haml b/app/views/shared/empty_states/_wikis_layout.html.haml index a5f100e3469..d44017299b8 100644 --- a/app/views/shared/empty_states/_wikis_layout.html.haml +++ b/app/views/shared/empty_states/_wikis_layout.html.haml @@ -1,6 +1,6 @@ .row.empty-state .col-12 - .svg-content + .svg-content.qa-svg-content = image_tag image_path .col-12 .text-content.text-center diff --git a/qa/qa.rb b/qa/qa.rb index 7aaf56bd51f..355034daec9 100644 --- a/qa/qa.rb +++ b/qa/qa.rb @@ -290,6 +290,7 @@ module QA # module Component autoload :ClonePanel, 'qa/page/component/clone_panel' + autoload :LazyLoader, 'qa/page/component/lazy_loader' autoload :LegacyClonePanel, 'qa/page/component/legacy_clone_panel' autoload :Dropzone, 'qa/page/component/dropzone' autoload :GroupsFilter, 'qa/page/component/groups_filter' diff --git a/qa/qa/page/component/lazy_loader.rb b/qa/qa/page/component/lazy_loader.rb new file mode 100644 index 00000000000..6f74a4691ba --- /dev/null +++ b/qa/qa/page/component/lazy_loader.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module QA + module Page + module Component + module LazyLoader + def self.included(base) + base.view 'app/assets/javascripts/lazy_loader.js' do + element :js_lazy_loaded + end + end + end + end + end +end diff --git a/qa/qa/page/label/index.rb b/qa/qa/page/label/index.rb index 97ce8f0eba5..f0d323ca3b4 100644 --- a/qa/qa/page/label/index.rb +++ b/qa/qa/page/label/index.rb @@ -1,7 +1,11 @@ +# frozen_string_literal: true + module QA module Page module Label class Index < Page::Base + include Component::LazyLoader + view 'app/views/shared/labels/_nav.html.haml' do element :label_create_new end @@ -10,10 +14,6 @@ module QA element :label_svg end - view 'app/assets/javascripts/lazy_loader.js' do - element :js_lazy_loaded - end - def go_to_new_label # The 'labels.svg' takes a fraction of a second to load after which the "New label" button shifts up a bit # This can cause webdriver to miss the hit so we wait for the svg to load (implicitly with has_element?) diff --git a/qa/qa/page/project/wiki/new.rb b/qa/qa/page/project/wiki/new.rb index 2498af8600c..b90e03be36a 100644 --- a/qa/qa/page/project/wiki/new.rb +++ b/qa/qa/page/project/wiki/new.rb @@ -1,42 +1,58 @@ +# frozen_string_literal: true + module QA module Page module Project module Wiki class New < Page::Base + include Component::LazyLoader + view 'app/views/projects/wikis/_form.html.haml' do - element :wiki_title_textbox, 'text_field :title' # rubocop:disable QA/ElementWithPattern - element :wiki_content_textarea, "render 'projects/zen', f: f, attr: :content" # rubocop:disable QA/ElementWithPattern - element :wiki_message_textbox, 'text_field :message' # rubocop:disable QA/ElementWithPattern - element :save_changes_button, 'submit _("Save changes")' # rubocop:disable QA/ElementWithPattern - element :create_page_button, 'submit s_("Wiki|Create page")' # rubocop:disable QA/ElementWithPattern + element :wiki_title_textbox + element :wiki_content_textarea + element :wiki_message_textbox + element :save_changes_button + element :create_page_button end view 'app/views/shared/empty_states/_wikis.html.haml' do - element :create_link, 'Create your first page' # rubocop:disable QA/ElementWithPattern + element :create_first_page_link + end + + view 'app/views/shared/empty_states/_wikis_layout.html.haml' do + element :svg_content end def go_to_create_first_page - click_link 'Create your first page' + # The svg takes a fraction of a second to load after which the + # "Create your first page" 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_page_link end def set_title(title) - fill_in 'wiki_title', with: title + fill_element :wiki_title_textbox, title end def set_content(content) - fill_in 'wiki_content', with: content + fill_element :wiki_content_textarea, content end def set_message(message) - fill_in 'wiki_message', with: message + fill_element :wiki_message_textbox, message end def save_changes - click_on 'Save changes' + click_element :save_changes_button end def create_new_page - click_on 'Create page' + click_element :create_page_button end end end diff --git a/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb b/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb index a7d0998d42c..29589ec870a 100644 --- a/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/wiki/create_edit_clone_push_wiki_spec.rb @@ -3,22 +3,15 @@ module QA context 'Create' do describe 'Wiki management' do - def login - Runtime::Browser.visit(:gitlab, Page::Main::Login) - Page::Main::Login.act { sign_in_using_credentials } - end - def validate_content(content) expect(page).to have_content('Wiki was successfully updated') expect(page).to have_content(/#{content}/) end - before do - login - end + it 'user creates, edits, clones, and pushes to the wiki' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + Page::Main::Login.perform(&:sign_in_using_credentials) - # Failure reported: https://gitlab.com/gitlab-org/quality/nightly/issues/24 - it 'user creates, edits, clones, and pushes to the wiki', :quarantine do wiki = Resource::Wiki.fabricate! do |resource| resource.title = 'Home' resource.content = '# My First Wiki Content' @@ -27,7 +20,7 @@ module QA validate_content('My First Wiki Content') - Page::Project::Wiki::Edit.act { go_to_edit_page } + Page::Project::Wiki::Edit.perform(&:go_to_edit_page) Page::Project::Wiki::New.perform do |page| page.set_content("My Second Wiki Content") page.save_changes @@ -41,7 +34,7 @@ module QA push.file_content = '# My Third Wiki Content' push.commit_message = 'Update Home.md' end - Page::Project::Menu.act { click_wiki } + Page::Project::Menu.perform(&:click_wiki) expect(page).to have_content('My Third Wiki Content') end -- cgit v1.2.3 From aca9ce3eb61b4af37ee47daaf03c5e1e7a06a15d Mon Sep 17 00:00:00 2001 From: GitLab Release Tools Bot Date: Tue, 29 Jan 2019 23:35:05 +0000 Subject: Update CHANGELOG.md for 11.7.2 [ci skip] --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98c4c8f9233..594a632f0da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 11.7.2 (2019-01-29) + +### Security (24 changes) + +- Make potentially malicious links more visible in the UI and scrub RTLO chars from links. !2770 +- Don't process MR refs for guests in the notes. !2771 +- Sanitize user full name to clean up any URL to prevent mail clients from auto-linking URLs. !2828 +- Fixed XSS content in KaTex links. +- Disallows unauthorized users from accessing the pipelines section. +- Verify that LFS upload requests are genuine. +- Extract GitLab Pages using RubyZip. +- Prevent awarding emojis to notes whose parent is not visible to user. +- Prevent unauthorized replies when discussion is locked or confidential. +- Disable git v2 protocol temporarily. +- Fix showing ci status for guest users when public pipline are not set. +- Fix contributed projects info still visible when user enable private profile. +- Add subresources removal to member destroy service. +- Add more LFS validations to prevent forgery. +- Use common error for unauthenticated users when creating issues. +- Fix slow regex in project reference pattern. +- Fix private user email being visible in push (and tag push) webhooks. +- Fix wiki access rights when external wiki is enabled. +- Group guests are no longer able to see merge requests they don't have access to at group level. +- Fix path disclosure on project import error. +- Restrict project import visibility based on its group. +- Expose CI/CD trigger token only to the trigger owner. +- Notify only users who can access the project on project move. +- Alias GitHub and BitBucket OAuth2 callback URLs. + +### Fixed (1 change) + +- Fix uninitialized constant with GitLab Pages. + + ## 11.7.1 (2019-01-28) ### Security (24 changes) -- cgit v1.2.3 From 5051f6549732bead5d4df5cc576c6104c5afa6f3 Mon Sep 17 00:00:00 2001 From: syasonik Date: Tue, 29 Jan 2019 17:58:04 -0800 Subject: Refactor envs helper for clarity --- app/controllers/projects/environments_controller.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 1a1b024d766..4e85de25c6b 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -182,11 +182,11 @@ class Projects::EnvironmentsController < Projects::ApplicationController end def serialize_environments(request, response, nested = false) - serializer = EnvironmentSerializer + EnvironmentSerializer .new(project: @project, current_user: @current_user) + .tap { |serializer| serializer.within_folders if nested } .with_pagination(request, response) - serializer = serializer.within_folders if nested - serializer.represent(@environments) + .represent(@environments) end def authorize_stop_environment! -- cgit v1.2.3 From f942a08d23923afc7c99d750922ff61018cbcbf8 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Tue, 29 Jan 2019 18:25:53 +0900 Subject: Fix Markdown of release notes It was leaings confidential issue titles and MR titles to any users Fix spec Fix spec Fix tests --- lib/api/entities.rb | 4 +++- spec/requests/api/releases_spec.rb | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 62278360329..3341dd92ded 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -1116,7 +1116,9 @@ module API class Release < TagRelease expose :name - expose :description_html + expose :description_html do |entity| + MarkupHelper.markdown_field(entity, :description) + end expose :created_at expose :author, using: Entities::UserBasic, if: -> (release, _) { release.author.present? } expose :commit, using: Entities::Commit diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb index 811e23fb854..1f317971a66 100644 --- a/spec/requests/api/releases_spec.rb +++ b/spec/requests/api/releases_spec.rb @@ -127,6 +127,31 @@ describe API::Releases do .to match_array(release.sources.map(&:url)) end + context "when release description contains confidential issue's link" do + let(:confidential_issue) do + create(:issue, + :confidential, + project: project, + title: 'A vulnerability') + end + + let!(:release) do + create(:release, + project: project, + tag: 'v0.1', + sha: commit.id, + author: maintainer, + description: "This is confidential #{confidential_issue.to_reference}") + end + + it "does not expose confidential issue's title" do + get api("/projects/#{project.id}/releases/v0.1", maintainer) + + expect(json_response['description_html']).to include(confidential_issue.to_reference) + expect(json_response['description_html']).not_to include('A vulnerability') + end + end + context 'when release has link asset' do let!(:link) do create(:release_link, -- cgit v1.2.3 From a051dd6ac87ebbbad196441c9aed7a4bef66d542 Mon Sep 17 00:00:00 2001 From: Steve Azzopardi Date: Tue, 29 Jan 2019 09:23:31 +0100 Subject: Update web terminal note for shared runners The current documentation suggests that a user can not use his own runner for a specific group or project. At first I thought this was the case but it is not true, since we do allow websocket connections on GitLab.com, this was discovered as part of the investigation on https://gitlab.com/gitlab-org/gitlab-ce/issues/52611. --- doc/ci/interactive_web_terminal/index.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/doc/ci/interactive_web_terminal/index.md b/doc/ci/interactive_web_terminal/index.md index 0cf9daed22f..2a4160f62b0 100644 --- a/doc/ci/interactive_web_terminal/index.md +++ b/doc/ci/interactive_web_terminal/index.md @@ -1,4 +1,4 @@ -# Interactive Web Terminals **[CORE ONLY]** +# Interactive Web Terminals > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/50144) in GitLab 11.3. @@ -9,10 +9,11 @@ is deployed, some [security precautions](../../administration/integration/termin taken to protect the users. NOTE: **Note:** -GitLab.com does not support interactive web terminal at the moment – neither -using shared GitLab.com runners nor your own runners. Please follow -[this issue](https://gitlab.com/gitlab-org/gitlab-ce/issues/52611) for -progress. +[Shared runners on GitLab.com](../quick_start/README.md#shared-runners) do not +provide an interactive web terminal. Follow [this +issue](https://gitlab.com/gitlab-org/gitlab-ce/issues/52611) for progress on +adding support. For groups and projects hosted on GitLab.com, interactive web +terminals are available when using your own group or project runner. ## Configuration -- cgit v1.2.3 From 9008d93731a0f058497db07194471744b41235e4 Mon Sep 17 00:00:00 2001 From: Oliver Bristow Date: Fri, 18 Jan 2019 12:28:07 +0000 Subject: Highlight when CI_ENVIRONMENT_* environment args are present --- doc/ci/variables/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 25d189afb24..706c97526b4 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -58,9 +58,9 @@ future GitLab releases.** | **CI_DEPLOY_PASSWORD** | 10.8 | all | Authentication password of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.| | **CI_DEPLOY_USER** | 10.8 | all | Authentication username of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.| | **CI_DISPOSABLE_ENVIRONMENT** | all | 10.1 | Marks that the job is executed in a disposable environment (something that is created only for this job and disposed of/destroyed after the execution - all executors except `shell` and `ssh`). If the environment is disposable, it is set to true, otherwise it is not defined at all. | -| **CI_ENVIRONMENT_NAME** | 8.15 | all | The name of the environment for this job | -| **CI_ENVIRONMENT_SLUG** | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. | -| **CI_ENVIRONMENT_URL** | 9.3 | all | The URL of the environment for this job | +| **CI_ENVIRONMENT_NAME** | 8.15 | all | The name of the environment for this job. Only present if [`environment:name`](../yaml/README.md#environmenturl) is set. | +| **CI_ENVIRONMENT_SLUG** | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. Only present if [`environment:name`](../yaml/README.md#environmentname) is set. | +| **CI_ENVIRONMENT_URL** | 9.3 | all | The URL of the environment for this job. Only present if [`environment:url`](../yaml/README.md#environmenturl) is set. | | **CI_JOB_ID** | 9.0 | all | The unique id of the current job that GitLab CI uses internally | | **CI_JOB_MANUAL** | 8.12 | all | The flag to indicate that job was manually started | | **CI_JOB_NAME** | 9.0 | 0.5 | The name of the job as defined in `.gitlab-ci.yml` | -- cgit v1.2.3 From 913c2691c0ae6cb82cbf5f101aa112fc0a60dca5 Mon Sep 17 00:00:00 2001 From: Marcel Amirault Date: Wed, 30 Jan 2019 10:43:39 +0000 Subject: Change non-standard NBSP space to regular SP space --- doc/api/project_clusters.md | 22 +++++++++++----------- doc/ci/variables/README.md | 2 +- doc/user/project/pages/introduction.md | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/doc/api/project_clusters.md b/doc/api/project_clusters.md index 034b9172ffa..8efb98fe1fc 100644 --- a/doc/api/project_clusters.md +++ b/doc/api/project_clusters.md @@ -76,7 +76,7 @@ Parameters: | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `id` | integer | yes | The ID of the project owned by the authenticated user | -| `cluster_id` | integer | yes | The ID of the cluster | +| `cluster_id` | integer | yes | The ID of the cluster | Example request: @@ -157,12 +157,12 @@ Parameters: | --------- | ---- | -------- | ----------- | | `id` | integer | yes | The ID of the project owned by the authenticated user | | `name` | String | yes | The name of the cluster | -| `enabled` | Boolean | no | Determines if cluster is active or not, defaults to true | -| `platform_kubernetes_attributes[api_url]` | String | yes | The URL to access the Kubernetes API | +| `enabled` | Boolean | no | Determines if cluster is active or not, defaults to true | +| `platform_kubernetes_attributes[api_url]` | String | yes | The URL to access the Kubernetes API | | `platform_kubernetes_attributes[token]` | String | yes | The token to authenticate against Kubernetes | -| `platform_kubernetes_attributes[ca_cert]` | String | no | TLS certificate (needed if API is using a self-signed TLS certificate | -| `platform_kubernetes_attributes[namespace]` | String | no | The unique namespace related to the project | -| `platform_kubernetes_attributes[authorization_type]` | String | no | The cluster authorization type: `rbac`, `abac` or `unknown_authorization`. Defaults to `rbac`. | +| `platform_kubernetes_attributes[ca_cert]` | String | no | TLS certificate (needed if API is using a self-signed TLS certificate | +| `platform_kubernetes_attributes[namespace]` | String | no | The unique namespace related to the project | +| `platform_kubernetes_attributes[authorization_type]` | String | no | The cluster authorization type: `rbac`, `abac` or `unknown_authorization`. Defaults to `rbac`. | Example request: @@ -246,11 +246,11 @@ Parameters: | --------- | ---- | -------- | ----------- | | `id` | integer | yes | The ID of the project owned by the authenticated user | | `cluster_id` | integer | yes | The ID of the cluster | -| `name` | String | no | The name of the cluster | -| `platform_kubernetes_attributes[api_url]` | String | no | The URL to access the Kubernetes API | -| `platform_kubernetes_attributes[token]` | String | no | The token to authenticate against Kubernetes | -| `platform_kubernetes_attributes[ca_cert]` | String | no | TLS certificate (needed if API is using a self-signed TLS certificate | -| `platform_kubernetes_attributes[namespace]` | String | no | The unique namespace related to the project | +| `name` | String | no | The name of the cluster | +| `platform_kubernetes_attributes[api_url]` | String | no | The URL to access the Kubernetes API | +| `platform_kubernetes_attributes[token]` | String | no | The token to authenticate against Kubernetes | +| `platform_kubernetes_attributes[ca_cert]` | String | no | TLS certificate (needed if API is using a self-signed TLS certificate | +| `platform_kubernetes_attributes[namespace]` | String | no | The unique namespace related to the project | NOTE: **Note:** `name`, `api_url`, `ca_cert` and `token` can only be updated if the cluster was added diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index ac46e0eedc6..64638e856cc 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -59,7 +59,7 @@ future GitLab releases.** | **CI_COMMIT_TITLE** | 10.8 | all | The title of the commit - the full first line of the message | | **CI_CONFIG_PATH** | 9.4 | 0.5 | The path to CI config file. Defaults to `.gitlab-ci.yml` | | **CI_DEBUG_TRACE** | all | 1.7 | Whether [debug tracing](#debug-tracing) is enabled | -| **CI_DEPLOY_PASSWORD** | 10.8 | all | Authentication password of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.| +| **CI_DEPLOY_PASSWORD** | 10.8 | all | Authentication password of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.| | **CI_DEPLOY_USER** | 10.8 | all | Authentication username of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.| | **CI_DISPOSABLE_ENVIRONMENT** | all | 10.1 | Marks that the job is executed in a disposable environment (something that is created only for this job and disposed of/destroyed after the execution - all executors except `shell` and `ssh`). If the environment is disposable, it is set to true, otherwise it is not defined at all. | | **CI_ENVIRONMENT_NAME** | 8.15 | all | The name of the environment for this job | diff --git a/doc/user/project/pages/introduction.md b/doc/user/project/pages/introduction.md index a7846b1ee18..2bb6fcd9d74 100644 --- a/doc/user/project/pages/introduction.md +++ b/doc/user/project/pages/introduction.md @@ -178,7 +178,7 @@ Supposed your repository contained the following files: ``` ├── index.html ├── css -│   └── main.css +│ └── main.css └── js └── main.js ``` @@ -333,7 +333,7 @@ public/ │ └ index.html.gz │ ├── css/ -│   └─┬ main.css +│ └─┬ main.css │ └ main.css.gz │ └── js/ -- cgit v1.2.3 From fdc252f1835d1de9f46818cc1eebfcb38c5c1cae Mon Sep 17 00:00:00 2001 From: Star Light Date: Wed, 30 Jan 2019 13:09:16 +0000 Subject: The GitLab Pages IP address for GitLab.com changed from 52.167.214.135 to 35.185.44.232 in August 2018. (https://about.gitlab.com/2017/02/07/setting-up-gitlab-pages-with-cloudflare-certificates/#step-1-dns-record) --- doc/user/gitlab_com/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md index 68a0f1a5837..c1c9b8bf43c 100644 --- a/doc/user/gitlab_com/index.md +++ b/doc/user/gitlab_com/index.md @@ -46,7 +46,7 @@ Below are the settings for [GitLab Pages]. | Setting | GitLab.com | Default | | ----------------------- | ---------------- | ------------- | | Domain name | `gitlab.io` | - | -| IP address | `52.167.214.135` | - | +| IP address | `35.185.44.232` | - | | Custom domains support | yes | no | | TLS certificates support| yes | no | -- cgit v1.2.3 From 253b88e325e4b22b70af7cf936c19c1dbd6a54b8 Mon Sep 17 00:00:00 2001 From: Winnie Hellmann Date: Mon, 14 Jan 2019 17:37:49 +0100 Subject: Add ee_else_ce alias to Webpack config (cherry picked from commit da3cd00f5a31f762eb67c2824233ad2b275b2ba8) Conflicts: config/webpack.config.js --- config/webpack.config.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/webpack.config.js b/config/webpack.config.js index b9044e13f50..fdf179b007a 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -94,6 +94,9 @@ module.exports = { vendor: path.join(ROOT_PATH, 'vendor/assets/javascripts'), vue$: 'vue/dist/vue.esm.js', spec: path.join(ROOT_PATH, 'spec/javascripts'), + + // the following resolves files which are different between CE and EE + ee_else_ce: path.join(ROOT_PATH, 'app/assets/javascripts'), }, }, -- cgit v1.2.3 From 57381b9d9c9d47e40420f7914f7d1e3fe02e9116 Mon Sep 17 00:00:00 2001 From: Winnie Hellmann Date: Mon, 14 Jan 2019 17:38:49 +0100 Subject: Use ee_else_ce alias in merge request widget (cherry picked from commit 5c35cdc929abf5251512ba6ddf7078e570232709) --- app/assets/javascripts/vue_merge_request_widget/index.js | 2 +- .../javascripts/vue_merge_request_widget/mr_widget_options.vue | 6 +++--- .../javascripts/vue_merge_request_widget/stores/mr_widget_store.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 60cebbfc2b2..0cedbdbdfef 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import MrWidgetOptions from './ee_switch_mr_widget_options'; +import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; import Translate from '../vue_shared/translate'; Vue.use(Translate); diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 5a9d86594b1..0ce9d271845 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -3,6 +3,9 @@ import _ from 'underscore'; import { __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; +import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; +import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; +import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; import createFlash from '../flash'; import WidgetHeader from './components/mr_widget_header.vue'; import WidgetMergeHelp from './components/mr_widget_merge_help.vue'; @@ -28,10 +31,7 @@ import FailedToMerge from './components/states/mr_widget_failed_to_merge.vue'; import MergeWhenPipelineSucceedsState from './components/states/mr_widget_merge_when_pipeline_succeeds.vue'; import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue'; import CheckingState from './components/states/mr_widget_checking.vue'; -import MRWidgetStore from './stores/ee_switch_mr_widget_store'; -import MRWidgetService from './services/ee_switch_mr_widget_service'; import eventHub from './event_hub'; -import stateMaps from './stores/ee_switch_state_maps'; import notify from '~/lib/utils/notify'; import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue'; import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue'; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index e5a52c6a7f6..ab194e84ab4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -1,5 +1,5 @@ import Timeago from 'timeago.js'; -import getStateKey from './ee_switch_get_state_key'; +import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; import { stateKey } from './state_maps'; import { formatDate } from '../../lib/utils/datetime_utility'; -- cgit v1.2.3 From 71cc198fc73c379e9b6edcf6c84578aab8545a05 Mon Sep 17 00:00:00 2001 From: Winnie Hellmann Date: Mon, 14 Jan 2019 17:39:11 +0100 Subject: Delete ee_switch files from merge request widget (cherry picked from commit 9c64af3c2821d4f63e4a48903f4d3099bb85cebd) --- .../vue_merge_request_widget/ee_switch_mr_widget_options.js | 3 --- .../vue_merge_request_widget/services/ee_switch_mr_widget_service.js | 3 --- .../vue_merge_request_widget/stores/ee_switch_get_state_key.js | 3 --- .../vue_merge_request_widget/stores/ee_switch_mr_widget_store.js | 3 --- .../vue_merge_request_widget/stores/ee_switch_state_maps.js | 3 --- 5 files changed, 15 deletions(-) delete mode 100644 app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js delete mode 100644 app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js delete mode 100644 app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js delete mode 100644 app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js delete mode 100644 app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js diff --git a/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js deleted file mode 100644 index 8780aa4bd1c..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js +++ /dev/null @@ -1,3 +0,0 @@ -import MRWidgetOptions from './mr_widget_options.vue'; - -export default MRWidgetOptions; diff --git a/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js deleted file mode 100644 index ea2aabb78fe..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js +++ /dev/null @@ -1,3 +0,0 @@ -import MRWidgetService from './mr_widget_service'; - -export default MRWidgetService; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js deleted file mode 100644 index ebef30e3eab..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js +++ /dev/null @@ -1,3 +0,0 @@ -import getStateKey from './get_state_key'; - -export default getStateKey; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js deleted file mode 100644 index 92a07c53f2d..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js +++ /dev/null @@ -1,3 +0,0 @@ -import MergeRequestStore from './mr_widget_store'; - -export default MergeRequestStore; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js deleted file mode 100644 index 50cf9503ea7..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js +++ /dev/null @@ -1,3 +0,0 @@ -import stateMaps from './state_maps'; - -export default stateMaps; -- cgit v1.2.3 From 9d57d50a4f531a09b5377e2b6a40e4a61ee2cfd4 Mon Sep 17 00:00:00 2001 From: Winnie Hellmann Date: Wed, 30 Jan 2019 14:02:44 +0000 Subject: Document ee_else_ce alias for splitting CE/EE JavaScript --- doc/development/ee_features.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md index 790b1bf951b..e0985922443 100644 --- a/doc/development/ee_features.md +++ b/doc/development/ee_features.md @@ -839,6 +839,20 @@ For example there can be an `app/assets/javascripts/protected_branches/protected_branches_bundle.js` and an EE counterpart `ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js`. +The corresponding import statement would then look like this: + +```javascript +// app/assets/javascripts/protected_branches/protected_branches_bundle.js +import bundle from '~/protected_branches/protected_branches_bundle.js'; + +// ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js +// (only works in EE) +import bundle from 'ee/protected_branches/protected_branches_bundle.js'; + +// in CE: app/assets/javascripts/protected_branches/protected_branches_bundle.js +// in EE: ee/app/assets/javascripts/protected_branches/protected_branches_bundle.js +import bundle from 'ee_else_ce/protected_branches/protected_branches_bundle.js'; +``` See the frontend guide [performance section](./fe_guide/performance.md) for information on managing page-specific javascript within EE. -- cgit v1.2.3 From ec22c9d2e5f1bbec25db1d87ac2b9bf8c12e8d42 Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Wed, 30 Jan 2019 14:16:32 +0000 Subject: Fix requiring the rubyzip Gem In commit 6fa5fd8515e0f2d5a6341134560021f353d84362 the `require: false` was removed to ensure the Gem was loaded at run time. Unfortunately, the `require` necessary for the rubyzip Gem is "zip" and not "rubyzip". As a result, Bundler would not require the Gem. This meant that we would still run into constant errors when referring to `Zip::File`. --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index f30b7fb3041..970e3dd871e 100644 --- a/Gemfile +++ b/Gemfile @@ -57,7 +57,7 @@ gem 'u2f', '~> 0.2.1' # GitLab Pages gem 'validates_hostname', '~> 1.0.6' -gem 'rubyzip', '~> 1.2.2' +gem 'rubyzip', '~> 1.2.2', require: 'zip' # Browser detection gem 'browser', '~> 2.5' -- cgit v1.2.3 From 3d1aff8796682c83f4f47b2657b9e5f424af2d0a Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Sun, 27 Jan 2019 21:36:23 +0100 Subject: Clarify search parameter in the branches API docs --- doc/api/branches.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/doc/api/branches.md b/doc/api/branches.md index 8d5f333ba77..62468b6e917 100644 --- a/doc/api/branches.md +++ b/doc/api/branches.md @@ -18,11 +18,10 @@ GET /projects/:id/repository/branches Parameters: -| Attribute | Type | Required | Description | -|:----------|:---------------|:---------|:-------------------------------------------------------------------------------------------------------------| -| `id` | integer/string | yes | ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user. | -| `search` | string | no | Return list of branches matching the search criteria. | - +| Attribute | Type | Required | Description | +|:----------|:---------------|:---------|:------------| +| `id` | integer/string | yes | ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user.| +| `search` | string | no | Return list of branches containing the search string. You can use `^term` and `term$` to find branches that begin and end with `term` respectively.| Example request: ```sh -- cgit v1.2.3 From 861f93e6fc72ef4adbc4ace3fd297382e07b619c Mon Sep 17 00:00:00 2001 From: Winnie Hellmann Date: Wed, 30 Jan 2019 17:34:06 +0100 Subject: Convert noteable_note_spec.js to Vue test utils --- .../notes/components/noteable_note_spec.js | 93 ++++++++++++++++------ 1 file changed, 70 insertions(+), 23 deletions(-) diff --git a/spec/javascripts/notes/components/noteable_note_spec.js b/spec/javascripts/notes/components/noteable_note_spec.js index 8ade6fc2ced..4a143ef089a 100644 --- a/spec/javascripts/notes/components/noteable_note_spec.js +++ b/spec/javascripts/notes/components/noteable_note_spec.js @@ -1,64 +1,105 @@ -import $ from 'jquery'; import _ from 'underscore'; -import Vue from 'vue'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; import createStore from '~/notes/stores'; import issueNote from '~/notes/components/noteable_note.vue'; +import NoteHeader from '~/notes/components/note_header.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import NoteActions from '~/notes/components/note_actions.vue'; +import NoteBody from '~/notes/components/note_body.vue'; import { noteableDataMock, notesDataMock, note } from '../mock_data'; describe('issue_note', () => { let store; - let vm; + let wrapper; beforeEach(() => { - const Component = Vue.extend(issueNote); - store = createStore(); store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); - vm = new Component({ + const localVue = createLocalVue(); + wrapper = shallowMount(issueNote, { store, propsData: { note, }, - }).$mount(); + sync: false, + localVue, + }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('should render user information', () => { - expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual( - note.author.avatar_url, - ); + const { author } = note; + const avatar = wrapper.find(UserAvatarLink); + const avatarProps = avatar.props(); + + expect(avatarProps.linkHref).toBe(author.path); + expect(avatarProps.imgSrc).toBe(author.avatar_url); + expect(avatarProps.imgAlt).toBe(author.name); + expect(avatarProps.imgSize).toBe(40); }); it('should render note header content', () => { - const el = vm.$el.querySelector('.note-header .note-header-author-name'); + const noteHeader = wrapper.find(NoteHeader); + const noteHeaderProps = noteHeader.props(); - expect(el.textContent.trim()).toEqual(note.author.name); + expect(noteHeaderProps.author).toEqual(note.author); + expect(noteHeaderProps.createdAt).toEqual(note.created_at); + expect(noteHeaderProps.noteId).toEqual(note.id); }); it('should render note actions', () => { - expect(vm.$el.querySelector('.note-actions')).toBeDefined(); + const { author } = note; + const noteActions = wrapper.find(NoteActions); + const noteActionsProps = noteActions.props(); + + expect(noteActionsProps.authorId).toBe(author.id); + expect(noteActionsProps.noteId).toBe(note.id); + expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url); + expect(noteActionsProps.accessLevel).toBe(note.human_access); + expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit); + expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji); + expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit); + expect(noteActionsProps.canReportAsAbuse).toBe(true); + expect(noteActionsProps.canResolve).toBe(false); + expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path); + expect(noteActionsProps.resolvable).toBe(false); + expect(noteActionsProps.isResolved).toBe(false); + expect(noteActionsProps.isResolving).toBe(false); + expect(noteActionsProps.resolvedBy).toEqual({}); }); it('should render issue body', () => { - expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html); + const noteBody = wrapper.find(NoteBody); + const noteBodyProps = noteBody.props(); + + expect(noteBodyProps.note).toEqual(note); + expect(noteBodyProps.line).toBe(null); + expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit); + expect(noteBodyProps.isEditing).toBe(false); + expect(noteBodyProps.helpPagePath).toBe(''); }); it('prevents note preview xss', done => { const imgSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; const noteBody = ``; const alertSpy = spyOn(window, 'alert'); - vm.updateNote = () => new Promise($.noop); + store.hotUpdate({ + actions: { + updateNote() {}, + }, + }); + const noteBodyComponent = wrapper.find(NoteBody); - vm.formUpdateHandler(noteBody, null, $.noop); + noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {}); setTimeout(() => { expect(alertSpy).not.toHaveBeenCalled(); - expect(vm.note.note_html).toEqual(_.escape(noteBody)); + expect(wrapper.vm.note.note_html).toEqual(_.escape(noteBody)); done(); }, 0); }); @@ -66,17 +107,23 @@ describe('issue_note', () => { describe('cancel edit', () => { it('restores content of updated note', done => { const noteBody = 'updated note text'; - vm.updateNote = () => Promise.resolve(); + store.hotUpdate({ + actions: { + updateNote() {}, + }, + }); + const noteBodyComponent = wrapper.find(NoteBody); + noteBodyComponent.vm.resetAutoSave = () => {}; - vm.formUpdateHandler(noteBody, null, $.noop); + noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {}); setTimeout(() => { - expect(vm.note.note_html).toEqual(noteBody); + expect(wrapper.vm.note.note_html).toEqual(noteBody); - vm.formCancelHandler(); + noteBodyComponent.vm.$emit('cancelForm'); setTimeout(() => { - expect(vm.note.note_html).toEqual(noteBody); + expect(wrapper.vm.note.note_html).toEqual(noteBody); done(); }); -- cgit v1.2.3 From 6afa5770f630536d5caf88375c88a4c9df69b8b6 Mon Sep 17 00:00:00 2001 From: GitLab Release Tools Bot Date: Wed, 30 Jan 2019 18:11:05 +0000 Subject: Update CHANGELOG.md for 11.6.8 [ci skip] --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 594a632f0da..37bff7e50a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -252,6 +252,10 @@ entry. - Update url placeholder for the sentry configuration page. !24338 +## 11.6.8 (2019-01-30) + +- No changes. + ## 11.6.5 (2019-01-17) ### Fixed (5 changes) -- cgit v1.2.3 From dede8a0daee1e5a6f0f29bc82688e07f8b93f3d3 Mon Sep 17 00:00:00 2001 From: danielgruesso Date: Fri, 25 Jan 2019 14:25:16 -0500 Subject: Update tm cli version and clarify steps --- doc/user/project/clusters/serverless/index.md | 50 +++++++++++++++++---------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md index bebccf97987..85d41777ce6 100644 --- a/doc/user/project/clusters/serverless/index.md +++ b/doc/user/project/clusters/serverless/index.md @@ -82,7 +82,27 @@ Currently the following [runtimes](https://gitlab.com/triggermesh/runtimes) are - node.js - kaniko -In order to deploy functions to your Knative instance, the following files must be present: +You can find all the files referenced in this doc in the [functions example project](https://gitlab.com/knative-examples/functions). + +Follow these steps to deploy a function using the node.js runtime to your Knative instance: + +1. Create a directory that will house the function. In this example we will create a directory called `echo` at the root of the project. + +1. Create the file that will contain the function code. In this example our file is called `echo.js` and is located inside the `echo` directory. + + If your project is public, skip to step no. 4. + +1. If your project is private you will need to [Create a GitLab deploy token](https://docs.gitlab.com/ee/user/project/deploy_tokens/#creating-a-deploy-token). +This will enable the `tm` cli to be used as a deployment step and access the container registry. + + 1. Go to the project you want to create the function for. + 1. Go to **Settings** > **Repository**. + 1. Click on "Expand" on **Deploy Tokens** section. + 1. Enter `gitlab-deploy-token` as the name. + 1. Check the `read_registry` scope + 1. Click on **Create deploy token**. + 1. Save the deploy token somewhere safe. Once you leave or refresh + the page, **you won't be able to access it again**. 1. `.gitlab-ci.yml`: This template allows to define the stage, environment, and image to be used for your functions. It must be included at the root of your repository: @@ -94,10 +114,12 @@ In order to deploy functions to your Knative instance, the following files must functions: stage: deploy environment: test - image: gcr.io/triggermesh/tm:v0.0.7 + image: gcr.io/triggermesh/tm:v0.0.9 script: - - tm -n "$KUBE_NAMESPACE" set registry-auth gitlab-registry --registry "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password "$CI_JOB_TOKEN" - - tm -n "$KUBE_NAMESPACE" --registry-host "$CI_REGISTRY_IMAGE" deploy --wait + - tm -n "$KUBE_NAMESPACE" set registry-auth gitlab-registry --registry "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password "$CI_JOB_TOKEN" --push + - tm -n "$KUBE_NAMESPACE" set registry-auth gitlab-registry --registry "$CI_REGISTRY" --username "$CI_DEPLOY_USER" --password "$CI_DEPLOY_PASSWORD" --pull + - tm -n "$KUBE_NAMESPACE" deploy --wait + ``` The `gitlab-ci.yml` template creates a `Deploy` stage with a `functions` job that invokes the `tm` CLI with the required parameters. @@ -127,7 +149,9 @@ In order to deploy functions to your Knative instance, the following files must ``` -The `serverless.yml` file is referencing both an `echo` directory (under `buildargs`) and an `echo` file (under `handler`) which is a reference to `echo.js` in the [repository](https://gitlab.com/knative-examples/functions). Additionally, it contains three sections with distinct parameters: +The `serverless.yml` file is referencing both an `echo` directory (under `buildargs`) and an `echo` file (under `handler`) +which is a reference to `echo.js` in the [repository](https://gitlab.com/knative-examples/functions). Additionally, it +contains three sections with distinct parameters: ### `service` @@ -149,7 +173,6 @@ The `serverless.yml` file is referencing both an `echo` directory (under `builda In the `serverless.yml` example above, the function name is `echo` and the subsequent lines contain the function attributes. - | Parameter | Description | |-----------|-------------| | `handler` | The function's file name. In the example above, both the function name and the handler are the same. | @@ -159,8 +182,7 @@ In the `serverless.yml` example above, the function name is `echo` and the subse | `environment` | Sets an environment variable for the specific function only. | After the `gitlab-ci.yml` template has been added and the `serverless.yml` file has been -created, each function must be defined as a single file in your repository. Committing a -function to your project will result in a +created, pushing a commit to your project will result in a CI pipeline being executed which will deploy each function as a Knative service. Once the deploy stage has finished, additional details for the function will appear under **Operations > Serverless**. @@ -182,14 +204,6 @@ The sample function can now be triggered from any HTTP client using a simple `PO ![function exection](img/function-execution.png) -Currently, the Serverless page presents all functions available in all clusters registered for the project with Knative installed. - -Clicking on the function name will provide additional details such as the -function's URL as well as runtime statistics such as the number of active pods -available to service the request based on load. - -![serverless function details](img/serverless-details.png) - ## Deploying Serverless applications > Introduced in GitLab 11.5. @@ -227,12 +241,12 @@ deploy: - tm -n "$KUBE_NAMESPACE" --config "$KUBECONFIG" deploy service "$CI_PROJECT_NAME" --from-image "$CI_REGISTRY_IMAGE" --wait ``` -## Deploy the application with Knative +### Deploy the application with Knative With all the pieces in place, the next time a CI pipeline runs, the Knative application will be deployed. Navigate to **CI/CD > Pipelines** and click the most recent pipeline. -## Obtain the URL for the Knative deployment +### Obtain the URL for the Knative deployment Use the CI/CD deployment job output to obtain the deployment URL. Once all the stages of the pipeline finish, click the **deploy** stage. -- cgit v1.2.3 From bb00f0ee5e352f5dbde3c3ab0dbe3e89bf5d9449 Mon Sep 17 00:00:00 2001 From: danielgruesso Date: Fri, 25 Jan 2019 15:42:14 -0500 Subject: Add domain retrieval info and image --- .../project/clusters/serverless/img/app-domain.png | Bin 0 -> 209263 bytes doc/user/project/clusters/serverless/index.md | 6 +++++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 doc/user/project/clusters/serverless/img/app-domain.png diff --git a/doc/user/project/clusters/serverless/img/app-domain.png b/doc/user/project/clusters/serverless/img/app-domain.png new file mode 100644 index 00000000000..d113dfadd2e Binary files /dev/null and b/doc/user/project/clusters/serverless/img/app-domain.png differ diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md index 85d41777ce6..2a6dc7862df 100644 --- a/doc/user/project/clusters/serverless/index.md +++ b/doc/user/project/clusters/serverless/index.md @@ -248,7 +248,11 @@ With all the pieces in place, the next time a CI pipeline runs, the Knative appl ### Obtain the URL for the Knative deployment -Use the CI/CD deployment job output to obtain the deployment URL. Once all the stages of the pipeline finish, click the **deploy** stage. +Visit **Operations >> Serverless** to find the URL for your deployment (listed under the "Domain" column). + +![app domain](img/app-domain.png) + +Alternatively, use the CI/CD deployment job output to obtain the deployment URL. Once all the stages of the pipeline finish, click the **deploy** stage. ![deploy stage](img/deploy-stage.png) -- cgit v1.2.3 From a3169bfc73fb6a06492d6263849c621564d5a860 Mon Sep 17 00:00:00 2001 From: danielgruesso Date: Fri, 25 Jan 2019 16:25:21 -0500 Subject: Removed orphaned file and update for clarity --- doc/user/project/clusters/serverless/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md index 2a6dc7862df..a09af918857 100644 --- a/doc/user/project/clusters/serverless/index.md +++ b/doc/user/project/clusters/serverless/index.md @@ -1,6 +1,6 @@ # Serverless -> Introduced in GitLab 11.5. +> Introduced in GitLab 11.5. > Serverless is currently in [alpha](https://about.gitlab.com/handbook/product/#alpha). Run serverless workloads on Kubernetes using [Knative](https://cloud.google.com/knative/). -- cgit v1.2.3 From 1350602aa01e48a22e3794ceccf8ecf8154ce7a8 Mon Sep 17 00:00:00 2001 From: Daniel Gruesso Date: Tue, 29 Jan 2019 14:49:20 +0000 Subject: Delete file serverless-details.png --- .../clusters/serverless/img/serverless-details.png | Bin 63347 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 doc/user/project/clusters/serverless/img/serverless-details.png diff --git a/doc/user/project/clusters/serverless/img/serverless-details.png b/doc/user/project/clusters/serverless/img/serverless-details.png deleted file mode 100644 index 61e0735199a..00000000000 Binary files a/doc/user/project/clusters/serverless/img/serverless-details.png and /dev/null differ -- cgit v1.2.3 From 3e93d3981d8ff9501ea2a10342963bdafab73e3c Mon Sep 17 00:00:00 2001 From: Daniel Gruesso Date: Tue, 29 Jan 2019 15:05:13 +0000 Subject: Removed orphaned file and update for clarity --- doc/user/project/clusters/serverless/index.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md index a09af918857..d09f0b431f9 100644 --- a/doc/user/project/clusters/serverless/index.md +++ b/doc/user/project/clusters/serverless/index.md @@ -84,18 +84,15 @@ Currently the following [runtimes](https://gitlab.com/triggermesh/runtimes) are You can find all the files referenced in this doc in the [functions example project](https://gitlab.com/knative-examples/functions). -Follow these steps to deploy a function using the node.js runtime to your Knative instance: +Follow these steps to deploy a function using the Node.js runtime to your Knative instance: 1. Create a directory that will house the function. In this example we will create a directory called `echo` at the root of the project. -1. Create the file that will contain the function code. In this example our file is called `echo.js` and is located inside the `echo` directory. +1. Create the file that will contain the function code. In this example, our file is called `echo.js` and is located inside the `echo` directory. If your project is public, skip to step 4. - If your project is public, skip to step no. 4. +1. If your project is private you will need to [Create a GitLab deploy token](../../deploy_tokens/index.md#creating-a-deploy-token). +This will enable the `tm` cli to be able to be used in a deployment step and gives it access to the container registry. -1. If your project is private you will need to [Create a GitLab deploy token](https://docs.gitlab.com/ee/user/project/deploy_tokens/#creating-a-deploy-token). -This will enable the `tm` cli to be used as a deployment step and access the container registry. - - 1. Go to the project you want to create the function for. 1. Go to **Settings** > **Repository**. 1. Click on "Expand" on **Deploy Tokens** section. 1. Enter `gitlab-deploy-token` as the name. @@ -116,9 +113,9 @@ This will enable the `tm` cli to be used as a deployment step and access the con environment: test image: gcr.io/triggermesh/tm:v0.0.9 script: - - tm -n "$KUBE_NAMESPACE" set registry-auth gitlab-registry --registry "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password "$CI_JOB_TOKEN" --push - - tm -n "$KUBE_NAMESPACE" set registry-auth gitlab-registry --registry "$CI_REGISTRY" --username "$CI_DEPLOY_USER" --password "$CI_DEPLOY_PASSWORD" --pull - - tm -n "$KUBE_NAMESPACE" deploy --wait + - tm -n "$KUBE_NAMESPACE" set registry-auth gitlab-registry --registry "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password "$CI_JOB_TOKEN" --push + - tm -n "$KUBE_NAMESPACE" set registry-auth gitlab-registry --registry "$CI_REGISTRY" --username "$CI_DEPLOY_USER" --password "$CI_DEPLOY_PASSWORD" --pull + - tm -n "$KUBE_NAMESPACE" deploy --wait ``` @@ -149,7 +146,7 @@ This will enable the `tm` cli to be used as a deployment step and access the con ``` -The `serverless.yml` file is referencing both an `echo` directory (under `buildargs`) and an `echo` file (under `handler`) +The `serverless.yml` file references both an `echo` directory (under `buildargs`) and an `echo` file (under `handler`), which is a reference to `echo.js` in the [repository](https://gitlab.com/knative-examples/functions). Additionally, it contains three sections with distinct parameters: @@ -248,7 +245,7 @@ With all the pieces in place, the next time a CI pipeline runs, the Knative appl ### Obtain the URL for the Knative deployment -Visit **Operations >> Serverless** to find the URL for your deployment (listed under the "Domain" column). +Go to the **Operations > Serverless** page to find the URL for your deployment in the **Domain** column. ![app domain](img/app-domain.png) -- cgit v1.2.3 From 46dc355ad2eb1803e94ab181f8f3b52e13d8ad03 Mon Sep 17 00:00:00 2001 From: Daniel Gruesso Date: Wed, 30 Jan 2019 19:57:03 +0000 Subject: Update private repo steps --- doc/user/project/clusters/serverless/index.md | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md index d09f0b431f9..83c3dd0faec 100644 --- a/doc/user/project/clusters/serverless/index.md +++ b/doc/user/project/clusters/serverless/index.md @@ -90,16 +90,7 @@ Follow these steps to deploy a function using the Node.js runtime to your Knativ 1. Create the file that will contain the function code. In this example, our file is called `echo.js` and is located inside the `echo` directory. If your project is public, skip to step 4. -1. If your project is private you will need to [Create a GitLab deploy token](../../deploy_tokens/index.md#creating-a-deploy-token). -This will enable the `tm` cli to be able to be used in a deployment step and gives it access to the container registry. - - 1. Go to **Settings** > **Repository**. - 1. Click on "Expand" on **Deploy Tokens** section. - 1. Enter `gitlab-deploy-token` as the name. - 1. Check the `read_registry` scope - 1. Click on **Create deploy token**. - 1. Save the deploy token somewhere safe. Once you leave or refresh - the page, **you won't be able to access it again**. +1. If your project is private you will need to [create a GitLab deploy token](../../deploy_tokens/index.md#creating-a-deploy-token) with `gitlab-deploy-token` as the name and the `read_registry` scope. 1. `.gitlab-ci.yml`: This template allows to define the stage, environment, and image to be used for your functions. It must be included at the root of your repository: -- cgit v1.2.3 From f653952202a6734be597dd971c1155c82c6a1fba Mon Sep 17 00:00:00 2001 From: Mark Lapierre Date: Mon, 28 Jan 2019 15:30:50 -0500 Subject: Don't use .netrc with SSH There was a bug that required credentials when using SSH key auth when using LFS. That bug was fixed so we shouldn't need to add credentials to .netrc when using SSH anymore. --- qa/qa/git/repository.rb | 3 --- qa/spec/git/repository_spec.rb | 11 ----------- 2 files changed, 14 deletions(-) diff --git a/qa/qa/git/repository.rb b/qa/qa/git/repository.rb index df254ef288b..0aa94101098 100644 --- a/qa/qa/git/repository.rb +++ b/qa/qa/git/repository.rb @@ -117,8 +117,6 @@ module QA File.binwrite(private_key_file, key.private_key) File.chmod(0700, private_key_file) - try_add_credentials_to_netrc - @known_hosts_file = Tempfile.new("known_hosts_#{SecureRandom.hex(8)}") keyscan_params = ['-H'] keyscan_params << "-p #{uri.port}" if uri.port @@ -180,7 +178,6 @@ module QA def add_credentials? return false if !username || !password return true unless ssh_key_set? - return true if ssh_key_set? && use_lfs? false end diff --git a/qa/spec/git/repository_spec.rb b/qa/spec/git/repository_spec.rb index 48bcc8e8276..4a350cd6c42 100644 --- a/qa/spec/git/repository_spec.rb +++ b/qa/spec/git/repository_spec.rb @@ -115,16 +115,5 @@ describe QA::Git::Repository do .to eq("machine foo login user password foo\n") end end - - describe '#use_ssh_key' do - it 'does not add credentials to .netrc' do - key = Struct.new(:private_key).new('foo') - - expect(repository).to receive(:try_add_credentials_to_netrc).and_call_original - expect(repository).not_to receive(:save_netrc_content) - - repository.use_ssh_key(key) - end - end end end -- cgit v1.2.3 From 74dc0c1866322c1f7f2ce0f0eb382f9d8c209858 Mon Sep 17 00:00:00 2001 From: Gokhan Apaydin Date: Wed, 30 Jan 2019 21:14:42 +0000 Subject: fix display comment avatars issue in IE 11 --- .../vue_shared/components/user_avatar/user_avatar_image.vue | 3 ++- changelogs/unreleased/patch-38.yml | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/patch-38.yml diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index 95f4395ac13..a6c1737dcab 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -68,7 +68,8 @@ export default { sanitizedSource() { let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; // Only adds the width to the URL if its not a base64 data image - if (!baseSrc.startsWith('data:') && !baseSrc.includes('?')) baseSrc += `?width=${this.size}`; + if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?')) + baseSrc += `?width=${this.size}`; return baseSrc; }, resultantSrcAttribute() { diff --git a/changelogs/unreleased/patch-38.yml b/changelogs/unreleased/patch-38.yml new file mode 100644 index 00000000000..9179fc6846e --- /dev/null +++ b/changelogs/unreleased/patch-38.yml @@ -0,0 +1,5 @@ +--- +title: fix display comment avatars issue in IE 11 +merge_request: 24777 +author: Gokhan Apaydin +type: fixed -- cgit v1.2.3 From 1414cfae43ce175feaaaca346287274fe3c372e2 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Wed, 30 Jan 2019 21:55:28 +0000 Subject: Revert "Merge branch '56398-fix-cluster-installation-loading-state' into 'master'" This reverts merge request !24485 --- app/assets/javascripts/clusters/clusters_bundle.js | 28 +++++----- .../clusters/components/application_row.vue | 59 +++++++++++----------- app/assets/javascripts/clusters/constants.js | 3 +- ...6398-fix-cluster-installation-loading-state.yml | 5 -- spec/javascripts/clusters/clusters_bundle_spec.js | 57 +++++++++++++++++---- .../clusters/components/application_row_spec.js | 49 +++++++++++------- 6 files changed, 125 insertions(+), 76 deletions(-) delete mode 100644 changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index fc4779632f9..b1f992c03ff 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -6,7 +6,7 @@ import Flash from '../flash'; import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; -import { APPLICATION_STATUS, REQUEST_SUBMITTED, REQUEST_FAILURE } from './constants'; +import { APPLICATION_STATUS, REQUEST_LOADING, REQUEST_SUCCESS, REQUEST_FAILURE } from './constants'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import Applications from './components/applications.vue'; @@ -231,18 +231,22 @@ export default class Clusters { installApplication(data) { const appId = data.id; - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUBMITTED); + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING); this.store.updateAppProperty(appId, 'requestReason', null); - this.store.updateAppProperty(appId, 'statusReason', null); - - this.service.installApplication(appId, data.params).catch(() => { - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); - this.store.updateAppProperty( - appId, - 'requestReason', - s__('ClusterIntegration|Request to begin installing failed'), - ); - }); + + this.service + .installApplication(appId, data.params) + .then(() => { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS); + }) + .catch(() => { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); + this.store.updateAppProperty( + appId, + 'requestReason', + s__('ClusterIntegration|Request to begin installing failed'), + ); + }); } destroy() { diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 3c3ce1dec56..d4354dcfebd 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -4,7 +4,12 @@ import { s__, sprintf } from '../../locale'; import eventHub from '../event_hub'; import identicon from '../../vue_shared/components/identicon.vue'; import loadingButton from '../../vue_shared/components/loading_button.vue'; -import { APPLICATION_STATUS, REQUEST_SUBMITTED, REQUEST_FAILURE } from '../constants'; +import { + APPLICATION_STATUS, + REQUEST_LOADING, + REQUEST_SUCCESS, + REQUEST_FAILURE, +} from '../constants'; export default { components: { @@ -67,13 +72,6 @@ export default { isKnownStatus() { return Object.values(APPLICATION_STATUS).includes(this.status); }, - isInstalling() { - return ( - this.status === APPLICATION_STATUS.SCHEDULED || - this.status === APPLICATION_STATUS.INSTALLING || - (this.requestStatus === REQUEST_SUBMITTED && !this.statusReason && !this.isInstalled) - ); - }, isInstalled() { return ( this.status === APPLICATION_STATUS.INSTALLED || @@ -81,18 +79,6 @@ export default { this.status === APPLICATION_STATUS.UPDATING ); }, - canInstall() { - if (this.isInstalling) { - return false; - } - - return ( - this.status === APPLICATION_STATUS.NOT_INSTALLABLE || - this.status === APPLICATION_STATUS.INSTALLABLE || - this.status === APPLICATION_STATUS.ERROR || - this.isUnknownStatus - ); - }, hasLogo() { return !!this.logoUrl; }, @@ -104,7 +90,12 @@ export default { return `js-cluster-application-row-${this.id}`; }, installButtonLoading() { - return !this.status || this.status === APPLICATION_STATUS.SCHEDULED || this.isInstalling; + return ( + !this.status || + this.status === APPLICATION_STATUS.SCHEDULED || + this.status === APPLICATION_STATUS.INSTALLING || + this.requestStatus === REQUEST_LOADING + ); }, installButtonDisabled() { // Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but @@ -113,17 +104,30 @@ export default { return ( ((this.status !== APPLICATION_STATUS.INSTALLABLE && this.status !== APPLICATION_STATUS.ERROR) || - this.isInstalling) && + this.requestStatus === REQUEST_LOADING || + this.requestStatus === REQUEST_SUCCESS) && this.isKnownStatus ); }, installButtonLabel() { let label; - if (this.canInstall) { + if ( + this.status === APPLICATION_STATUS.NOT_INSTALLABLE || + this.status === APPLICATION_STATUS.INSTALLABLE || + this.status === APPLICATION_STATUS.ERROR || + this.isUnknownStatus + ) { label = s__('ClusterIntegration|Install'); - } else if (this.isInstalling) { + } else if ( + this.status === APPLICATION_STATUS.SCHEDULED || + this.status === APPLICATION_STATUS.INSTALLING + ) { label = s__('ClusterIntegration|Installing'); - } else if (this.isInstalled) { + } else if ( + this.status === APPLICATION_STATUS.INSTALLED || + this.status === APPLICATION_STATUS.UPDATED || + this.status === APPLICATION_STATUS.UPDATING + ) { label = s__('ClusterIntegration|Installed'); } @@ -136,10 +140,7 @@ export default { return s__('ClusterIntegration|Manage'); }, hasError() { - return ( - !this.isInstalling && - (this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE) - ); + return this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE; }, generalErrorDescription() { return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), { diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 360511e8882..e31afadf186 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -18,7 +18,8 @@ export const APPLICATION_STATUS = { }; // These are only used client-side -export const REQUEST_SUBMITTED = 'request-submitted'; +export const REQUEST_LOADING = 'request-loading'; +export const REQUEST_SUCCESS = 'request-success'; export const REQUEST_FAILURE = 'request-failure'; export const INGRESS = 'ingress'; export const JUPYTER = 'jupyter'; diff --git a/changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml b/changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml deleted file mode 100644 index 39261cce8ef..00000000000 --- a/changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix cluster installation processing spinner -merge_request: 24485 -author: -type: fixed diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js index 7928feeadfa..880b469284b 100644 --- a/spec/javascripts/clusters/clusters_bundle_spec.js +++ b/spec/javascripts/clusters/clusters_bundle_spec.js @@ -1,5 +1,10 @@ import Clusters from '~/clusters/clusters_bundle'; -import { REQUEST_SUBMITTED, REQUEST_FAILURE, APPLICATION_STATUS } from '~/clusters/constants'; +import { + REQUEST_LOADING, + REQUEST_SUCCESS, + REQUEST_FAILURE, + APPLICATION_STATUS, +} from '~/clusters/constants'; import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; describe('Clusters', () => { @@ -191,43 +196,67 @@ describe('Clusters', () => { }); describe('installApplication', () => { - it('tries to install helm', () => { + it('tries to install helm', done => { spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); expect(cluster.store.state.applications.helm.requestStatus).toEqual(null); cluster.installApplication({ id: 'helm' }); - expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED); + expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING); expect(cluster.store.state.applications.helm.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('helm', undefined); + + getSetTimeoutPromise() + .then(() => { + expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUCCESS); + expect(cluster.store.state.applications.helm.requestReason).toEqual(null); + }) + .then(done) + .catch(done.fail); }); - it('tries to install ingress', () => { + it('tries to install ingress', done => { spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); expect(cluster.store.state.applications.ingress.requestStatus).toEqual(null); cluster.installApplication({ id: 'ingress' }); - expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_SUBMITTED); + expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_LOADING); expect(cluster.store.state.applications.ingress.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress', undefined); + + getSetTimeoutPromise() + .then(() => { + expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_SUCCESS); + expect(cluster.store.state.applications.ingress.requestReason).toEqual(null); + }) + .then(done) + .catch(done.fail); }); - it('tries to install runner', () => { + it('tries to install runner', done => { spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); expect(cluster.store.state.applications.runner.requestStatus).toEqual(null); cluster.installApplication({ id: 'runner' }); - expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_SUBMITTED); + expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_LOADING); expect(cluster.store.state.applications.runner.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('runner', undefined); + + getSetTimeoutPromise() + .then(() => { + expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_SUCCESS); + expect(cluster.store.state.applications.runner.requestReason).toEqual(null); + }) + .then(done) + .catch(done.fail); }); - it('tries to install jupyter', () => { + it('tries to install jupyter', done => { spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(null); @@ -236,11 +265,19 @@ describe('Clusters', () => { params: { hostname: cluster.store.state.applications.jupyter.hostname }, }); - expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_SUBMITTED); + expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_LOADING); expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('jupyter', { hostname: cluster.store.state.applications.jupyter.hostname, }); + + getSetTimeoutPromise() + .then(() => { + expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_SUCCESS); + expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null); + }) + .then(done) + .catch(done.fail); }); it('sets error request status when the request fails', done => { @@ -252,7 +289,7 @@ describe('Clusters', () => { cluster.installApplication({ id: 'helm' }); - expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED); + expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING); expect(cluster.store.state.applications.helm.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalled(); diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js index d1f4a1cebb4..45d56514930 100644 --- a/spec/javascripts/clusters/components/application_row_spec.js +++ b/spec/javascripts/clusters/components/application_row_spec.js @@ -1,6 +1,11 @@ import Vue from 'vue'; import eventHub from '~/clusters/event_hub'; -import { APPLICATION_STATUS, REQUEST_SUBMITTED, REQUEST_FAILURE } from '~/clusters/constants'; +import { + APPLICATION_STATUS, + REQUEST_LOADING, + REQUEST_SUCCESS, + REQUEST_FAILURE, +} from '~/clusters/constants'; import applicationRow from '~/clusters/components/application_row.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { DEFAULT_APPLICATION_STATE } from '../services/mock_data'; @@ -52,12 +57,6 @@ describe('Application Row', () => { expect(vm.installButtonLabel).toBeUndefined(); }); - it('has install button', () => { - const installationBtn = vm.$el.querySelector('.js-cluster-application-install-button'); - - expect(installationBtn).not.toBe(null); - }); - it('has disabled "Install" when APPLICATION_STATUS.NOT_INSTALLABLE', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, @@ -102,18 +101,6 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(true); }); - it('has loading "Installing" when REQUEST_SUBMITTED', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.INSTALLABLE, - requestStatus: REQUEST_SUBMITTED, - }); - - expect(vm.installButtonLabel).toEqual('Installing'); - expect(vm.installButtonLoading).toEqual(true); - expect(vm.installButtonDisabled).toEqual(true); - }); - it('has disabled "Installed" when APPLICATION_STATUS.INSTALLED', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, @@ -147,6 +134,30 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(false); }); + it('has loading "Install" when REQUEST_LOADING', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_STATUS.INSTALLABLE, + requestStatus: REQUEST_LOADING, + }); + + expect(vm.installButtonLabel).toEqual('Install'); + expect(vm.installButtonLoading).toEqual(true); + expect(vm.installButtonDisabled).toEqual(true); + }); + + it('has disabled "Install" when REQUEST_SUCCESS', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_STATUS.INSTALLABLE, + requestStatus: REQUEST_SUCCESS, + }); + + expect(vm.installButtonLabel).toEqual('Install'); + expect(vm.installButtonLoading).toEqual(false); + expect(vm.installButtonDisabled).toEqual(true); + }); + it('has enabled "Install" when REQUEST_FAILURE (so you can try installing again)', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, -- cgit v1.2.3 From 8bcd508b4c7f559f27db3c05b6ae4a3a33dfff95 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Fri, 21 Dec 2018 01:19:17 +0100 Subject: Add lock version to issuable helpers --- app/controllers/concerns/issuable_actions.rb | 3 ++- app/helpers/issuables_helper.rb | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 3d64ae8b775..8ef3b6502df 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -58,7 +58,8 @@ module IssuableActions title_text: issuable.title, description: view_context.markdown_field(issuable, :description), description_text: issuable.description, - task_status: issuable.task_status + task_status: issuable.task_status, + lock_version: issuable.lock_version } if issuable.edited? diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index f8176facce9..0fee29bf7c7 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -269,6 +269,7 @@ module IssuablesHelper markdownPreviewPath: preview_markdown_path(parent), markdownDocsPath: help_page_path('user/markdown'), markdownVersion: issuable.cached_markdown_version, + lockVersion: issuable.lock_version, issuableTemplates: issuable_templates(issuable), initialTitleHtml: markdown_field(issuable, :title), initialTitleText: issuable.title, -- cgit v1.2.3 From 45eabf921a9bb90677d1e4f59544f0a7abcbc879 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Fri, 21 Dec 2018 01:19:41 +0100 Subject: Accept lockVersion as a prop and add to store --- .../javascripts/issue_show/components/app.vue | 6 +++++ app/assets/javascripts/issue_show/stores/index.js | 2 ++ spec/helpers/issuables_helper_spec.rb | 1 + spec/javascripts/issue_show/components/app_spec.js | 26 +++++++++++++++++----- spec/javascripts/issue_show/mock_data.js | 2 ++ 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index cd569eb3045..b6eacf839b9 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -130,6 +130,10 @@ export default { required: false, default: true, }, + lockVersion: { + type: Number, + required: true, + }, }, data() { const store = new Store({ @@ -141,6 +145,7 @@ export default { updatedByName: this.updatedByName, updatedByPath: this.updatedByPath, taskStatus: this.initialTaskStatus, + lock_version: this.lockVersion, }); return { @@ -214,6 +219,7 @@ export default { this.store.setFormState({ title: this.state.titleText, description: this.state.descriptionText, + lock_version: this.state.lock_version, lockedWarningVisible: false, updateLoading: false, }); diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 32044d6da25..2b3903def6b 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -6,6 +6,7 @@ export default class Store { description: '', lockedWarningVisible: false, updateLoading: false, + lock_version: 0, }; } @@ -22,6 +23,7 @@ export default class Store { this.state.updatedAt = data.updated_at; this.state.updatedByName = data.updated_by_name; this.state.updatedByPath = data.updated_by_path; + this.state.lock_version = data.lock_version; } stateShouldUpdate(data) { diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 03e3a72a82f..af319e5ebfe 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -190,6 +190,7 @@ describe IssuablesHelper do markdownDocsPath: '/help/user/markdown', markdownVersion: CacheMarkdownField::CACHE_COMMONMARK_VERSION, issuableTemplates: [], + lockVersion: issue.lock_version, projectPath: @project.path, projectNamespace: @project.namespace.path, initialTitleHtml: issue.title, diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index 2bd1b3996dc..9b2b6b670c3 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -43,6 +43,7 @@ describe('Issuable output', () => { initialTitleText: '', initialDescriptionHtml: 'test', initialDescriptionText: 'test', + lockVersion: 1, markdownPreviewPath: '/', markdownDocsPath: '/', projectNamespace: '/', @@ -78,6 +79,7 @@ describe('Issuable output', () => { expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/); expect(editedText.querySelector('.author-link').href).toMatch(/\/some_user$/); expect(editedText.querySelector('time')).toBeTruthy(); + expect(vm.state.lock_version).toEqual(1); }) .then(() => { vm.poll.makeRequest(); @@ -95,6 +97,7 @@ describe('Issuable output', () => { expect(editedText.querySelector('.author-link').href).toMatch(/\/other_user$/); expect(editedText.querySelector('time')).toBeTruthy(); + expect(vm.state.lock_version).toEqual(2); }) .then(done) .catch(done.fail); @@ -255,15 +258,10 @@ describe('Issuable output', () => { describe('error when updating', () => { beforeEach(() => { spyOn(window, 'Flash').and.callThrough(); - spyOn(vm.service, 'updateIssuable').and.callFake( - () => - new Promise((resolve, reject) => { - reject(); - }), - ); }); it('closes form on error', done => { + spyOn(vm.service, 'updateIssuable').and.callFake(() => Promise.resolve()); vm.updateIssuable(); setTimeout(() => { @@ -276,6 +274,7 @@ describe('Issuable output', () => { }); it('returns the correct error message for issuableType', done => { + spyOn(vm.service, 'updateIssuable').and.callFake(() => Promise.reject()); vm.issuableType = 'merge request'; Vue.nextTick(() => { @@ -290,6 +289,20 @@ describe('Issuable output', () => { }); }); }); + + it('shows error mesage from backend if exists', done => { + const msg = 'Custom error message from backend'; + spyOn(vm.service, 'updateIssuable').and.callFake(() => + Promise.reject({ response: { data: { errors: msg } } }), // eslint-disable-line prefer-promise-reject-errors + ); + + vm.updateIssuable(); + setTimeout(() => { + expect(window.Flash).toHaveBeenCalledWith(msg); + + done(); + }); + }); }); }); @@ -420,6 +433,7 @@ describe('Issuable output', () => { .then(vm.$nextTick) .then(() => { expect(vm.formState.lockedWarningVisible).toEqual(true); + expect(vm.formState.lock_version).toEqual(1); expect(vm.$el.querySelector('.alert')).not.toBeNull(); }) .then(done) diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js index 74b3efb014b..f4475aadb8b 100644 --- a/spec/javascripts/issue_show/mock_data.js +++ b/spec/javascripts/issue_show/mock_data.js @@ -8,6 +8,7 @@ export default { updated_at: '2015-05-15T12:31:04.428Z', updated_by_name: 'Some User', updated_by_path: '/some_user', + lock_version: 1, }, secondRequest: { title: '

2

', @@ -18,5 +19,6 @@ export default { updated_at: '2016-05-15T12:31:04.428Z', updated_by_name: 'Other User', updated_by_path: '/other_user', + lock_version: 2, }, }; -- cgit v1.2.3 From f1acd5051513ee815578f7311ca88ee54e79e323 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Mon, 7 Jan 2019 16:23:53 +0100 Subject: Show error message from backend --- app/assets/javascripts/issue_show/components/app.vue | 8 +++++++- spec/javascripts/issue_show/components/app_spec.js | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index b6eacf839b9..d65b44ca6f3 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -250,8 +250,14 @@ export default { if (error && error.name === 'SpamError') { this.openRecaptcha(); } else { + let errMsg = `Error updating ${this.issuableType}`; + + if (error && error.response && error.response.data && error.response.data.errors) { + errMsg = error.response.data.errors; + } + eventHub.$emit('close.form'); - window.Flash(`Error updating ${this.issuableType}`); + window.Flash(errMsg); } }); }, diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index 9b2b6b670c3..863d15570a0 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -292,8 +292,8 @@ describe('Issuable output', () => { it('shows error mesage from backend if exists', done => { const msg = 'Custom error message from backend'; - spyOn(vm.service, 'updateIssuable').and.callFake(() => - Promise.reject({ response: { data: { errors: msg } } }), // eslint-disable-line prefer-promise-reject-errors + spyOn(vm.service, 'updateIssuable').and.callFake( + () => Promise.reject({ response: { data: { errors: msg } } }), // eslint-disable-line prefer-promise-reject-errors ); vm.updateIssuable(); -- cgit v1.2.3 From 83306d249e0762d21b9ca128b9ebb57a0bef6f8b Mon Sep 17 00:00:00 2001 From: Brett Walker Date: Thu, 10 Jan 2019 15:22:28 -0600 Subject: Pass tasklist lock version receive data on when there is a conflict --- app/assets/javascripts/issue_show/components/app.vue | 1 + app/assets/javascripts/issue_show/components/description.vue | 10 ++++++++++ app/assets/javascripts/task_list.js | 4 +++- app/controllers/concerns/issuable_actions.rb | 5 ++++- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index d65b44ca6f3..3e71c40e896 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -326,6 +326,7 @@ export default { :task-status="state.taskStatus" :issuable-type="issuableType" :update-url="updateEndpoint" + :lock-version="state.lock_version" /> {}); - this.onError = function showFlash(e) { + this.onError = options.onError || function showFlash(e) { let errorMessages = ''; if (e.response.data && typeof e.response.data === 'object') { @@ -43,6 +44,7 @@ export default class TaskList { const patchData = {}; patchData[this.dataType] = { [this.fieldName]: $target.val(), + ['lock_version']: this.lockVersion, }; return axios diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 8ef3b6502df..f0b4198b2d9 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -162,10 +162,13 @@ module IssuableActions end format.json do + # We want to pass back the latest valid data, so reload the model + @issuable.reload render json: { errors: [ "Someone edited this #{issuable.human_class_name} at the same time you did. Please refresh your browser and make sure your changes will not unintentionally remove theirs." - ] + ], + data: serializer.represent(@issuable) }, status: :conflict end end -- cgit v1.2.3 From 1362267d45dee0a6a4311c59e72b90e3ea032da6 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Fri, 11 Jan 2019 01:07:09 +0100 Subject: Fix data coming down to error handler of tasklist --- app/assets/javascripts/issue_show/components/description.vue | 3 ++- app/assets/javascripts/task_list.js | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 4443d6c6760..d5b355a39a2 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -1,5 +1,6 @@ @@ -215,23 +193,13 @@ export default { :name="groupData.group" :show-panels="showPanels" > - - - {{ null }} - + /> -import { scaleLinear, scaleTime } from 'd3-scale'; -import { axisLeft, axisBottom } from 'd3-axis'; -import _ from 'underscore'; -import { max, extent } from 'd3-array'; -import { select } from 'd3-selection'; -import GraphAxis from './graph/axis.vue'; -import GraphLegend from './graph/legend.vue'; -import GraphFlag from './graph/flag.vue'; -import GraphDeployment from './graph/deployment.vue'; -import GraphPath from './graph/path.vue'; -import MonitoringMixin from '../mixins/monitoring_mixins'; -import eventHub from '../event_hub'; -import measurements from '../utils/measurements'; -import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters'; -import createTimeSeries from '../utils/multiple_time_series'; -import bp from '../../breakpoints'; - -const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select }; - -export default { - components: { - GraphAxis, - GraphFlag, - GraphDeployment, - GraphPath, - GraphLegend, - }, - mixins: [MonitoringMixin], - props: { - graphData: { - type: Object, - required: true, - }, - deploymentData: { - type: Array, - required: true, - }, - hoverData: { - type: Object, - required: false, - default: () => ({}), - }, - projectPath: { - type: String, - required: true, - }, - tagsPath: { - type: String, - required: true, - }, - showLegend: { - type: Boolean, - required: false, - default: true, - }, - smallGraph: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - baseGraphHeight: 450, - baseGraphWidth: 600, - graphHeight: 450, - graphWidth: 600, - graphHeightOffset: 120, - margin: {}, - unitOfDisplay: '', - yAxisLabel: '', - legendTitle: '', - reducedDeploymentData: [], - measurements: measurements.large, - currentData: { - time: new Date(), - value: 0, - }, - currentXCoordinate: 0, - currentCoordinates: {}, - showFlag: false, - showFlagContent: false, - timeSeries: [], - graphDrawData: {}, - realPixelRatio: 1, - seriesUnderMouse: [], - }; - }, - computed: { - outerViewBox() { - return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`; - }, - innerViewBox() { - return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`; - }, - axisTransform() { - return `translate(70, ${this.graphHeight - 100})`; - }, - paddingBottomRootSvg() { - return { - paddingBottom: `${Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth || 0}%`, - }; - }, - deploymentFlagData() { - return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag); - }, - shouldRenderData() { - return this.graphData.queries.filter(s => s.result.length > 0).length > 0; - }, - }, - watch: { - hoverData() { - this.positionFlag(); - }, - }, - mounted() { - this.draw(); - }, - methods: { - showDot(path) { - return this.showFlagContent && this.seriesUnderMouse.includes(path); - }, - draw() { - const breakpointSize = bp.getBreakpointSize(); - const svgWidth = this.$refs.baseSvg.getBoundingClientRect().width; - - this.margin = measurements.large.margin; - - if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') { - this.graphHeight = 300; - this.margin = measurements.small.margin; - this.measurements = measurements.small; - } - - this.yAxisLabel = this.graphData.y_label || 'Values'; - this.graphWidth = svgWidth - this.margin.left - this.margin.right; - this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; - this.baseGraphHeight = this.graphHeight - 50; - this.baseGraphWidth = this.graphWidth; - - // pixel offsets inside the svg and outside are not 1:1 - this.realPixelRatio = svgWidth / this.baseGraphWidth; - - // set the legends on the axes - const [query] = this.graphData.queries; - this.legendTitle = query ? query.label : 'Average'; - this.unitOfDisplay = query ? query.unit : ''; - - if (this.shouldRenderData) { - this.renderAxesPaths(); - this.formatDeployments(); - } - }, - handleMouseOverGraph(e) { - let point = this.$refs.graphData.createSVGPoint(); - point.x = e.clientX; - point.y = e.clientY; - point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse()); - point.x += 7; - - this.seriesUnderMouse = this.timeSeries.filter(series => { - const mouseX = series.timeSeriesScaleX.invert(point.x); - let minDistance = Infinity; - - const closestTickMark = Object.keys(this.allXAxisValues).reduce((closest, x) => { - const distance = Math.abs(Number(new Date(x)) - Number(mouseX)); - if (distance < minDistance) { - minDistance = distance; - return x; - } - return closest; - }); - - return series.values.find(v => v.time.toString() === closestTickMark); - }); - - const firstTimeSeries = this.seriesUnderMouse[0]; - const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x); - const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1); - const d0 = firstTimeSeries.values[overlayIndex - 1]; - const d1 = firstTimeSeries.values[overlayIndex]; - if (d0 === undefined || d1 === undefined) return; - const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay; - const hoveredDataIndex = evalTime ? overlayIndex : overlayIndex - 1; - const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time; - const currentDeployXPos = this.mouseOverDeployInfo(point.x); - - eventHub.$emit('hoverChanged', { - hoveredDate, - currentDeployXPos, - }); - }, - renderAxesPaths() { - ({ timeSeries: this.timeSeries, graphDrawData: this.graphDrawData } = createTimeSeries( - this.graphData.queries, - this.graphWidth, - this.graphHeight, - this.graphHeightOffset, - )); - - if (_.findWhere(this.timeSeries, { renderCanary: true })) { - this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true })); - } - - const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]); - const axisYScale = d3.scaleLinear().range([this.graphHeight - this.graphHeightOffset, 0]); - - const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []); - axisXScale.domain(d3.extent(allValues, d => d.time)); - axisYScale.domain([0, d3.max(allValues.map(d => d.value))]); - - this.allXAxisValues = this.timeSeries.reduce((obj, series) => { - const seriesKeys = {}; - series.values.forEach(v => { - seriesKeys[v.time] = true; - }); - return { - ...obj, - ...seriesKeys, - }; - }, {}); - - const xAxis = d3 - .axisBottom() - .scale(axisXScale) - .ticks(this.graphWidth / 120) - .tickFormat(timeScaleFormat); - - const yAxis = d3 - .axisLeft() - .scale(axisYScale) - .ticks(measurements.yTicks); - - d3.select(this.$refs.baseSvg) - .select('.x-axis') - .call(xAxis); - - const width = this.graphWidth; - d3.select(this.$refs.baseSvg) - .select('.y-axis') - .call(yAxis) - .selectAll('.tick') - .each(function createTickLines(d, i) { - if (i > 0) { - d3.select(this) - .select('line') - .attr('x2', width) - .attr('class', 'axis-tick'); - } // Avoid adding the class to the first tick, to prevent coloring - }); // This will select all of the ticks once they're rendered - }, - }, -}; - - - diff --git a/app/assets/javascripts/monitoring/components/graph/axis.vue b/app/assets/javascripts/monitoring/components/graph/axis.vue deleted file mode 100644 index 8f046857a20..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/axis.vue +++ /dev/null @@ -1,118 +0,0 @@ - - diff --git a/app/assets/javascripts/monitoring/components/graph/deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue deleted file mode 100644 index bee9784692c..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/deployment.vue +++ /dev/null @@ -1,48 +0,0 @@ - - diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue deleted file mode 100644 index 9d6d1caef80..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/flag.vue +++ /dev/null @@ -1,151 +0,0 @@ - - - diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue deleted file mode 100644 index b5211c306a3..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/legend.vue +++ /dev/null @@ -1,62 +0,0 @@ - - diff --git a/app/assets/javascripts/monitoring/components/graph/path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue deleted file mode 100644 index f2c237ec391..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/path.vue +++ /dev/null @@ -1,65 +0,0 @@ - - diff --git a/app/assets/javascripts/monitoring/components/graph/track_info.vue b/app/assets/javascripts/monitoring/components/graph/track_info.vue deleted file mode 100644 index 3464067834f..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/track_info.vue +++ /dev/null @@ -1,28 +0,0 @@ - - diff --git a/app/assets/javascripts/monitoring/components/graph/track_line.vue b/app/assets/javascripts/monitoring/components/graph/track_line.vue deleted file mode 100644 index d2ed1ba113e..00000000000 --- a/app/assets/javascripts/monitoring/components/graph/track_line.vue +++ /dev/null @@ -1,33 +0,0 @@ - - diff --git a/app/assets/javascripts/monitoring/event_hub.js b/app/assets/javascripts/monitoring/event_hub.js deleted file mode 100644 index 0948c2e5352..00000000000 --- a/app/assets/javascripts/monitoring/event_hub.js +++ /dev/null @@ -1,3 +0,0 @@ -import Vue from 'vue'; - -export default new Vue(); diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js deleted file mode 100644 index 87c3d969de4..00000000000 --- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js +++ /dev/null @@ -1,86 +0,0 @@ -import { bisectDate } from '../utils/date_time_formatters'; - -const mixins = { - methods: { - mouseOverDeployInfo(mouseXPos) { - if (!this.reducedDeploymentData) return false; - - let dataFound = false; - this.reducedDeploymentData = this.reducedDeploymentData.map(d => { - const deployment = d; - if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) { - dataFound = d.xPos + 1; - - deployment.showDeploymentFlag = true; - } else { - deployment.showDeploymentFlag = false; - } - return deployment; - }); - - return dataFound; - }, - - formatDeployments() { - this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => { - const time = new Date(deployment.created_at); - const xPos = Math.floor(this.timeSeries[0].timeSeriesScaleX(time)); - - time.setSeconds(this.timeSeries[0].values[0].time.getSeconds()); - - if (xPos >= 0) { - const seriesIndex = bisectDate(this.timeSeries[0].values, time, 1); - - deploymentDataArray.push({ - id: deployment.id, - time, - sha: deployment.sha, - commitUrl: `${this.projectPath}/commit/${deployment.sha}`, - tag: deployment.tag, - tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null, - ref: deployment.ref.name, - xPos, - seriesIndex, - showDeploymentFlag: false, - }); - } - - return deploymentDataArray; - }, []); - }, - - positionFlag() { - const timeSeries = this.seriesUnderMouse[0]; - if (!timeSeries) { - return; - } - const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate); - - this.currentData = timeSeries.values[hoveredDataIndex]; - this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time)); - - this.currentCoordinates = {}; - - this.seriesUnderMouse.forEach(series => { - const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate); - const currentData = series.values[currentDataIndex]; - const currentX = Math.floor(series.timeSeriesScaleX(currentData.time)); - const currentY = Math.floor(series.timeSeriesScaleY(currentData.value)); - - this.currentCoordinates[series.metricTag] = { - currentX, - currentY, - currentDataIndex, - }; - }); - - if (this.hoverData.currentDeployXPos) { - this.showFlag = false; - } else { - this.showFlag = true; - } - }, - }, -}; - -export default mixins; diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js deleted file mode 100644 index d88c13609dc..00000000000 --- a/app/assets/javascripts/monitoring/utils/date_time_formatters.js +++ /dev/null @@ -1,42 +0,0 @@ -import { timeFormat as time } from 'd3-time-format'; -import { timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear } from 'd3-time'; -import { bisector } from 'd3-array'; - -const d3 = { - time, - bisector, - timeSecond, - timeMinute, - timeHour, - timeDay, - timeWeek, - timeMonth, - timeYear, -}; - -export const dateFormat = d3.time('%d %b %Y, '); -export const timeFormat = d3.time('%-I:%M%p'); -export const dateFormatWithName = d3.time('%a, %b %-d'); -export const bisectDate = d3.bisector(d => d.time).left; - -export function timeScaleFormat(date) { - let formatFunction; - if (d3.timeSecond(date) < date) { - formatFunction = d3.time('.%L'); - } else if (d3.timeMinute(date) < date) { - formatFunction = d3.time(':%S'); - } else if (d3.timeHour(date) < date) { - formatFunction = d3.time('%-I:%M'); - } else if (d3.timeDay(date) < date) { - formatFunction = d3.time('%-I %p'); - } else if (d3.timeWeek(date) < date) { - formatFunction = d3.time('%a %d'); - } else if (d3.timeMonth(date) < date) { - formatFunction = d3.time('%b %d'); - } else if (d3.timeYear(date) < date) { - formatFunction = d3.time('%B'); - } else { - formatFunction = d3.time('%Y'); - } - return formatFunction(date); -} diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js deleted file mode 100644 index 7c771f43eee..00000000000 --- a/app/assets/javascripts/monitoring/utils/measurements.js +++ /dev/null @@ -1,44 +0,0 @@ -export default { - small: { - // Covers both xs and sm screen sizes - margin: { - top: 40, - right: 40, - bottom: 50, - left: 40, - }, - legends: { - width: 15, - height: 3, - offsetX: 20, - offsetY: 32, - }, - backgroundLegend: { - width: 30, - height: 50, - }, - axisLabelLineOffset: -20, - }, - large: { - // This covers both md and lg screen sizes - margin: { - top: 80, - right: 80, - bottom: 100, - left: 80, - }, - legends: { - width: 15, - height: 3, - offsetX: 20, - offsetY: 34, - }, - backgroundLegend: { - width: 30, - height: 150, - }, - axisLabelLineOffset: 20, - }, - xTicks: 8, - yTicks: 3, -}; diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js deleted file mode 100644 index 50ba14dfb2e..00000000000 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ /dev/null @@ -1,223 +0,0 @@ -import _ from 'underscore'; -import { scaleLinear, scaleTime } from 'd3-scale'; -import { line, area, curveLinear } from 'd3-shape'; -import { extent, max, sum } from 'd3-array'; -import { timeMinute, timeSecond } from 'd3-time'; -import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; - -const d3 = { - scaleLinear, - scaleTime, - line, - area, - curveLinear, - extent, - max, - timeMinute, - timeSecond, - sum, -}; - -const defaultColorPalette = { - blue: ['#1f78d1', '#8fbce8'], - orange: ['#fc9403', '#feca81'], - red: ['#db3b21', '#ed9d90'], - green: ['#1aaa55', '#8dd5aa'], - purple: ['#6666c4', '#d1d1f0'], -}; - -const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple']; - -const defaultStyleOrder = ['solid', 'dashed', 'dotted']; - -function queryTimeSeries(query, graphDrawData, lineStyle) { - let usedColors = []; - let renderCanary = false; - const timeSeriesParsed = []; - - function pickColor(name) { - let pick; - if (name && defaultColorPalette[name]) { - pick = name; - } else { - const unusedColors = _.difference(defaultColorOrder, usedColors); - if (unusedColors.length > 0) { - [pick] = unusedColors; - } else { - usedColors = []; - [pick] = defaultColorOrder; - } - } - usedColors.push(pick); - return defaultColorPalette[pick]; - } - - function findByDate(series, time) { - const val = series.find(v => Math.abs(d3.timeSecond.count(time, v.time)) < 60); - if (val) { - return val.value; - } - return NaN; - } - - // The timeseries data may have gaps in it - // but we need a regularly-spaced set of time/value pairs - // this gives us a complete range of one minute intervals - // offset the same amount as the original data - const [minX, maxX] = graphDrawData.xDom; - const offset = d3.timeMinute(minX) - Number(minX); - const datesWithoutGaps = d3.timeSecond - .every(60) - .range(d3.timeMinute.offset(minX, -1), maxX) - .map(d => d - offset); - - query.result.forEach((timeSeries, timeSeriesNumber) => { - let metricTag = ''; - let lineColor = ''; - let areaColor = ''; - let shouldRenderLegend = true; - const timeSeriesValues = timeSeries.values.map(d => d.value); - const maximumValue = d3.max(timeSeriesValues); - const accum = d3.sum(timeSeriesValues); - const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable'); - - if (trackName === 'Canary') { - renderCanary = true; - } - - const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]]; - const seriesCustomizationData = - query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel }); - - if (seriesCustomizationData) { - metricTag = seriesCustomizationData.value || timeSeriesMetricLabel; - [lineColor, areaColor] = pickColor(seriesCustomizationData.color); - if (timeSeriesParsed.length > 0) { - shouldRenderLegend = false; - } else { - shouldRenderLegend = true; - } - } else { - metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`; - [lineColor, areaColor] = pickColor(); - if (timeSeriesParsed.length > 1) { - shouldRenderLegend = false; - } - } - - const values = datesWithoutGaps.map(time => ({ - time, - value: findByDate(timeSeries.values, time), - })); - - timeSeriesParsed.push({ - linePath: graphDrawData.lineFunction(values), - areaPath: graphDrawData.areaBelowLine(values), - timeSeriesScaleX: graphDrawData.timeSeriesScaleX, - timeSeriesScaleY: graphDrawData.timeSeriesScaleY, - values: timeSeries.values, - max: maximumValue, - average: accum / timeSeries.values.length, - lineStyle, - lineColor, - areaColor, - metricTag, - trackName, - shouldRenderLegend, - renderCanary, - }); - - if (!shouldRenderLegend) { - if (!timeSeriesParsed[0].tracksLegend) { - timeSeriesParsed[0].tracksLegend = []; - } - timeSeriesParsed[0].tracksLegend.push({ - max: maximumValue, - average: accum / timeSeries.values.length, - lineStyle, - lineColor, - metricTag, - }); - } - }); - - return timeSeriesParsed; -} - -function xyDomain(queries) { - const allValues = queries.reduce( - (allQueryResults, query) => - allQueryResults.concat( - query.result.reduce((allResults, result) => allResults.concat(result.values), []), - ), - [], - ); - - const xDom = d3.extent(allValues, d => d.time); - const yDom = [0, d3.max(allValues.map(d => d.value))]; - - return { - xDom, - yDom, - }; -} - -export function generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset) { - const { xDom, yDom } = xyDomain(queries); - - const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]); - const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]); - - timeSeriesScaleX.domain(xDom); - timeSeriesScaleX.ticks(d3.timeMinute, 60); - timeSeriesScaleY.domain(yDom); - - const defined = d => !Number.isNaN(d.value) && d.value != null; - - const lineFunction = d3 - .line() - .defined(defined) - .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate - .x(d => timeSeriesScaleX(d.time)) - .y(d => timeSeriesScaleY(d.value)); - - const areaBelowLine = d3 - .area() - .defined(defined) - .curve(d3.curveLinear) - .x(d => timeSeriesScaleX(d.time)) - .y0(graphHeight - graphHeightOffset) - .y1(d => timeSeriesScaleY(d.value)); - - const areaAboveLine = d3 - .area() - .defined(defined) - .curve(d3.curveLinear) - .x(d => timeSeriesScaleX(d.time)) - .y0(0) - .y1(d => timeSeriesScaleY(d.value)); - - return { - lineFunction, - areaBelowLine, - areaAboveLine, - xDom, - yDom, - timeSeriesScaleX, - timeSeriesScaleY, - }; -} - -export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) { - const graphDrawData = generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset); - - const timeSeries = queries.reduce((series, query, index) => { - const lineStyle = defaultStyleOrder[index % defaultStyleOrder.length]; - return series.concat(queryTimeSeries(query, graphDrawData, lineStyle)); - }, []); - - return { - timeSeries, - graphDrawData, - }; -} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bb98fc06ed6..8caa876e6b0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4447,9 +4447,6 @@ msgstr "" msgid "Metrics|Learn about environments" msgstr "" -msgid "Metrics|No data to display" -msgstr "" - msgid "Metrics|No deployed environments" msgstr "" @@ -5711,9 +5708,6 @@ msgstr "" msgid "ProjectsDropdown|This feature requires browser localStorage support" msgstr "" -msgid "PrometheusDashboard|Time" -msgstr "" - msgid "PrometheusService|%{exporters} with %{metrics} were found" msgstr "" diff --git a/spec/javascripts/monitoring/graph/axis_spec.js b/spec/javascripts/monitoring/graph/axis_spec.js deleted file mode 100644 index c7adba00637..00000000000 --- a/spec/javascripts/monitoring/graph/axis_spec.js +++ /dev/null @@ -1,65 +0,0 @@ -import Vue from 'vue'; -import GraphAxis from '~/monitoring/components/graph/axis.vue'; -import measurements from '~/monitoring/utils/measurements'; - -const createComponent = propsData => { - const Component = Vue.extend(GraphAxis); - - return new Component({ - propsData, - }).$mount(); -}; - -const defaultValuesComponent = { - graphWidth: 500, - graphHeight: 300, - graphHeightOffset: 120, - margin: measurements.large.margin, - measurements: measurements.large, - yAxisLabel: 'Values', - unitOfDisplay: 'MB', -}; - -function getTextFromNode(component, selector) { - return component.$el.querySelector(selector).firstChild.nodeValue.trim(); -} - -describe('Axis', () => { - describe('Computed props', () => { - it('textTransform', () => { - const component = createComponent(defaultValuesComponent); - - expect(component.textTransform).toContain('translate(15, 120) rotate(-90)'); - }); - - it('xPosition', () => { - const component = createComponent(defaultValuesComponent); - - expect(component.xPosition).toEqual(180); - }); - - it('yPosition', () => { - const component = createComponent(defaultValuesComponent); - - expect(component.yPosition).toEqual(240); - }); - - it('rectTransform', () => { - const component = createComponent(defaultValuesComponent); - - expect(component.rectTransform).toContain('translate(0, 120) rotate(-90)'); - }); - }); - - it('has 2 rect-axis-text rect svg elements', () => { - const component = createComponent(defaultValuesComponent); - - expect(component.$el.querySelectorAll('.rect-axis-text').length).toEqual(2); - }); - - it('contains text to signal the usage, title and time with multiple time series', () => { - const component = createComponent(defaultValuesComponent); - - expect(getTextFromNode(component, '.y-label-text')).toEqual('Values (MB)'); - }); -}); diff --git a/spec/javascripts/monitoring/graph/deployment_spec.js b/spec/javascripts/monitoring/graph/deployment_spec.js deleted file mode 100644 index 7d39c4345d2..00000000000 --- a/spec/javascripts/monitoring/graph/deployment_spec.js +++ /dev/null @@ -1,53 +0,0 @@ -import Vue from 'vue'; -import GraphDeployment from '~/monitoring/components/graph/deployment.vue'; -import { deploymentData } from '../mock_data'; - -const createComponent = propsData => { - const Component = Vue.extend(GraphDeployment); - - return new Component({ - propsData, - }).$mount(); -}; - -describe('MonitoringDeployment', () => { - describe('Methods', () => { - it('should contain a hidden gradient', () => { - const component = createComponent({ - showDeployInfo: true, - deploymentData, - graphHeight: 300, - graphWidth: 440, - graphHeightOffset: 120, - }); - - expect(component.$el.querySelector('#shadow-gradient')).not.toBeNull(); - }); - - it('transformDeploymentGroup translates an available deployment', () => { - const component = createComponent({ - showDeployInfo: false, - deploymentData, - graphHeight: 300, - graphWidth: 440, - graphHeightOffset: 120, - }); - - expect(component.transformDeploymentGroup({ xPos: 16 })).toContain('translate(11, 20)'); - }); - - describe('Computed props', () => { - it('calculatedHeight', () => { - const component = createComponent({ - showDeployInfo: true, - deploymentData, - graphHeight: 300, - graphWidth: 440, - graphHeightOffset: 120, - }); - - expect(component.calculatedHeight).toEqual(180); - }); - }); - }); -}); diff --git a/spec/javascripts/monitoring/graph/flag_spec.js b/spec/javascripts/monitoring/graph/flag_spec.js deleted file mode 100644 index 038bfffd44f..00000000000 --- a/spec/javascripts/monitoring/graph/flag_spec.js +++ /dev/null @@ -1,133 +0,0 @@ -import Vue from 'vue'; -import GraphFlag from '~/monitoring/components/graph/flag.vue'; -import { deploymentData } from '../mock_data'; - -const createComponent = propsData => { - const Component = Vue.extend(GraphFlag); - - return new Component({ - propsData, - }).$mount(); -}; - -const defaultValuesComponent = { - currentXCoordinate: 200, - currentYCoordinate: 100, - currentFlagPosition: 100, - currentData: { - time: new Date('2017-06-04T18:17:33.501Z'), - value: '1.49609375', - }, - graphHeight: 300, - graphHeightOffset: 120, - showFlagContent: true, - realPixelRatio: 1, - timeSeries: [ - { - values: [ - { - time: new Date('2017-06-04T18:17:33.501Z'), - value: '1.49609375', - }, - ], - }, - ], - unitOfDisplay: 'ms', - currentDataIndex: 0, - legendTitle: 'Average', - currentCoordinates: {}, -}; - -const deploymentFlagData = { - ...deploymentData[0], - ref: deploymentData[0].ref.name, - xPos: 10, - time: new Date(deploymentData[0].created_at), -}; - -describe('GraphFlag', () => { - let component; - - it('has a line at the currentXCoordinate', () => { - component = createComponent(defaultValuesComponent); - - expect(component.$el.style.left).toEqual(`${70 + component.currentXCoordinate}px`); - }); - - describe('Deployment flag', () => { - it('shows a deployment flag when deployment data provided', () => { - const deploymentFlagComponent = createComponent({ - ...defaultValuesComponent, - deploymentFlagData, - }); - - expect(deploymentFlagComponent.$el.querySelector('.popover-title')).toContainText('Deployed'); - }); - - it('contains the ref when a tag is available', () => { - const deploymentFlagComponent = createComponent({ - ...defaultValuesComponent, - deploymentFlagData: { - ...deploymentFlagData, - sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', - tag: true, - ref: '1.0', - }, - }); - - expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).toContainText( - 'f5bcd1d9', - ); - - expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).toContainText( - '1.0', - ); - }); - - it('does not contain the ref when a tag is unavailable', () => { - const deploymentFlagComponent = createComponent({ - ...defaultValuesComponent, - deploymentFlagData: { - ...deploymentFlagData, - sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', - tag: false, - ref: '1.0', - }, - }); - - expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).toContainText( - 'f5bcd1d9', - ); - - expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).not.toContainText( - '1.0', - ); - }); - }); - - describe('Computed props', () => { - beforeEach(() => { - component = createComponent(defaultValuesComponent); - }); - - it('formatTime', () => { - expect(component.formatTime).toMatch(/\d:17PM/); - }); - - it('formatDate', () => { - expect(component.formatDate).toEqual('04 Jun 2017, '); - }); - - it('cursorStyle', () => { - expect(component.cursorStyle).toEqual({ - top: '20px', - left: '270px', - height: '180px', - }); - }); - - it('flagOrientation', () => { - expect(component.flagOrientation).toEqual('left'); - }); - }); -}); diff --git a/spec/javascripts/monitoring/graph/legend_spec.js b/spec/javascripts/monitoring/graph/legend_spec.js deleted file mode 100644 index 9209e77dcf4..00000000000 --- a/spec/javascripts/monitoring/graph/legend_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import Vue from 'vue'; -import GraphLegend from '~/monitoring/components/graph/legend.vue'; -import createTimeSeries from '~/monitoring/utils/multiple_time_series'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data'; - -const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); - -const defaultValuesComponent = {}; - -const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120); - -defaultValuesComponent.timeSeries = timeSeries; - -describe('Legend Component', () => { - let vm; - let Legend; - - beforeEach(() => { - Legend = Vue.extend(GraphLegend); - }); - - describe('View', () => { - beforeEach(() => { - vm = mountComponent(Legend, { - legendTitle: 'legend', - timeSeries, - currentDataIndex: 0, - unitOfDisplay: 'Req/Sec', - }); - }); - - it('should render the usage, title and time with multiple time series', () => { - const titles = vm.$el.querySelectorAll('.legend-metric-title'); - - expect(titles[0].textContent.indexOf('1xx')).not.toEqual(-1); - expect(titles[1].textContent.indexOf('2xx')).not.toEqual(-1); - }); - - it('should container the same number of rows in the table as time series', () => { - expect(vm.$el.querySelectorAll('.prometheus-table tr').length).toEqual(vm.timeSeries.length); - }); - }); -}); diff --git a/spec/javascripts/monitoring/graph/track_info_spec.js b/spec/javascripts/monitoring/graph/track_info_spec.js deleted file mode 100644 index ce93ae28842..00000000000 --- a/spec/javascripts/monitoring/graph/track_info_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import Vue from 'vue'; -import TrackInfo from '~/monitoring/components/graph/track_info.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import createTimeSeries from '~/monitoring/utils/multiple_time_series'; -import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data'; - -const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); -const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120); - -describe('TrackInfo component', () => { - let vm; - let Component; - - beforeEach(() => { - Component = Vue.extend(TrackInfo); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('Computed props', () => { - beforeEach(() => { - vm = mountComponent(Component, { track: timeSeries[0] }); - }); - - it('summaryMetrics', () => { - expect(vm.summaryMetrics).toEqual('Avg: 0.000 · Max: 0.000'); - }); - }); - - describe('Rendered output', () => { - beforeEach(() => { - vm = mountComponent(Component, { track: timeSeries[0] }); - }); - - it('contains metric tag and the summary metrics', () => { - const metricTag = vm.$el.querySelector('strong'); - - expect(metricTag.textContent.trim()).toEqual(vm.track.metricTag); - expect(vm.$el.textContent).toContain('Avg: 0.000 · Max: 0.000'); - }); - }); -}); diff --git a/spec/javascripts/monitoring/graph/track_line_spec.js b/spec/javascripts/monitoring/graph/track_line_spec.js deleted file mode 100644 index 2a4f89ddf6e..00000000000 --- a/spec/javascripts/monitoring/graph/track_line_spec.js +++ /dev/null @@ -1,52 +0,0 @@ -import Vue from 'vue'; -import TrackLine from '~/monitoring/components/graph/track_line.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import createTimeSeries from '~/monitoring/utils/multiple_time_series'; -import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data'; - -const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); -const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120); - -describe('TrackLine component', () => { - let vm; - let Component; - - beforeEach(() => { - Component = Vue.extend(TrackLine); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('Computed props', () => { - it('stylizedLine for dashed lineStyles', () => { - vm = mountComponent(Component, { track: { ...timeSeries[0], lineStyle: 'dashed' } }); - - expect(vm.stylizedLine).toEqual('6, 3'); - }); - - it('stylizedLine for dotted lineStyles', () => { - vm = mountComponent(Component, { track: { ...timeSeries[0], lineStyle: 'dotted' } }); - - expect(vm.stylizedLine).toEqual('3, 3'); - }); - }); - - describe('Rendered output', () => { - it('has an svg with a line', () => { - vm = mountComponent(Component, { track: { ...timeSeries[0] } }); - const svgEl = vm.$el.querySelector('svg'); - const lineEl = vm.$el.querySelector('svg line'); - - expect(svgEl.getAttribute('width')).toEqual('16'); - expect(svgEl.getAttribute('height')).toEqual('8'); - - expect(lineEl.getAttribute('stroke-width')).toEqual('4'); - expect(lineEl.getAttribute('x1')).toEqual('0'); - expect(lineEl.getAttribute('x2')).toEqual('16'); - expect(lineEl.getAttribute('y1')).toEqual('4'); - expect(lineEl.getAttribute('y2')).toEqual('4'); - }); - }); -}); diff --git a/spec/javascripts/monitoring/graph_path_spec.js b/spec/javascripts/monitoring/graph_path_spec.js deleted file mode 100644 index fd167b83d51..00000000000 --- a/spec/javascripts/monitoring/graph_path_spec.js +++ /dev/null @@ -1,56 +0,0 @@ -import Vue from 'vue'; -import GraphPath from '~/monitoring/components/graph/path.vue'; -import createTimeSeries from '~/monitoring/utils/multiple_time_series'; -import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from './mock_data'; - -const createComponent = propsData => { - const Component = Vue.extend(GraphPath); - - return new Component({ - propsData, - }).$mount(); -}; - -const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); - -const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120); -const firstTimeSeries = timeSeries[0]; - -describe('Monitoring Paths', () => { - it('renders two paths to represent a line and the area underneath it', () => { - const component = createComponent({ - generatedLinePath: firstTimeSeries.linePath, - generatedAreaPath: firstTimeSeries.areaPath, - lineColor: firstTimeSeries.lineColor, - areaColor: firstTimeSeries.areaColor, - showDot: false, - }); - const metricArea = component.$el.querySelector('.metric-area'); - const metricLine = component.$el.querySelector('.metric-line'); - - expect(metricArea.getAttribute('fill')).toBe('#8fbce8'); - expect(metricArea.getAttribute('d')).toBe(firstTimeSeries.areaPath); - expect(metricLine.getAttribute('stroke')).toBe('#1f78d1'); - expect(metricLine.getAttribute('d')).toBe(firstTimeSeries.linePath); - }); - - describe('Computed properties', () => { - it('strokeDashArray', () => { - const component = createComponent({ - generatedLinePath: firstTimeSeries.linePath, - generatedAreaPath: firstTimeSeries.areaPath, - lineColor: firstTimeSeries.lineColor, - areaColor: firstTimeSeries.areaColor, - showDot: false, - }); - - component.lineStyle = 'dashed'; - - expect(component.strokeDashArray).toBe('3, 1'); - - component.lineStyle = 'dotted'; - - expect(component.strokeDashArray).toBe('1, 1'); - }); - }); -}); diff --git a/spec/javascripts/monitoring/graph_spec.js b/spec/javascripts/monitoring/graph_spec.js deleted file mode 100644 index 59d6d4f3a7f..00000000000 --- a/spec/javascripts/monitoring/graph_spec.js +++ /dev/null @@ -1,127 +0,0 @@ -import Vue from 'vue'; -import Graph from '~/monitoring/components/graph.vue'; -import MonitoringMixins from '~/monitoring/mixins/monitoring_mixins'; -import { - deploymentData, - convertDatesMultipleSeries, - singleRowMetricsMultipleSeries, - queryWithoutData, -} from './mock_data'; - -const tagsPath = 'http://test.host/frontend-fixtures/environments-project/tags'; -const projectPath = 'http://test.host/frontend-fixtures/environments-project'; -const createComponent = propsData => { - const Component = Vue.extend(Graph); - - return new Component({ - propsData, - }).$mount(); -}; - -const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); - -describe('Graph', () => { - beforeEach(() => { - spyOn(MonitoringMixins.methods, 'formatDeployments').and.returnValue({}); - }); - - it('has a title', () => { - const component = createComponent({ - graphData: convertedMetrics[1], - updateAspectRatio: false, - deploymentData, - tagsPath, - projectPath, - }); - - expect(component.$el.querySelector('.prometheus-graph-title').innerText.trim()).toBe( - component.graphData.title, - ); - }); - - describe('Computed props', () => { - it('axisTransform translates an element Y position depending of its height', () => { - const component = createComponent({ - graphData: convertedMetrics[1], - updateAspectRatio: false, - deploymentData, - tagsPath, - projectPath, - }); - - const transformedHeight = `${component.graphHeight - 100}`; - - expect(component.axisTransform.indexOf(transformedHeight)).not.toEqual(-1); - }); - - it('outerViewBox gets a width and height property based on the DOM size of the element', () => { - const component = createComponent({ - graphData: convertedMetrics[1], - updateAspectRatio: false, - deploymentData, - tagsPath, - projectPath, - }); - - const viewBoxArray = component.outerViewBox.split(' '); - - expect(typeof component.outerViewBox).toEqual('string'); - expect(viewBoxArray[2]).toEqual(component.graphWidth.toString()); - expect(viewBoxArray[3]).toEqual((component.graphHeight - 50).toString()); - }); - }); - - it('has a title for the y-axis and the chart legend that comes from the backend', () => { - const component = createComponent({ - graphData: convertedMetrics[1], - updateAspectRatio: false, - deploymentData, - tagsPath, - projectPath, - }); - - expect(component.yAxisLabel).toEqual(component.graphData.y_label); - expect(component.legendTitle).toEqual(component.graphData.queries[0].label); - }); - - it('sets the currentData object based on the hovered data index', () => { - const component = createComponent({ - graphData: convertedMetrics[1], - updateAspectRatio: false, - deploymentData, - graphIdentifier: 0, - hoverData: { - hoveredDate: new Date('Sun Aug 27 2017 06:11:51 GMT-0500 (CDT)'), - currentDeployXPos: null, - }, - tagsPath, - projectPath, - }); - - // simulate moving mouse over data series - component.seriesUnderMouse = component.timeSeries; - - component.positionFlag(); - - expect(component.currentData).toBe(component.timeSeries[0].values[10]); - }); - - describe('Without data to display', () => { - it('shows a "no data to display" empty state on a graph', done => { - const component = createComponent({ - graphData: queryWithoutData, - deploymentData, - tagsPath, - projectPath, - }); - - Vue.nextTick(() => { - expect( - component.$el.querySelector('.js-no-data-to-display text').textContent.trim(), - ).toEqual('No data to display'); - - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/monitoring/utils/multiple_time_series_spec.js b/spec/javascripts/monitoring/utils/multiple_time_series_spec.js deleted file mode 100644 index 8937b7d9680..00000000000 --- a/spec/javascripts/monitoring/utils/multiple_time_series_spec.js +++ /dev/null @@ -1,22 +0,0 @@ -import createTimeSeries from '~/monitoring/utils/multiple_time_series'; -import { convertDatesMultipleSeries, singleRowMetricsMultipleSeries } from '../mock_data'; - -const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); -const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120); -const firstTimeSeries = timeSeries[0]; - -describe('Multiple time series', () => { - it('createTimeSeries returned array contains an object for each element', () => { - expect(typeof firstTimeSeries.linePath).toEqual('string'); - expect(typeof firstTimeSeries.areaPath).toEqual('string'); - expect(typeof firstTimeSeries.timeSeriesScaleX).toEqual('function'); - expect(typeof firstTimeSeries.areaColor).toEqual('string'); - expect(typeof firstTimeSeries.lineColor).toEqual('string'); - expect(firstTimeSeries.values instanceof Array).toEqual(true); - }); - - it('createTimeSeries returns an array', () => { - expect(timeSeries instanceof Array).toEqual(true); - expect(timeSeries.length).toEqual(2); - }); -}); -- cgit v1.2.3 From 63d220f237486ad0865873ccb52d3abfede386b7 Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Fri, 1 Feb 2019 12:51:08 +0500 Subject: Fix ReturnInVoidContext rubocop offense --- .rubocop_todo.yml | 5 ----- app/models/project.rb | 12 +++++++----- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index c42d11a860e..77ad4753c84 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -80,11 +80,6 @@ Lint/InterpolationCheck: Lint/MissingCopEnableDirective: Enabled: false -# Offense count: 1 -Lint/ReturnInVoidContext: - Exclude: - - 'app/models/project.rb' - # Offense count: 9 Lint/UriEscapeUnescape: Exclude: diff --git a/app/models/project.rb b/app/models/project.rb index b385b89449d..e55a57e46ef 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -738,11 +738,13 @@ class Project < ActiveRecord::Base end def import_url=(value) - return super(value) unless Gitlab::UrlSanitizer.valid?(value) - - import_url = Gitlab::UrlSanitizer.new(value) - super(import_url.sanitized_url) - create_or_update_import_data(credentials: import_url.credentials) + if Gitlab::UrlSanitizer.valid?(value) + import_url = Gitlab::UrlSanitizer.new(value) + super(import_url.sanitized_url) + create_or_update_import_data(credentials: import_url.credentials) + else + super(value) + end end def import_url -- cgit v1.2.3 From b43ffd0d42ae5129ed63a18ccd45abb6f026411a Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Fri, 1 Feb 2019 12:51:33 +0500 Subject: Fix warning already initialized constant --- spec/factories/projects.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 1906c06a211..18fab395cc2 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -1,7 +1,7 @@ require_relative '../support/helpers/test_env' FactoryBot.define do - PAGES_ACCESS_LEVEL_SCHEMA_VERSION = 20180423204600 + PAGES_ACCESS_LEVEL_SCHEMA_VERSION ||= 20180423204600 # Project without repository # -- cgit v1.2.3 From 5b2110553ee0d5d524875deda3a20446bb7eb7c4 Mon Sep 17 00:00:00 2001 From: Jacques Erasmus Date: Fri, 1 Feb 2019 10:44:49 +0000 Subject: Fix cluster installation processing spinner (reopened) --- app/assets/javascripts/clusters/clusters_bundle.js | 28 +++++----- .../clusters/components/application_row.vue | 59 +++++++++++----------- app/assets/javascripts/clusters/constants.js | 3 +- ...6398-fix-cluster-installation-loading-state.yml | 5 ++ .../projects/clusters/applications_spec.rb | 10 ++-- spec/javascripts/clusters/clusters_bundle_spec.js | 57 ++++----------------- .../clusters/components/application_row_spec.js | 49 +++++++----------- 7 files changed, 81 insertions(+), 130 deletions(-) create mode 100644 changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index b1f992c03ff..fc4779632f9 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -6,7 +6,7 @@ import Flash from '../flash'; import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; -import { APPLICATION_STATUS, REQUEST_LOADING, REQUEST_SUCCESS, REQUEST_FAILURE } from './constants'; +import { APPLICATION_STATUS, REQUEST_SUBMITTED, REQUEST_FAILURE } from './constants'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import Applications from './components/applications.vue'; @@ -231,22 +231,18 @@ export default class Clusters { installApplication(data) { const appId = data.id; - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING); + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUBMITTED); this.store.updateAppProperty(appId, 'requestReason', null); - - this.service - .installApplication(appId, data.params) - .then(() => { - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS); - }) - .catch(() => { - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); - this.store.updateAppProperty( - appId, - 'requestReason', - s__('ClusterIntegration|Request to begin installing failed'), - ); - }); + this.store.updateAppProperty(appId, 'statusReason', null); + + this.service.installApplication(appId, data.params).catch(() => { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); + this.store.updateAppProperty( + appId, + 'requestReason', + s__('ClusterIntegration|Request to begin installing failed'), + ); + }); } destroy() { diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index d4354dcfebd..3c3ce1dec56 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -4,12 +4,7 @@ import { s__, sprintf } from '../../locale'; import eventHub from '../event_hub'; import identicon from '../../vue_shared/components/identicon.vue'; import loadingButton from '../../vue_shared/components/loading_button.vue'; -import { - APPLICATION_STATUS, - REQUEST_LOADING, - REQUEST_SUCCESS, - REQUEST_FAILURE, -} from '../constants'; +import { APPLICATION_STATUS, REQUEST_SUBMITTED, REQUEST_FAILURE } from '../constants'; export default { components: { @@ -72,6 +67,13 @@ export default { isKnownStatus() { return Object.values(APPLICATION_STATUS).includes(this.status); }, + isInstalling() { + return ( + this.status === APPLICATION_STATUS.SCHEDULED || + this.status === APPLICATION_STATUS.INSTALLING || + (this.requestStatus === REQUEST_SUBMITTED && !this.statusReason && !this.isInstalled) + ); + }, isInstalled() { return ( this.status === APPLICATION_STATUS.INSTALLED || @@ -79,6 +81,18 @@ export default { this.status === APPLICATION_STATUS.UPDATING ); }, + canInstall() { + if (this.isInstalling) { + return false; + } + + return ( + this.status === APPLICATION_STATUS.NOT_INSTALLABLE || + this.status === APPLICATION_STATUS.INSTALLABLE || + this.status === APPLICATION_STATUS.ERROR || + this.isUnknownStatus + ); + }, hasLogo() { return !!this.logoUrl; }, @@ -90,12 +104,7 @@ export default { return `js-cluster-application-row-${this.id}`; }, installButtonLoading() { - return ( - !this.status || - this.status === APPLICATION_STATUS.SCHEDULED || - this.status === APPLICATION_STATUS.INSTALLING || - this.requestStatus === REQUEST_LOADING - ); + return !this.status || this.status === APPLICATION_STATUS.SCHEDULED || this.isInstalling; }, installButtonDisabled() { // Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but @@ -104,30 +113,17 @@ export default { return ( ((this.status !== APPLICATION_STATUS.INSTALLABLE && this.status !== APPLICATION_STATUS.ERROR) || - this.requestStatus === REQUEST_LOADING || - this.requestStatus === REQUEST_SUCCESS) && + this.isInstalling) && this.isKnownStatus ); }, installButtonLabel() { let label; - if ( - this.status === APPLICATION_STATUS.NOT_INSTALLABLE || - this.status === APPLICATION_STATUS.INSTALLABLE || - this.status === APPLICATION_STATUS.ERROR || - this.isUnknownStatus - ) { + if (this.canInstall) { label = s__('ClusterIntegration|Install'); - } else if ( - this.status === APPLICATION_STATUS.SCHEDULED || - this.status === APPLICATION_STATUS.INSTALLING - ) { + } else if (this.isInstalling) { label = s__('ClusterIntegration|Installing'); - } else if ( - this.status === APPLICATION_STATUS.INSTALLED || - this.status === APPLICATION_STATUS.UPDATED || - this.status === APPLICATION_STATUS.UPDATING - ) { + } else if (this.isInstalled) { label = s__('ClusterIntegration|Installed'); } @@ -140,7 +136,10 @@ export default { return s__('ClusterIntegration|Manage'); }, hasError() { - return this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE; + return ( + !this.isInstalling && + (this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE) + ); }, generalErrorDescription() { return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), { diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index e31afadf186..360511e8882 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -18,8 +18,7 @@ export const APPLICATION_STATUS = { }; // These are only used client-side -export const REQUEST_LOADING = 'request-loading'; -export const REQUEST_SUCCESS = 'request-success'; +export const REQUEST_SUBMITTED = 'request-submitted'; export const REQUEST_FAILURE = 'request-failure'; export const INGRESS = 'ingress'; export const JUPYTER = 'jupyter'; diff --git a/changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml b/changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml new file mode 100644 index 00000000000..19ff408ddf4 --- /dev/null +++ b/changelogs/unreleased/56398-fix-cluster-installation-loading-state.yml @@ -0,0 +1,5 @@ +--- +title: Fix cluster installation processing spinner +merge_request: 24814 +author: +type: fixed diff --git a/spec/features/projects/clusters/applications_spec.rb b/spec/features/projects/clusters/applications_spec.rb index fab9e035d53..2c8d014c36d 100644 --- a/spec/features/projects/clusters/applications_spec.rb +++ b/spec/features/projects/clusters/applications_spec.rb @@ -48,9 +48,9 @@ describe 'Clusters Applications', :js do it 'they see status transition' do page.within('.js-cluster-application-row-helm') do - # FE sends request and gets the response, then the buttons is "Install" + # FE sends request and gets the response, then the buttons is "Installing" expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true') - expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install') + expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing') wait_until_helm_created! @@ -118,7 +118,7 @@ describe 'Clusters Applications', :js do page.within('.js-cluster-application-row-cert_manager') do expect(email_form_value).to eq(cluster.user.email) - expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install') + expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing') page.find('.js-email').set("new_email@example.org") Clusters::Cluster.last.application_cert_manager.make_installing! @@ -153,9 +153,9 @@ describe 'Clusters Applications', :js do it 'they see status transition' do page.within('.js-cluster-application-row-ingress') do - # FE sends request and gets the response, then the buttons is "Install" + # FE sends request and gets the response, then the buttons is "Installing" expect(page).to have_css('.js-cluster-application-install-button[disabled]') - expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Install') + expect(page).to have_css('.js-cluster-application-install-button', exact_text: 'Installing') Clusters::Cluster.last.application_ingress.make_installing! diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js index 880b469284b..7928feeadfa 100644 --- a/spec/javascripts/clusters/clusters_bundle_spec.js +++ b/spec/javascripts/clusters/clusters_bundle_spec.js @@ -1,10 +1,5 @@ import Clusters from '~/clusters/clusters_bundle'; -import { - REQUEST_LOADING, - REQUEST_SUCCESS, - REQUEST_FAILURE, - APPLICATION_STATUS, -} from '~/clusters/constants'; +import { REQUEST_SUBMITTED, REQUEST_FAILURE, APPLICATION_STATUS } from '~/clusters/constants'; import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; describe('Clusters', () => { @@ -196,67 +191,43 @@ describe('Clusters', () => { }); describe('installApplication', () => { - it('tries to install helm', done => { + it('tries to install helm', () => { spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); expect(cluster.store.state.applications.helm.requestStatus).toEqual(null); cluster.installApplication({ id: 'helm' }); - expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING); + expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED); expect(cluster.store.state.applications.helm.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('helm', undefined); - - getSetTimeoutPromise() - .then(() => { - expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUCCESS); - expect(cluster.store.state.applications.helm.requestReason).toEqual(null); - }) - .then(done) - .catch(done.fail); }); - it('tries to install ingress', done => { + it('tries to install ingress', () => { spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); expect(cluster.store.state.applications.ingress.requestStatus).toEqual(null); cluster.installApplication({ id: 'ingress' }); - expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_LOADING); + expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_SUBMITTED); expect(cluster.store.state.applications.ingress.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress', undefined); - - getSetTimeoutPromise() - .then(() => { - expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_SUCCESS); - expect(cluster.store.state.applications.ingress.requestReason).toEqual(null); - }) - .then(done) - .catch(done.fail); }); - it('tries to install runner', done => { + it('tries to install runner', () => { spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); expect(cluster.store.state.applications.runner.requestStatus).toEqual(null); cluster.installApplication({ id: 'runner' }); - expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_LOADING); + expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_SUBMITTED); expect(cluster.store.state.applications.runner.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('runner', undefined); - - getSetTimeoutPromise() - .then(() => { - expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_SUCCESS); - expect(cluster.store.state.applications.runner.requestReason).toEqual(null); - }) - .then(done) - .catch(done.fail); }); - it('tries to install jupyter', done => { + it('tries to install jupyter', () => { spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(null); @@ -265,19 +236,11 @@ describe('Clusters', () => { params: { hostname: cluster.store.state.applications.jupyter.hostname }, }); - expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_LOADING); + expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_SUBMITTED); expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('jupyter', { hostname: cluster.store.state.applications.jupyter.hostname, }); - - getSetTimeoutPromise() - .then(() => { - expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_SUCCESS); - expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null); - }) - .then(done) - .catch(done.fail); }); it('sets error request status when the request fails', done => { @@ -289,7 +252,7 @@ describe('Clusters', () => { cluster.installApplication({ id: 'helm' }); - expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING); + expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED); expect(cluster.store.state.applications.helm.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalled(); diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js index 45d56514930..d1f4a1cebb4 100644 --- a/spec/javascripts/clusters/components/application_row_spec.js +++ b/spec/javascripts/clusters/components/application_row_spec.js @@ -1,11 +1,6 @@ import Vue from 'vue'; import eventHub from '~/clusters/event_hub'; -import { - APPLICATION_STATUS, - REQUEST_LOADING, - REQUEST_SUCCESS, - REQUEST_FAILURE, -} from '~/clusters/constants'; +import { APPLICATION_STATUS, REQUEST_SUBMITTED, REQUEST_FAILURE } from '~/clusters/constants'; import applicationRow from '~/clusters/components/application_row.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { DEFAULT_APPLICATION_STATE } from '../services/mock_data'; @@ -57,6 +52,12 @@ describe('Application Row', () => { expect(vm.installButtonLabel).toBeUndefined(); }); + it('has install button', () => { + const installationBtn = vm.$el.querySelector('.js-cluster-application-install-button'); + + expect(installationBtn).not.toBe(null); + }); + it('has disabled "Install" when APPLICATION_STATUS.NOT_INSTALLABLE', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, @@ -101,6 +102,18 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(true); }); + it('has loading "Installing" when REQUEST_SUBMITTED', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_STATUS.INSTALLABLE, + requestStatus: REQUEST_SUBMITTED, + }); + + expect(vm.installButtonLabel).toEqual('Installing'); + expect(vm.installButtonLoading).toEqual(true); + expect(vm.installButtonDisabled).toEqual(true); + }); + it('has disabled "Installed" when APPLICATION_STATUS.INSTALLED', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, @@ -134,30 +147,6 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(false); }); - it('has loading "Install" when REQUEST_LOADING', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.INSTALLABLE, - requestStatus: REQUEST_LOADING, - }); - - expect(vm.installButtonLabel).toEqual('Install'); - expect(vm.installButtonLoading).toEqual(true); - expect(vm.installButtonDisabled).toEqual(true); - }); - - it('has disabled "Install" when REQUEST_SUCCESS', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.INSTALLABLE, - requestStatus: REQUEST_SUCCESS, - }); - - expect(vm.installButtonLabel).toEqual('Install'); - expect(vm.installButtonLoading).toEqual(false); - expect(vm.installButtonDisabled).toEqual(true); - }); - it('has enabled "Install" when REQUEST_FAILURE (so you can try installing again)', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, -- cgit v1.2.3 From db35a3ae8aba3587dc5cadc1c44f1950502ff434 Mon Sep 17 00:00:00 2001 From: Mark Chao Date: Wed, 30 Jan 2019 12:51:10 +0800 Subject: Fix migration when project repository is missing The data migration looks for code owner file and errs if repository is missing. --- app/models/repository.rb | 2 ++ spec/models/repository_spec.rb | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/app/models/repository.rb b/app/models/repository.rb index b47238b52f1..e6ab3b484a2 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -525,6 +525,8 @@ class Repository # items is an Array like: [[oid, path], [oid1, path1]] def blobs_at(items) + return [] unless exists? + raw_repository.batch_blobs(items).map { |blob| Blob.decorate(blob, project) } end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index ac5874fd0f7..4978c43c9b5 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1237,6 +1237,27 @@ describe Repository do end end + describe '#blobs_at' do + let(:empty_repository) { create(:project_empty_repo).repository } + + it 'returns empty array for an empty repository' do + # rubocop:disable Style/WordArray + expect(empty_repository.blobs_at(['master', 'foobar'])).to eq([]) + # rubocop:enable Style/WordArray + end + + it 'returns blob array for a non-empty repository' do + repository.create_file(User.last, 'foobar', 'CONTENT', message: 'message', branch_name: 'master') + + # rubocop:disable Style/WordArray + blobs = repository.blobs_at([['master', 'foobar']]) + # rubocop:enable Style/WordArray + + expect(blobs.first.name).to eq('foobar') + expect(blobs.size).to eq(1) + end + end + describe '#root_ref' do it 'returns a branch name' do expect(repository.root_ref).to be_an_instance_of(String) -- cgit v1.2.3 From 33cd912b90c7021eedabbed25f07988e71ccf21c Mon Sep 17 00:00:00 2001 From: Adriel Santiago Date: Wed, 23 Jan 2019 18:42:31 -0500 Subject: Use ECharts for metrics dashboard Update metrics dashboard to support GitLab UI area chart changes --- .../monitoring/components/charts/area.vue | 21 +++++++++++---------- .../monitoring/stores/monitoring_store.js | 10 +++++----- app/assets/stylesheets/pages/environments.scss | 12 +----------- 3 files changed, 17 insertions(+), 26 deletions(-) diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index e2cffe0b4b4..5ca561259b6 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -35,13 +35,7 @@ export default { computed: { chartData() { return this.graphData.queries.reduce((accumulator, query) => { - const xLabel = `${query.unit}`; - accumulator[xLabel] = {}; - query.result.forEach(res => - res.values.forEach(v => { - accumulator[xLabel][v.time.toISOString()] = v.value; - }), - ); + accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []); return accumulator; }, {}); }, @@ -51,14 +45,17 @@ export default { name: 'Time', type: 'time', axisLabel: { - formatter: date => dateFormat(date, 'h:MMtt'), + formatter: date => dateFormat(date, 'h:MM TT'), + }, + axisPointer: { + snap: true, }, nameTextStyle: { padding: [18, 0, 0, 0], }, }, yAxis: { - name: this.graphData.y_label, + name: this.yAxisLabel, axisLabel: { formatter: value => value.toFixed(3), }, @@ -74,6 +71,10 @@ export default { xAxisLabel() { return this.graphData.queries.map(query => query.label).join(', '); }, + yAxisLabel() { + const [query] = this.graphData.queries; + return `${this.graphData.y_label} (${query.unit})`; + }, }, methods: { formatTooltipText(params) { @@ -85,7 +86,7 @@ export default { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue new file mode 100644 index 00000000000..b3c1c0e329d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue @@ -0,0 +1,38 @@ + + + diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue new file mode 100644 index 00000000000..a1d3a09cca4 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -0,0 +1,91 @@ + + + diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index b8f29649eb5..ce4207864ea 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -2,17 +2,24 @@ import successSvg from 'icons/_icon_status_success.svg'; import warningSvg from 'icons/_icon_status_warning.svg'; import simplePoll from '~/lib/utils/simple_poll'; +import { __ } from '~/locale'; import MergeRequest from '../../../merge_request'; import Flash from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; import SquashBeforeMerge from './squash_before_merge.vue'; +import CommitsHeader from './commits_header.vue'; +import CommitEdit from './commit_edit.vue'; +import CommitMessageDropdown from './commit_message_dropdown.vue'; export default { name: 'ReadyToMerge', components: { statusIcon, SquashBeforeMerge, + CommitsHeader, + CommitEdit, + CommitMessageDropdown, }, props: { mr: { type: Object, required: true }, @@ -22,27 +29,20 @@ export default { return { removeSourceBranch: this.mr.shouldRemoveSourceBranch, mergeWhenBuildSucceeds: false, - useCommitMessageWithDescription: false, setToMergeWhenPipelineSucceeds: false, - showCommitMessageEditor: false, isMakingRequest: false, isMergingImmediately: false, commitMessage: this.mr.commitMessage, squashBeforeMerge: this.mr.squash, successSvg, warningSvg, + squashCommitMessage: this.mr.squashCommitMessage, }; }, computed: { shouldShowMergeWhenPipelineSucceedsText() { return this.mr.isPipelineActive; }, - commitMessageLinkTitle() { - const withDesc = 'Include description in commit message'; - const withoutDesc = "Don't include description in commit message"; - - return this.useCommitMessageWithDescription ? withoutDesc : withDesc; - }, status() { const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr; @@ -84,9 +84,9 @@ export default { }, mergeButtonText() { if (this.isMergingImmediately) { - return 'Merge in progress'; + return __('Merge in progress'); } else if (this.shouldShowMergeWhenPipelineSucceedsText) { - return 'Merge when pipeline succeeds'; + return __('Merge when pipeline succeeds'); } return 'Merge'; @@ -98,7 +98,7 @@ export default { const { commitMessage } = this; return Boolean( !commitMessage.length || - !this.shouldShowMergeControls() || + !this.shouldShowMergeControls || this.isMakingRequest || this.mr.preventMerge, ); @@ -110,18 +110,14 @@ export default { const { commitsCount, enableSquashBeforeMerge } = this.mr; return enableSquashBeforeMerge && commitsCount > 1; }, - }, - methods: { shouldShowMergeControls() { return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText; }, - updateCommitMessage() { - const cmwd = this.mr.commitMessageWithDescription; - this.useCommitMessageWithDescription = !this.useCommitMessageWithDescription; - this.commitMessage = this.useCommitMessageWithDescription ? cmwd : this.mr.commitMessage; - }, - toggleCommitMessageEditor() { - this.showCommitMessageEditor = !this.showCommitMessageEditor; + }, + methods: { + updateMergeCommitMessage(includeDescription) { + const { commitMessageWithDescription, commitMessage } = this.mr; + this.commitMessage = includeDescription ? commitMessageWithDescription : commitMessage; }, handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) { // TODO: Remove no-param-reassign @@ -139,6 +135,7 @@ export default { merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds, should_remove_source_branch: this.removeSourceBranch === true, squash: this.squashBeforeMerge, + squash_commit_message: this.squashCommitMessage, }; this.isMakingRequest = true; @@ -158,7 +155,7 @@ export default { }) .catch(() => { this.isMakingRequest = false; - new Flash('Something went wrong. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line }); }, initiateMergePolling() { @@ -194,7 +191,7 @@ export default { } }) .catch(() => { - new Flash('Something went wrong while merging this merge request. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong while merging this merge request. Please try again.')); // eslint-disable-line }); }, initiateRemoveSourceBranchPolling() { @@ -223,7 +220,7 @@ export default { } }) .catch(() => { - new Flash('Something went wrong while deleting the source branch. Please try again.'); // eslint-disable-line + new Flash(__('Something went wrong while deleting the source branch. Please try again.')); // eslint-disable-line }); }, }, @@ -231,127 +228,136 @@ export default { diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 57c4dfbe3b7..abbbe19c5ef 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -315,7 +315,7 @@ export default { :endpoint="mr.testResultsPath" /> -
+
+ +
+ {{ versionLabel }} + + to + + + chart v{{ version }} + +
+ +
+ {{ upgradeFailureDescription }} +
+ +
+ {{ upgradeSuccessDescription }} + + +
+ +
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 360511e8882..39022879d91 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -12,15 +12,19 @@ export const APPLICATION_STATUS = { SCHEDULED: 'scheduled', INSTALLING: 'installing', INSTALLED: 'installed', - UPDATED: 'updated', UPDATING: 'updating', + UPDATED: 'updated', + UPDATE_ERRORED: 'update_errored', ERROR: 'errored', }; // These are only used client-side export const REQUEST_SUBMITTED = 'request-submitted'; export const REQUEST_FAILURE = 'request-failure'; +export const UPGRADE_REQUESTED = 'upgrade-requested'; +export const UPGRADE_REQUEST_FAILURE = 'upgrade-request-failure'; export const INGRESS = 'ingress'; export const JUPYTER = 'jupyter'; export const KNATIVE = 'knative'; +export const RUNNER = 'runner'; export const CERT_MANAGER = 'cert_manager'; diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 8f74be4e0e6..d309678be27 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -1,6 +1,6 @@ import { s__ } from '../../locale'; import { parseBoolean } from '../../lib/utils/common_utils'; -import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER } from '../constants'; +import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER, RUNNER } from '../constants'; export default class ClusterStore { constructor() { @@ -40,6 +40,9 @@ export default class ClusterStore { statusReason: null, requestStatus: null, requestReason: null, + version: null, + chartRepo: 'https://gitlab.com/charts/gitlab-runner', + upgradeAvailable: null, }, prometheus: { title: s__('ClusterIntegration|Prometheus'), @@ -100,7 +103,13 @@ export default class ClusterStore { this.state.statusReason = serverState.status_reason; serverState.applications.forEach(serverAppEntry => { - const { name: appId, status, status_reason: statusReason } = serverAppEntry; + const { + name: appId, + status, + status_reason: statusReason, + version, + update_available: upgradeAvailable, + } = serverAppEntry; this.state.applications[appId] = { ...(this.state.applications[appId] || {}), @@ -124,6 +133,9 @@ export default class ClusterStore { serverAppEntry.hostname || this.state.applications.knative.hostname; this.state.applications.knative.externalIp = serverAppEntry.external_ip || this.state.applications.knative.externalIp; + } else if (appId === RUNNER) { + this.state.applications.runner.version = version; + this.state.applications.runner.upgradeAvailable = upgradeAvailable; } }); } diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index ad12cd101b6..809ba6d4953 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -58,6 +58,20 @@ } } +.cluster-application-banner { + height: 45px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.cluster-application-banner-close { + align-self: flex-start; + font-weight: 500; + font-size: 20px; + margin: $gl-padding-8 14px 0 0; +} + .cluster-application-description { flex: 1; } diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 26bf73f4dd8..52c440ffb2f 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -53,11 +53,11 @@ module Clusters end def upgrade_command(values) - ::Gitlab::Kubernetes::Helm::UpgradeCommand.new( - name, + ::Gitlab::Kubernetes::Helm::InstallCommand.new( + name: name, version: VERSION, - chart: chart, rbac: cluster.platform_kubernetes_rbac?, + chart: chart, files: files_with_replaced_values(values) ) end diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index a556dd5ad8b..6fe7b4a6bd7 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -20,7 +20,7 @@ module Clusters state :update_errored, value: 6 event :make_scheduled do - transition [:installable, :errored] => :scheduled + transition [:installable, :errored, :installed, :updated, :update_errored] => :scheduled end event :make_installing do @@ -29,16 +29,19 @@ module Clusters event :make_installed do transition [:installing] => :installed + transition [:updating] => :updated end event :make_errored do - transition any => :errored + transition any - [:updating] => :errored + transition [:updating] => :update_errored end event :make_updating do - transition [:installed, :updated, :update_errored] => :updating + transition [:installed, :updated, :update_errored, :scheduled] => :updating end + # Deprecated event :make_updated do transition [:updating] => :updated end @@ -74,6 +77,10 @@ module Clusters end end + def updateable? + installed? || updated? || update_errored? + end + def available? installed? || updated? end diff --git a/app/models/clusters/concerns/application_version.rb b/app/models/clusters/concerns/application_version.rb index e355de23df6..db94e8e08c9 100644 --- a/app/models/clusters/concerns/application_version.rb +++ b/app/models/clusters/concerns/application_version.rb @@ -12,6 +12,10 @@ module Clusters end end end + + def update_available? + version != self.class.const_get(:VERSION) + end end end end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index 62b23a889c8..02df1480828 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -8,4 +8,5 @@ class ClusterApplicationEntity < Grape::Entity expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) } expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) } expose :email, if: -> (e, _) { e.respond_to?(:email) } + expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) } end diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index 21ec26ea233..c592d608b89 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -4,7 +4,7 @@ module Clusters module Applications class CheckInstallationProgressService < BaseHelmService def execute - return unless app.installing? + return unless operation_in_progress? case installation_phase when Gitlab::Kubernetes::Pod::SUCCEEDED @@ -16,11 +16,16 @@ module Clusters end rescue Kubeclient::HttpError => e log_error(e) - app.make_errored!("Kubernetes error: #{e.error_code}") unless app.errored? + + app.make_errored!("Kubernetes error: #{e.error_code}") end private + def operation_in_progress? + app.installing? || app.updating? + end + def on_success app.make_installed! ensure @@ -28,13 +33,13 @@ module Clusters end def on_failed - app.make_errored!("Installation failed. Check pod logs for #{install_command.pod_name} for more details.") + app.make_errored!("Operation failed. Check pod logs for #{pod_name} for more details.") end def check_timeout if timeouted? begin - app.make_errored!("Installation timed out. Check pod logs for #{install_command.pod_name} for more details.") + app.make_errored!("Operation timed out. Check pod logs for #{pod_name} for more details.") end else ClusterWaitForAppInstallationWorker.perform_in( @@ -42,20 +47,24 @@ module Clusters end end + def pod_name + install_command.pod_name + end + def timeouted? Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT end def remove_installation_pod - helm_api.delete_pod!(install_command.pod_name) + helm_api.delete_pod!(pod_name) end def installation_phase - helm_api.status(install_command.pod_name) + helm_api.status(pod_name) end def installation_errors - helm_api.log(install_command.pod_name) + helm_api.log(pod_name) end end end diff --git a/app/services/clusters/applications/schedule_installation_service.rb b/app/services/clusters/applications/schedule_installation_service.rb index d75ba70c27e..15c93f1e79b 100644 --- a/app/services/clusters/applications/schedule_installation_service.rb +++ b/app/services/clusters/applications/schedule_installation_service.rb @@ -10,6 +10,18 @@ module Clusters end def execute + application.updateable? ? schedule_upgrade : schedule_install + end + + private + + def schedule_upgrade + application.make_scheduled! + + ClusterUpgradeAppWorker.perform_async(application.name, application.id) + end + + def schedule_install application.make_scheduled! ClusterInstallAppWorker.perform_async(application.name, application.id) diff --git a/app/services/clusters/applications/upgrade_service.rb b/app/services/clusters/applications/upgrade_service.rb new file mode 100644 index 00000000000..a0ece1d2635 --- /dev/null +++ b/app/services/clusters/applications/upgrade_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class UpgradeService < BaseHelmService + def execute + return unless app.scheduled? + + begin + app.make_updating! + + # install_command works with upgrades too + # as it basically does `helm upgrade --install` + helm_api.update(install_command) + + ClusterWaitForAppInstallationWorker.perform_in( + ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) + rescue Kubeclient::HttpError => e + log_error(e) + app.make_update_errored!("Kubernetes error: #{e.error_code}") + rescue StandardError => e + log_error(e) + app.make_update_errored!("Can't start upgrade process.") + end + end + end + end +end diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 85c123c2704..410411b1294 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -23,6 +23,7 @@ - cronjob:prune_web_hook_logs - gcp_cluster:cluster_install_app +- gcp_cluster:cluster_upgrade_app - gcp_cluster:cluster_provision - gcp_cluster:cluster_wait_for_app_installation - gcp_cluster:wait_for_cluster_creation diff --git a/app/workers/cluster_upgrade_app_worker.rb b/app/workers/cluster_upgrade_app_worker.rb new file mode 100644 index 00000000000..d1a538859b4 --- /dev/null +++ b/app/workers/cluster_upgrade_app_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ClusterUpgradeAppWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + def perform(app_name, app_id) + find_application(app_name, app_id) do |app| + Clusters::Applications::UpgradeService.new(app).execute + end + end +end diff --git a/changelogs/unreleased/use_upgrade_install_for_helm_apps.yml b/changelogs/unreleased/use_upgrade_install_for_helm_apps.yml new file mode 100644 index 00000000000..b41c3cfa1ab --- /dev/null +++ b/changelogs/unreleased/use_upgrade_install_for_helm_apps.yml @@ -0,0 +1,5 @@ +--- +title: Added ability to upgrade cluster applications +merge_request: 24789 +author: +type: added diff --git a/lib/gitlab/kubernetes/helm/api.rb b/lib/gitlab/kubernetes/helm/api.rb index b9903e37f40..7dfd9ed4f35 100644 --- a/lib/gitlab/kubernetes/helm/api.rb +++ b/lib/gitlab/kubernetes/helm/api.rb @@ -20,14 +20,7 @@ module Gitlab kubeclient.create_pod(command.pod_resource) end - def update(command) - namespace.ensure_exists! - - update_config_map(command) - - delete_pod!(command.pod_name) - kubeclient.create_pod(command.pod_resource) - end + alias_method :update, :install ## # Returns Pod phase @@ -62,6 +55,8 @@ module Gitlab def create_config_map(command) command.config_map_resource.tap do |config_map_resource| + break unless config_map_resource + if config_map_exists?(config_map_resource) kubeclient.update_config_map(config_map_resource) else diff --git a/lib/gitlab/kubernetes/helm/install_command.rb b/lib/gitlab/kubernetes/helm/install_command.rb index a1ab5e048ac..f931248b747 100644 --- a/lib/gitlab/kubernetes/helm/install_command.rb +++ b/lib/gitlab/kubernetes/helm/install_command.rb @@ -42,8 +42,17 @@ module Gitlab 'helm repo update' if repository end + # Uses `helm upgrade --install` which means we can use this for both + # installation and uprade of applications def install_command - command = ['helm', 'install', chart] + install_command_flags + command = ['helm', 'upgrade', name, chart] + + install_flag + + reset_values_flag + + optional_tls_flags + + optional_version_flag + + rbac_create_flag + + namespace_flag + + value_flag command.shelljoin end @@ -56,17 +65,20 @@ module Gitlab postinstall.join("\n") if postinstall end - def install_command_flags - name_flag = ['--name', name] - namespace_flag = ['--namespace', Gitlab::Kubernetes::Helm::NAMESPACE] - value_flag = ['-f', "/data/helm/#{name}/config/values.yaml"] + def install_flag + ['--install'] + end - name_flag + - optional_tls_flags + - optional_version_flag + - rbac_create_flag + - namespace_flag + - value_flag + def reset_values_flag + ['--reset-values'] + end + + def value_flag + ['-f', "/data/helm/#{name}/config/values.yaml"] + end + + def namespace_flag + ['--namespace', Gitlab::Kubernetes::Helm::NAMESPACE] end def rbac_create_flag diff --git a/lib/gitlab/kubernetes/helm/upgrade_command.rb b/lib/gitlab/kubernetes/helm/upgrade_command.rb deleted file mode 100644 index 9daffc138b5..00000000000 --- a/lib/gitlab/kubernetes/helm/upgrade_command.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Kubernetes - module Helm - class UpgradeCommand - include BaseCommand - include ClientCommand - - attr_reader :name, :chart, :version, :repository, :files - - def initialize(name, chart:, files:, rbac:, version: nil, repository: nil) - @name = name - @chart = chart - @rbac = rbac - @version = version - @files = files - @repository = repository - end - - def generate_script - super + [ - init_command, - wait_for_tiller_command, - repository_command, - script_command - ].compact.join("\n") - end - - def rbac? - @rbac - end - - def pod_name - "upgrade-#{name}" - end - - private - - def script_command - upgrade_flags = "#{optional_version_flag}#{optional_tls_flags}" \ - " --reset-values" \ - " --install" \ - " --namespace #{::Gitlab::Kubernetes::Helm::NAMESPACE}" \ - " -f /data/helm/#{name}/config/values.yaml" - - "helm upgrade #{name} #{chart}#{upgrade_flags}" - end - - def optional_version_flag - " --version #{version}" if version - end - - def optional_tls_flags - return unless files.key?(:'ca.pem') - - " --tls" \ - " --tls-ca-cert #{files_dir}/ca.pem" \ - " --tls-cert #{files_dir}/cert.pem" \ - " --tls-key #{files_dir}/key.pem" - end - end - end - end -end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 37ed73a180b..a3f78968a55 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1554,6 +1554,9 @@ msgstr "" msgid "ClusterIntegration|%{boldNotice} This will add some extra resources like a load balancer, which may incur additional costs depending on the hosting provider your Kubernetes cluster is installed on. If you are using Google Kubernetes Engine, you can %{pricingLink}." msgstr "" +msgid "ClusterIntegration|%{title} upgraded successfully." +msgstr "" + msgid "ClusterIntegration|API URL" msgstr "" @@ -1878,6 +1881,9 @@ msgstr "" msgid "ClusterIntegration|Request to begin installing failed" msgstr "" +msgid "ClusterIntegration|Retry upgrade" +msgstr "" + msgid "ClusterIntegration|Save changes" msgstr "" @@ -1920,6 +1926,9 @@ msgstr "" msgid "ClusterIntegration|Something went wrong on our end." msgstr "" +msgid "ClusterIntegration|Something went wrong when upgrading %{title}. Please check the logs and try again." +msgstr "" + msgid "ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine" msgstr "" @@ -1944,6 +1953,18 @@ msgstr "" msgid "ClusterIntegration|Token" msgstr "" +msgid "ClusterIntegration|Upgrade" +msgstr "" + +msgid "ClusterIntegration|Upgrade failed" +msgstr "" + +msgid "ClusterIntegration|Upgraded" +msgstr "" + +msgid "ClusterIntegration|Upgrading" +msgstr "" + msgid "ClusterIntegration|Validating project billing status" msgstr "" diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json index 138a6c5ed6b..5ebc09a96dc 100644 --- a/spec/fixtures/api/schemas/cluster_status.json +++ b/spec/fixtures/api/schemas/cluster_status.json @@ -34,7 +34,8 @@ "status_reason": { "type": ["string", "null"] }, "external_ip": { "type": ["string", "null"] }, "hostname": { "type": ["string", "null"] }, - "email": { "type": ["string", "null"] } + "email": { "type": ["string", "null"] }, + "update_available": { "type": ["boolean", "null"] } }, "required" : [ "name", "status" ] } diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js index d1f4a1cebb4..8cb9713964e 100644 --- a/spec/javascripts/clusters/components/application_row_spec.js +++ b/spec/javascripts/clusters/components/application_row_spec.js @@ -208,6 +208,144 @@ describe('Application Row', () => { }); }); + describe('Upgrade button', () => { + it('has indeterminate state on page load', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: null, + }); + const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button'); + + expect(upgradeBtn).toBe(null); + }); + + it('has enabled "Upgrade" when "upgradeAvailable" is true', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + upgradeAvailable: true, + }); + const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button'); + + expect(upgradeBtn).not.toBe(null); + expect(upgradeBtn.innerHTML).toContain('Upgrade'); + }); + + it('has enabled "Retry upgrade" when APPLICATION_STATUS.UPDATE_ERRORED', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_STATUS.UPDATE_ERRORED, + }); + const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button'); + + expect(upgradeBtn).not.toBe(null); + expect(vm.upgradeFailed).toBe(true); + expect(upgradeBtn.innerHTML).toContain('Retry upgrade'); + }); + + it('has disabled "Retry upgrade" when APPLICATION_STATUS.UPDATING', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_STATUS.UPDATING, + }); + const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button'); + + expect(upgradeBtn).not.toBe(null); + expect(vm.isUpgrading).toBe(true); + expect(upgradeBtn.innerHTML).toContain('Upgrading'); + }); + + it('clicking upgrade button emits event', () => { + spyOn(eventHub, '$emit'); + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_STATUS.UPDATE_ERRORED, + }); + const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button'); + + upgradeBtn.click(); + + expect(eventHub.$emit).toHaveBeenCalledWith('upgradeApplication', { + id: DEFAULT_APPLICATION_STATE.id, + params: {}, + }); + }); + + it('clicking disabled upgrade button emits nothing', () => { + spyOn(eventHub, '$emit'); + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_STATUS.UPDATING, + }); + const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button'); + + upgradeBtn.click(); + + expect(eventHub.$emit).not.toHaveBeenCalled(); + }); + + it('displays an error message if application upgrade failed', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + title: 'GitLab Runner', + status: APPLICATION_STATUS.UPDATE_ERRORED, + }); + const failureMessage = vm.$el.querySelector( + '.js-cluster-application-upgrade-failure-message', + ); + + expect(failureMessage).not.toBe(null); + expect(failureMessage.innerHTML).toContain( + 'Something went wrong when upgrading GitLab Runner. Please check the logs and try again.', + ); + }); + }); + + describe('Version', () => { + it('displays a version number if application has been upgraded', () => { + const version = '0.1.45'; + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_STATUS.UPDATED, + version, + }); + const upgradeDetails = vm.$el.querySelector('.js-cluster-application-upgrade-details'); + const versionEl = vm.$el.querySelector('.js-cluster-application-upgrade-version'); + + expect(upgradeDetails.innerHTML).toContain('Upgraded'); + expect(versionEl).not.toBe(null); + expect(versionEl.innerHTML).toContain(version); + }); + + it('contains a link to the chart repo if application has been upgraded', () => { + const version = '0.1.45'; + const chartRepo = 'https://gitlab.com/charts/gitlab-runner'; + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_STATUS.UPDATED, + chartRepo, + version, + }); + const versionEl = vm.$el.querySelector('.js-cluster-application-upgrade-version'); + + expect(versionEl.href).toEqual(chartRepo); + expect(versionEl.target).toEqual('_blank'); + }); + + it('does not display a version number if application upgrade failed', () => { + const version = '0.1.45'; + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_STATUS.UPDATE_ERRORED, + version, + }); + const upgradeDetails = vm.$el.querySelector('.js-cluster-application-upgrade-details'); + const versionEl = vm.$el.querySelector('.js-cluster-application-upgrade-version'); + + expect(upgradeDetails.innerHTML).toContain('failed'); + expect(versionEl).toBe(null); + }); + }); + describe('Error block', () => { it('does not show error block when there is no error', () => { vm = mountComponent(ApplicationRow, { diff --git a/spec/javascripts/clusters/stores/clusters_store_spec.js b/spec/javascripts/clusters/stores/clusters_store_spec.js index dfce2656e4c..37a4d6614f6 100644 --- a/spec/javascripts/clusters/stores/clusters_store_spec.js +++ b/spec/javascripts/clusters/stores/clusters_store_spec.js @@ -85,6 +85,9 @@ describe('Clusters Store', () => { statusReason: mockResponseData.applications[2].status_reason, requestStatus: null, requestReason: null, + version: mockResponseData.applications[2].version, + upgradeAvailable: mockResponseData.applications[2].update_available, + chartRepo: 'https://gitlab.com/charts/gitlab-runner', }, prometheus: { title: 'Prometheus', diff --git a/spec/lib/gitlab/kubernetes/helm/api_spec.rb b/spec/lib/gitlab/kubernetes/helm/api_spec.rb index c7f92cbb143..8433d40b2ea 100644 --- a/spec/lib/gitlab/kubernetes/helm/api_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/api_spec.rb @@ -171,51 +171,6 @@ describe Gitlab::Kubernetes::Helm::Api do end end - describe '#update' do - let(:rbac) { false } - - let(:command) do - Gitlab::Kubernetes::Helm::UpgradeCommand.new( - application_name, - chart: 'chart-name', - files: files, - rbac: rbac - ) - end - - before do - allow(namespace).to receive(:ensure_exists!).once - - allow(client).to receive(:update_config_map).and_return(nil) - allow(client).to receive(:create_pod).and_return(nil) - allow(client).to receive(:delete_pod).and_return(nil) - end - - it 'ensures the namespace exists before creating the pod' do - expect(namespace).to receive(:ensure_exists!).once.ordered - expect(client).to receive(:create_pod).once.ordered - - subject.update(command) - end - - it 'removes an existing pod before updating' do - expect(client).to receive(:delete_pod).with('upgrade-app-name', 'gitlab-managed-apps').once.ordered - expect(client).to receive(:create_pod).once.ordered - - subject.update(command) - end - - it 'updates the config map on kubeclient when one exists' do - resource = Gitlab::Kubernetes::ConfigMap.new( - application_name, files - ).generate - - expect(client).to receive(:update_config_map).with(resource).once - - subject.update(command) - end - end - describe '#status' do let(:phase) { Gitlab::Kubernetes::Pod::RUNNING } let(:pod) { Kubeclient::Resource.new(status: { phase: phase }) } # partial representation diff --git a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb index 82ed4d47857..db76d5d207e 100644 --- a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb @@ -21,6 +21,15 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do ) end + let(:tls_flags) do + <<~EOS.squish + --tls + --tls-ca-cert /data/helm/app-name/config/ca.pem + --tls-cert /data/helm/app-name/config/cert.pem + --tls-key /data/helm/app-name/config/key.pem + EOS + end + subject { install_command } it_behaves_like 'helm commands' do @@ -36,12 +45,10 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do let(:helm_install_comand) do <<~EOS.squish - helm install chart-name - --name app-name - --tls - --tls-ca-cert /data/helm/app-name/config/ca.pem - --tls-cert /data/helm/app-name/config/cert.pem - --tls-key /data/helm/app-name/config/key.pem + helm upgrade app-name chart-name + --install + --reset-values + #{tls_flags} --version 1.2.3 --set rbac.create\\=false,rbac.enabled\\=false --namespace gitlab-managed-apps @@ -66,12 +73,10 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do let(:helm_install_command) do <<~EOS.squish - helm install chart-name - --name app-name - --tls - --tls-ca-cert /data/helm/app-name/config/ca.pem - --tls-cert /data/helm/app-name/config/cert.pem - --tls-key /data/helm/app-name/config/key.pem + helm upgrade app-name chart-name + --install + --reset-values + #{tls_flags} --version 1.2.3 --set rbac.create\\=true,rbac.enabled\\=true --namespace gitlab-managed-apps @@ -95,12 +100,10 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do let(:helm_install_command) do <<~EOS.squish - helm install chart-name - --name app-name - --tls - --tls-ca-cert /data/helm/app-name/config/ca.pem - --tls-cert /data/helm/app-name/config/cert.pem - --tls-key /data/helm/app-name/config/key.pem + helm upgrade app-name chart-name + --install + --reset-values + #{tls_flags} --version 1.2.3 --set rbac.create\\=false,rbac.enabled\\=false --namespace gitlab-managed-apps @@ -120,15 +123,22 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do for i in $(seq 1 30); do helm version && break; sleep 1s; echo "Retrying ($i)..."; done helm repo add app-name https://repository.example.com helm repo update + /bin/date + /bin/true #{helm_install_command} EOS end let(:helm_install_command) do - <<~EOS.strip - /bin/date - /bin/true - helm install chart-name --name app-name --tls --tls-ca-cert /data/helm/app-name/config/ca.pem --tls-cert /data/helm/app-name/config/cert.pem --tls-key /data/helm/app-name/config/key.pem --version 1.2.3 --set rbac.create\\=false,rbac.enabled\\=false --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml + <<~EOS.squish + helm upgrade app-name chart-name + --install + --reset-values + #{tls_flags} + --version 1.2.3 + --set rbac.create\\=false,rbac.enabled\\=false + --namespace gitlab-managed-apps + -f /data/helm/app-name/config/values.yaml EOS end end @@ -145,14 +155,21 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do helm repo add app-name https://repository.example.com helm repo update #{helm_install_command} + /bin/date + /bin/false EOS end let(:helm_install_command) do - <<~EOS.strip - helm install chart-name --name app-name --tls --tls-ca-cert /data/helm/app-name/config/ca.pem --tls-cert /data/helm/app-name/config/cert.pem --tls-key /data/helm/app-name/config/key.pem --version 1.2.3 --set rbac.create\\=false,rbac.enabled\\=false --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml - /bin/date - /bin/false + <<~EOS.squish + helm upgrade app-name chart-name + --install + --reset-values + #{tls_flags} + --version 1.2.3 + --set rbac.create\\=false,rbac.enabled\\=false + --namespace gitlab-managed-apps + -f /data/helm/app-name/config/values.yaml EOS end end @@ -174,8 +191,9 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do let(:helm_install_command) do <<~EOS.squish - helm install chart-name - --name app-name + helm upgrade app-name chart-name + --install + --reset-values --version 1.2.3 --set rbac.create\\=false,rbac.enabled\\=false --namespace gitlab-managed-apps @@ -201,12 +219,10 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do let(:helm_install_command) do <<~EOS.squish - helm install chart-name - --name app-name - --tls - --tls-ca-cert /data/helm/app-name/config/ca.pem - --tls-cert /data/helm/app-name/config/cert.pem - --tls-key /data/helm/app-name/config/key.pem + helm upgrade app-name chart-name + --install + --reset-values + #{tls_flags} --set rbac.create\\=false,rbac.enabled\\=false --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml diff --git a/spec/lib/gitlab/kubernetes/helm/upgrade_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/upgrade_command_spec.rb deleted file mode 100644 index 9b201dae417..00000000000 --- a/spec/lib/gitlab/kubernetes/helm/upgrade_command_spec.rb +++ /dev/null @@ -1,140 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Gitlab::Kubernetes::Helm::UpgradeCommand do - let(:application) { build(:clusters_applications_prometheus) } - let(:files) { { 'ca.pem': 'some file content' } } - let(:namespace) { ::Gitlab::Kubernetes::Helm::NAMESPACE } - let(:rbac) { false } - let(:upgrade_command) do - described_class.new( - application.name, - chart: application.chart, - files: files, - rbac: rbac - ) - end - - subject { upgrade_command } - - it_behaves_like 'helm commands' do - let(:commands) do - <<~EOS - helm init --upgrade - for i in $(seq 1 30); do helm version && break; sleep 1s; echo "Retrying ($i)..."; done - helm upgrade #{application.name} #{application.chart} --tls --tls-ca-cert /data/helm/#{application.name}/config/ca.pem --tls-cert /data/helm/#{application.name}/config/cert.pem --tls-key /data/helm/#{application.name}/config/key.pem --reset-values --install --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml - EOS - end - end - - context 'rbac is true' do - let(:rbac) { true } - - it_behaves_like 'helm commands' do - let(:commands) do - <<~EOS - helm init --upgrade - for i in $(seq 1 30); do helm version && break; sleep 1s; echo "Retrying ($i)..."; done - helm upgrade #{application.name} #{application.chart} --tls --tls-ca-cert /data/helm/#{application.name}/config/ca.pem --tls-cert /data/helm/#{application.name}/config/cert.pem --tls-key /data/helm/#{application.name}/config/key.pem --reset-values --install --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml - EOS - end - end - end - - context 'with an application with a repository' do - let(:ci_runner) { create(:ci_runner) } - let(:application) { build(:clusters_applications_runner, runner: ci_runner) } - let(:upgrade_command) do - described_class.new( - application.name, - chart: application.chart, - files: files, - rbac: rbac, - repository: application.repository - ) - end - - it_behaves_like 'helm commands' do - let(:commands) do - <<~EOS - helm init --upgrade - for i in $(seq 1 30); do helm version && break; sleep 1s; echo "Retrying ($i)..."; done - helm repo add #{application.name} #{application.repository} - helm upgrade #{application.name} #{application.chart} --tls --tls-ca-cert /data/helm/#{application.name}/config/ca.pem --tls-cert /data/helm/#{application.name}/config/cert.pem --tls-key /data/helm/#{application.name}/config/key.pem --reset-values --install --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml - EOS - end - end - end - - context 'when there is no ca.pem file' do - let(:files) { { 'file.txt': 'some content' } } - - it_behaves_like 'helm commands' do - let(:commands) do - <<~EOS - helm init --upgrade - for i in $(seq 1 30); do helm version && break; sleep 1s; echo "Retrying ($i)..."; done - helm upgrade #{application.name} #{application.chart} --reset-values --install --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml - EOS - end - end - end - - describe '#pod_resource' do - subject { upgrade_command.pod_resource } - - context 'rbac is enabled' do - let(:rbac) { true } - - it 'generates a pod that uses the tiller serviceAccountName' do - expect(subject.spec.serviceAccountName).to eq('tiller') - end - end - - context 'rbac is not enabled' do - let(:rbac) { false } - - it 'generates a pod that uses the default serviceAccountName' do - expect(subject.spec.serviceAcccountName).to be_nil - end - end - end - - describe '#config_map_resource' do - let(:metadata) do - { - name: "values-content-configuration-#{application.name}", - namespace: namespace, - labels: { name: "values-content-configuration-#{application.name}" } - } - end - let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: files) } - - it 'returns a KubeClient resource with config map content for the application' do - expect(subject.config_map_resource).to eq(resource) - end - end - - describe '#rbac?' do - subject { upgrade_command.rbac? } - - context 'rbac is enabled' do - let(:rbac) { true } - - it { is_expected.to be_truthy } - end - - context 'rbac is not enabled' do - let(:rbac) { false } - - it { is_expected.to be_falsey } - end - end - - describe '#pod_name' do - it 'returns the pod name' do - expect(subject.pod_name).to eq("upgrade-#{application.name}") - end - end -end diff --git a/spec/models/clusters/applications/cert_manager_spec.rb b/spec/models/clusters/applications/cert_manager_spec.rb index 79a06c35459..cf5cbf8ec5c 100644 --- a/spec/models/clusters/applications/cert_manager_spec.rb +++ b/spec/models/clusters/applications/cert_manager_spec.rb @@ -5,6 +5,7 @@ describe Clusters::Applications::CertManager do include_examples 'cluster application core specs', :clusters_applications_cert_managers include_examples 'cluster application status specs', :clusters_applications_cert_managers + include_examples 'cluster application version specs', :clusters_applications_cert_managers include_examples 'cluster application initial status specs' describe '#install_command' do diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb index 6d48131d1cc..03ca18c6943 100644 --- a/spec/models/clusters/applications/ingress_spec.rb +++ b/spec/models/clusters/applications/ingress_spec.rb @@ -7,6 +7,7 @@ describe Clusters::Applications::Ingress do include_examples 'cluster application core specs', :clusters_applications_ingress include_examples 'cluster application status specs', :clusters_applications_ingress + include_examples 'cluster application version specs', :clusters_applications_ingress include_examples 'cluster application helm specs', :clusters_applications_ingress include_examples 'cluster application initial status specs' diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb index b73a243f6e0..2c22c24c498 100644 --- a/spec/models/clusters/applications/jupyter_spec.rb +++ b/spec/models/clusters/applications/jupyter_spec.rb @@ -3,6 +3,7 @@ require 'rails_helper' describe Clusters::Applications::Jupyter do include_examples 'cluster application core specs', :clusters_applications_jupyter include_examples 'cluster application status specs', :clusters_applications_jupyter + include_examples 'cluster application version specs', :clusters_applications_jupyter include_examples 'cluster application helm specs', :clusters_applications_jupyter it { is_expected.to belong_to(:oauth_application) } diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb index 5519615d52d..cd29e0d4f53 100644 --- a/spec/models/clusters/applications/knative_spec.rb +++ b/spec/models/clusters/applications/knative_spec.rb @@ -9,6 +9,7 @@ describe Clusters::Applications::Knative do include_examples 'cluster application core specs', :clusters_applications_knative include_examples 'cluster application status specs', :clusters_applications_knative include_examples 'cluster application helm specs', :clusters_applications_knative + include_examples 'cluster application version specs', :clusters_applications_knative include_examples 'cluster application initial status specs' before do diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb index 073fbded8ac..caf59b0fc31 100644 --- a/spec/models/clusters/applications/prometheus_spec.rb +++ b/spec/models/clusters/applications/prometheus_spec.rb @@ -5,6 +5,7 @@ describe Clusters::Applications::Prometheus do include_examples 'cluster application core specs', :clusters_applications_prometheus include_examples 'cluster application status specs', :clusters_applications_prometheus + include_examples 'cluster application version specs', :clusters_applications_prometheus include_examples 'cluster application helm specs', :clusters_applications_prometheus include_examples 'cluster application initial status specs' @@ -206,8 +207,8 @@ describe Clusters::Applications::Prometheus do let(:prometheus) { build(:clusters_applications_prometheus) } let(:values) { prometheus.values } - it 'returns an instance of Gitlab::Kubernetes::Helm::GetCommand' do - expect(prometheus.upgrade_command(values)).to be_an_instance_of(::Gitlab::Kubernetes::Helm::UpgradeCommand) + it 'returns an instance of Gitlab::Kubernetes::Helm::InstallCommand' do + expect(prometheus.upgrade_command(values)).to be_an_instance_of(::Gitlab::Kubernetes::Helm::InstallCommand) end it 'should be initialized with 3 arguments' do diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb index 96b7b02dbaf..38758ff97bc 100644 --- a/spec/models/clusters/applications/runner_spec.rb +++ b/spec/models/clusters/applications/runner_spec.rb @@ -5,6 +5,7 @@ describe Clusters::Applications::Runner do include_examples 'cluster application core specs', :clusters_applications_runner include_examples 'cluster application status specs', :clusters_applications_runner + include_examples 'cluster application version specs', :clusters_applications_runner include_examples 'cluster application helm specs', :clusters_applications_runner include_examples 'cluster application initial status specs' diff --git a/spec/serializers/cluster_application_entity_spec.rb b/spec/serializers/cluster_application_entity_spec.rb index 88d16a5b360..7e151c3744e 100644 --- a/spec/serializers/cluster_application_entity_spec.rb +++ b/spec/serializers/cluster_application_entity_spec.rb @@ -21,6 +21,14 @@ describe ClusterApplicationEntity do expect(subject[:status_reason]).to be_nil end + context 'non-helm application' do + let(:application) { build(:clusters_applications_runner, version: '0.0.0') } + + it 'has update_available' do + expect(subject[:update_available]).to be_truthy + end + end + context 'when application is errored' do let(:application) { build(:clusters_applications_helm, :errored) } diff --git a/spec/services/clusters/applications/check_installation_progress_service_spec.rb b/spec/services/clusters/applications/check_installation_progress_service_spec.rb index 45b8ce94815..19446ce1cf8 100644 --- a/spec/services/clusters/applications/check_installation_progress_service_spec.rb +++ b/spec/services/clusters/applications/check_installation_progress_service_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Clusters::Applications::CheckInstallationProgressService do +describe Clusters::Applications::CheckInstallationProgressService, '#execute' do RESCHEDULE_PHASES = Gitlab::Kubernetes::Pod::PHASES - [Gitlab::Kubernetes::Pod::SUCCEEDED, Gitlab::Kubernetes::Pod::FAILED].freeze let(:application) { create(:clusters_applications_helm, :installing) } @@ -21,24 +21,39 @@ describe Clusters::Applications::CheckInstallationProgressService do expect(ClusterWaitForAppInstallationWorker).to receive(:perform_in).once expect(service).not_to receive(:remove_installation_pod) - service.execute + expect do + service.execute + + application.reload + end.not_to change(application, :status) - expect(application).to be_installing expect(application.status_reason).to be_nil end end + end + end - context 'when timeouted' do - let(:application) { create(:clusters_applications_helm, :timeouted) } + shared_examples 'error logging' do + context 'when installation raises a Kubeclient::HttpError' do + let(:cluster) { create(:cluster, :provided_by_user, :project) } - it 'make the application errored' do - expect(ClusterWaitForAppInstallationWorker).not_to receive(:perform_in) + before do + application.update!(cluster: cluster) - service.execute + expect(service).to receive(:installation_phase).and_raise(Kubeclient::HttpError.new(401, 'Unauthorized', nil)) + end - expect(application).to be_errored - expect(application.status_reason).to eq("Installation timed out. Check pod logs for install-helm for more details.") - end + it 'shows the response code from the error' do + service.execute + + expect(application).to be_errored.or(be_update_errored) + expect(application.status_reason).to eq('Kubernetes error: 401') + end + + it 'should log error' do + expect(service.send(:logger)).to receive(:error) + + service.execute end end end @@ -48,10 +63,15 @@ describe Clusters::Applications::CheckInstallationProgressService do allow(service).to receive(:remove_installation_pod).and_return(nil) end - describe '#execute' do + context 'when application is updating' do + let(:application) { create(:clusters_applications_helm, :updating) } + + include_examples 'error logging' + + RESCHEDULE_PHASES.each { |phase| it_behaves_like 'a not yet terminated installation', phase } + context 'when installation POD succeeded' do let(:phase) { Gitlab::Kubernetes::Pod::SUCCEEDED } - before do expect(service).to receive(:installation_phase).once.and_return(phase) end @@ -67,7 +87,7 @@ describe Clusters::Applications::CheckInstallationProgressService do service.execute - expect(application).to be_installed + expect(application).to be_updated expect(application.status_reason).to be_nil end end @@ -83,33 +103,86 @@ describe Clusters::Applications::CheckInstallationProgressService do it 'make the application errored' do service.execute - expect(application).to be_errored - expect(application.status_reason).to eq("Installation failed. Check pod logs for install-helm for more details.") + expect(application).to be_update_errored + expect(application.status_reason).to eq('Operation failed. Check pod logs for install-helm for more details.') end end - RESCHEDULE_PHASES.each { |phase| it_behaves_like 'a not yet terminated installation', phase } + context 'when timed out' do + let(:application) { create(:clusters_applications_helm, :timeouted, :updating) } - context 'when installation raises a Kubeclient::HttpError' do - let(:cluster) { create(:cluster, :provided_by_user, :project) } + before do + expect(service).to receive(:installation_phase).once.and_return(phase) + end + + it 'make the application errored' do + expect(ClusterWaitForAppInstallationWorker).not_to receive(:perform_in) + + service.execute + + expect(application).to be_update_errored + expect(application.status_reason).to eq('Operation timed out. Check pod logs for install-helm for more details.') + end + end + end + + context 'when application is installing' do + include_examples 'error logging' + + RESCHEDULE_PHASES.each { |phase| it_behaves_like 'a not yet terminated installation', phase } + context 'when installation POD succeeded' do + let(:phase) { Gitlab::Kubernetes::Pod::SUCCEEDED } before do - application.update!(cluster: cluster) + expect(service).to receive(:installation_phase).once.and_return(phase) + end - expect(service).to receive(:installation_phase).and_raise(Kubeclient::HttpError.new(401, 'Unauthorized', nil)) + it 'removes the installation POD' do + expect(service).to receive(:remove_installation_pod).once + + service.execute end - it 'shows the response code from the error' do + it 'make the application installed' do + expect(ClusterWaitForAppInstallationWorker).not_to receive(:perform_in) + + service.execute + + expect(application).to be_installed + expect(application.status_reason).to be_nil + end + end + + context 'when installation POD failed' do + let(:phase) { Gitlab::Kubernetes::Pod::FAILED } + let(:errors) { 'test installation failed' } + + before do + expect(service).to receive(:installation_phase).once.and_return(phase) + end + + it 'make the application errored' do service.execute expect(application).to be_errored - expect(application.status_reason).to eq('Kubernetes error: 401') + expect(application.status_reason).to eq('Operation failed. Check pod logs for install-helm for more details.') + end + end + + context 'when timed out' do + let(:application) { create(:clusters_applications_helm, :timeouted) } + + before do + expect(service).to receive(:installation_phase).once.and_return(phase) end - it 'should log error' do - expect(service.send(:logger)).to receive(:error) + it 'make the application errored' do + expect(ClusterWaitForAppInstallationWorker).not_to receive(:perform_in) service.execute + + expect(application).to be_errored + expect(application.status_reason).to eq('Operation timed out. Check pod logs for install-helm for more details.') end end end diff --git a/spec/services/clusters/applications/create_service_spec.rb b/spec/services/clusters/applications/create_service_spec.rb index 1a2ca23748a..3f621ed5944 100644 --- a/spec/services/clusters/applications/create_service_spec.rb +++ b/spec/services/clusters/applications/create_service_spec.rb @@ -13,6 +13,7 @@ describe Clusters::Applications::CreateService do describe '#execute' do before do allow(ClusterInstallAppWorker).to receive(:perform_async) + allow(ClusterUpgradeAppWorker).to receive(:perform_async) end subject { service.execute(test_request) } @@ -31,6 +32,22 @@ describe Clusters::Applications::CreateService do subject end + context 'application already installed' do + let!(:application) { create(:clusters_applications_helm, :installed, cluster: cluster) } + + it 'does not create a new application' do + expect do + subject + end.not_to change(Clusters::Applications::Helm, :count) + end + + it 'schedules an upgrade for the application' do + expect(Clusters::Applications::ScheduleInstallationService).to receive(:new).with(application).and_call_original + + subject + end + end + context 'cert manager application' do let(:params) do { diff --git a/spec/services/clusters/applications/schedule_installation_service_spec.rb b/spec/services/clusters/applications/schedule_installation_service_spec.rb index 21797edd533..8380932dfaa 100644 --- a/spec/services/clusters/applications/schedule_installation_service_spec.rb +++ b/spec/services/clusters/applications/schedule_installation_service_spec.rb @@ -49,5 +49,29 @@ describe Clusters::Applications::ScheduleInstallationService do it_behaves_like 'a failing service' end + + context 'when application is installed' do + let(:application) { create(:clusters_applications_helm, :installed) } + + it 'schedules an upgrade via worker' do + expect(ClusterUpgradeAppWorker).to receive(:perform_async).with(application.name, application.id).once + + service.execute + + expect(application).to be_scheduled + end + end + + context 'when application is updated' do + let(:application) { create(:clusters_applications_helm, :updated) } + + it 'schedules an upgrade via worker' do + expect(ClusterUpgradeAppWorker).to receive(:perform_async).with(application.name, application.id).once + + service.execute + + expect(application).to be_scheduled + end + end end end diff --git a/spec/services/clusters/applications/upgrade_service_spec.rb b/spec/services/clusters/applications/upgrade_service_spec.rb new file mode 100644 index 00000000000..1822fc38dbd --- /dev/null +++ b/spec/services/clusters/applications/upgrade_service_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Applications::UpgradeService do + describe '#execute' do + let(:application) { create(:clusters_applications_helm, :scheduled) } + let!(:install_command) { application.install_command } + let(:service) { described_class.new(application) } + let(:helm_client) { instance_double(Gitlab::Kubernetes::Helm::Api) } + + before do + allow(service).to receive(:install_command).and_return(install_command) + allow(service).to receive(:helm_api).and_return(helm_client) + end + + context 'when there are no errors' do + before do + expect(helm_client).to receive(:update).with(install_command) + allow(ClusterWaitForAppInstallationWorker).to receive(:perform_in).and_return(nil) + end + + it 'make the application updating' do + expect(application.cluster).not_to be_nil + service.execute + + expect(application).to be_updating + end + + it 'schedule async installation status check' do + expect(ClusterWaitForAppInstallationWorker).to receive(:perform_in).once + + service.execute + end + end + + context 'when kubernetes cluster communication fails' do + let(:error) { Kubeclient::HttpError.new(500, 'system failure', nil) } + + before do + expect(helm_client).to receive(:update).with(install_command).and_raise(error) + end + + it 'make the application errored' do + service.execute + + expect(application).to be_update_errored + expect(application.status_reason).to match('Kubernetes error: 500') + end + + it 'logs errors' do + expect(service.send(:logger)).to receive(:error).with( + { + exception: 'Kubeclient::HttpError', + message: 'system failure', + service: 'Clusters::Applications::UpgradeService', + app_id: application.id, + project_ids: application.cluster.project_ids, + group_ids: [], + error_code: 500 + } + ) + + expect(Gitlab::Sentry).to receive(:track_acceptable_exception).with( + error, + extra: { + exception: 'Kubeclient::HttpError', + message: 'system failure', + service: 'Clusters::Applications::UpgradeService', + app_id: application.id, + project_ids: application.cluster.project_ids, + group_ids: [], + error_code: 500 + } + ) + + service.execute + end + end + + context 'a non kubernetes error happens' do + let(:application) { create(:clusters_applications_helm, :scheduled) } + let(:error) { StandardError.new('something bad happened') } + + before do + expect(application).to receive(:make_updating!).once.and_raise(error) + end + + it 'make the application errored' do + expect(helm_client).not_to receive(:update) + + service.execute + + expect(application).to be_update_errored + expect(application.status_reason).to eq("Can't start upgrade process.") + end + + it 'logs errors' do + expect(service.send(:logger)).to receive(:error).with( + { + exception: 'StandardError', + error_code: nil, + message: 'something bad happened', + service: 'Clusters::Applications::UpgradeService', + app_id: application.id, + project_ids: application.cluster.projects.pluck(:id), + group_ids: [] + } + ) + + expect(Gitlab::Sentry).to receive(:track_acceptable_exception).with( + error, + extra: { + exception: 'StandardError', + error_code: nil, + message: 'something bad happened', + service: 'Clusters::Applications::UpgradeService', + app_id: application.id, + project_ids: application.cluster.projects.pluck(:id), + group_ids: [] + } + ) + + service.execute + end + end + end +end diff --git a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb index 554f2e747bc..0b19b50fdfc 100644 --- a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb +++ b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb @@ -48,6 +48,36 @@ shared_examples 'cluster application status specs' do |application_name| expect(subject.version).to eq(subject.class.const_get(:VERSION)) end + + context 'application is updating' do + subject { create(application_name, :updating) } + + it 'is updated' do + subject.make_installed! + + expect(subject).to be_updated + end + + it 'updates helm version' do + subject.cluster.application_helm.update!(version: '1.2.3') + + subject.make_installed! + + subject.cluster.application_helm.reload + + expect(subject.cluster.application_helm.version).to eq(Gitlab::Kubernetes::Helm::HELM_VERSION) + end + + it 'updates the version of the application' do + subject.update!(version: '0.0.0') + + subject.make_installed! + + subject.reload + + expect(subject.version).to eq(subject.class.const_get(:VERSION)) + end + end end describe '#make_updated' do @@ -90,6 +120,17 @@ shared_examples 'cluster application status specs' do |application_name| expect(subject).to be_errored expect(subject.status_reason).to eq(reason) end + + context 'application is updating' do + subject { create(application_name, :updating) } + + it 'is update_errored' do + subject.make_errored(reason) + + expect(subject).to be_update_errored + expect(subject.status_reason).to eq(reason) + end + end end describe '#make_scheduled' do @@ -112,6 +153,18 @@ shared_examples 'cluster application status specs' do |application_name| expect(subject.status_reason).to be_nil end end + + describe 'when was updated_errored' do + subject { create(application_name, :update_errored) } + + it 'clears #status_reason' do + expect(subject.status_reason).not_to be_nil + + subject.make_scheduled! + + expect(subject.status_reason).to be_nil + end + end end end diff --git a/spec/support/shared_examples/models/cluster_application_version_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_version_shared_examples.rb new file mode 100644 index 00000000000..181b102e685 --- /dev/null +++ b/spec/support/shared_examples/models/cluster_application_version_shared_examples.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +shared_examples 'cluster application version specs' do |application_name| + describe 'update_available?' do + let(:version) { '0.0.0' } + + subject { create(application_name, :installed, version: version).update_available? } + + context 'version is not the same as VERSION' do + it { is_expected.to be_truthy } + end + + context 'version is the same as VERSION' do + let(:application) { build(application_name) } + let(:version) { application.class.const_get(:VERSION) } + + it { is_expected.to be_falsey } + end + end +end -- cgit v1.2.3 From 0626c8a0d816c0057bd9837d394b0d6c2bc18eb3 Mon Sep 17 00:00:00 2001 From: Andrew Petro Date: Thu, 7 Feb 2019 22:13:43 +0000 Subject: docs: remove stray extra "to" --- doc/user/project/pages/getting_started_part_two.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/user/project/pages/getting_started_part_two.md b/doc/user/project/pages/getting_started_part_two.md index 8dbbdd051f1..c9081a6d72b 100644 --- a/doc/user/project/pages/getting_started_part_two.md +++ b/doc/user/project/pages/getting_started_part_two.md @@ -71,7 +71,7 @@ created for the steps below. To turn a **project website** forked from the Pages group into a **user/group** website, you'll need to: - Rename it to `namespace.gitlab.io`: navigate to project's **Settings** > expand **Advanced settings** > and scroll down to **Rename repository** -- Adjust your SSG's [base URL](#urls-and-baseurls) to from `"project-name"` to `""`. This setting will be at a different place for each SSG, as each of them have their own structure and file tree. Most likely, it will be in the SSG's config file. +- Adjust your SSG's [base URL](#urls-and-baseurls) from `"project-name"` to `""`. This setting will be at a different place for each SSG, as each of them have their own structure and file tree. Most likely, it will be in the SSG's config file. > **Notes:** > -- cgit v1.2.3 From 650d4f37fb4e2a193cd0ca3d31c2404df016f1d1 Mon Sep 17 00:00:00 2001 From: Thong Kuah Date: Thu, 7 Feb 2019 15:00:01 +1300 Subject: Remove #make_updated! as have #make_installed! now We can have multiple and conditional transistions in a event, so make use of that. --- app/models/clusters/concerns/application_status.rb | 5 ---- .../cluster_application_status_shared_examples.rb | 30 ---------------------- 2 files changed, 35 deletions(-) diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index 6fe7b4a6bd7..5c0164831bc 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -41,11 +41,6 @@ module Clusters transition [:installed, :updated, :update_errored, :scheduled] => :updating end - # Deprecated - event :make_updated do - transition [:updating] => :updated - end - event :make_update_errored do transition any => :update_errored end diff --git a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb index 0b19b50fdfc..c96a65cb56a 100644 --- a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb +++ b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb @@ -80,36 +80,6 @@ shared_examples 'cluster application status specs' do |application_name| end end - describe '#make_updated' do - subject { create(application_name, :updating) } - - it 'is updated' do - subject.make_updated! - - expect(subject).to be_updated - end - - it 'updates helm version' do - subject.cluster.application_helm.update!(version: '1.2.3') - - subject.make_updated! - - subject.cluster.application_helm.reload - - expect(subject.cluster.application_helm.version).to eq(Gitlab::Kubernetes::Helm::HELM_VERSION) - end - - it 'updates the version for the application' do - subject.update!(version: '0.0.0') - - subject.make_updated! - - subject.reload - - expect(subject.version).to eq(subject.class.const_get(:VERSION)) - end - end - describe '#make_errored' do subject { create(application_name, :installing) } let(:reason) { 'some errors' } -- cgit v1.2.3 From 73e5d3a2693d0469fdad925c398b6c464803c4b3 Mon Sep 17 00:00:00 2001 From: Tiger Date: Thu, 7 Feb 2019 15:56:08 +1100 Subject: Validate kubernetes cluster CA certificate No certificate is still accepted, but if one is provided it must be valid. Only run validation if the certificate has changed to avoid making existing records invalid. --- app/models/clusters/platforms/kubernetes.rb | 1 + .../unreleased/55447-validate-k8s-ca-cert.yml | 5 ++++ spec/fixtures/clusters/sample_cert.pem | 2 +- spec/models/clusters/kubernetes_namespace_spec.rb | 2 +- spec/models/clusters/platforms/kubernetes_spec.rb | 32 +++++++++++++++++++++- 5 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 changelogs/unreleased/55447-validate-k8s-ca-cert.yml diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 8f3424db295..ed6bbfb4c64 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -43,6 +43,7 @@ module Clusters # We expect to be `active?` only when enabled and cluster is created (the api_url is assigned) validates :api_url, url: true, presence: true validates :token, presence: true + validates :ca_cert, certificate: true, allow_blank: true, if: :ca_cert_changed? validate :prevent_modification, on: :update diff --git a/changelogs/unreleased/55447-validate-k8s-ca-cert.yml b/changelogs/unreleased/55447-validate-k8s-ca-cert.yml new file mode 100644 index 00000000000..e0448d403da --- /dev/null +++ b/changelogs/unreleased/55447-validate-k8s-ca-cert.yml @@ -0,0 +1,5 @@ +--- +title: Validate kubernetes cluster CA certificate +merge_request: 24990 +author: +type: changed diff --git a/spec/fixtures/clusters/sample_cert.pem b/spec/fixtures/clusters/sample_cert.pem index e39a2b34416..00e6ce44d87 100644 --- a/spec/fixtures/clusters/sample_cert.pem +++ b/spec/fixtures/clusters/sample_cert.pem @@ -30,4 +30,4 @@ TkIdFE47ZisEDhIdF6wC1izEMLeMEsPAO7/Y6MY4nRxsinSe95lRaw+yQpzx+mvJ Q7n1kiHI9Pd5M3+CiQda0d/GO1o5ORJnUGJRvr9HKuNmE7Lif0As/N0AlywjzE7A 6Z8AEiWyRV1ffshu1k2UKmzvZuZeGGKRtrIjbJIRAtpRVtVZZGzhq5/sojCLoJ+u texqFBUo/4mFRZa4pDItUdyOlDy2/LO/ag== ------END CERTIFICATE----- +-----END CERTIFICATE----- \ No newline at end of file diff --git a/spec/models/clusters/kubernetes_namespace_spec.rb b/spec/models/clusters/kubernetes_namespace_spec.rb index 235e2ee4e69..b865909c7fd 100644 --- a/spec/models/clusters/kubernetes_namespace_spec.rb +++ b/spec/models/clusters/kubernetes_namespace_spec.rb @@ -97,7 +97,7 @@ RSpec.describe Clusters::KubernetesNamespace, type: :model do let(:platform) { create(:cluster_platform_kubernetes, api_url: api_url, ca_cert: ca_pem, token: token) } let(:api_url) { 'https://kube.domain.com' } - let(:ca_pem) { 'CA PEM DATA' } + let(:ca_pem) { File.read(Rails.root.join('spec/fixtures/clusters/sample_cert.pem')) } let(:token) { 'token' } let(:kubeconfig) do diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb index 6c8a223092e..ce8269d8024 100644 --- a/spec/models/clusters/platforms/kubernetes_spec.rb +++ b/spec/models/clusters/platforms/kubernetes_spec.rb @@ -114,6 +114,36 @@ describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching end end + context 'ca_cert' do + let(:kubernetes) { build(:cluster_platform_kubernetes, ca_pem: ca_pem) } + + context 'with a valid certificate' do + let(:ca_pem) { File.read(Rails.root.join('spec/fixtures/clusters/sample_cert.pem')) } + + it { is_expected.to be_truthy } + end + + context 'with an invalid certificate' do + let(:ca_pem) { "invalid" } + + it { is_expected.to be_falsey } + + context 'but the certificate is not being updated' do + before do + allow(kubernetes).to receive(:ca_cert_changed?).and_return(false) + end + + it { is_expected.to be_truthy } + end + end + + context 'with no certificate' do + let(:ca_pem) { "" } + + it { is_expected.to be_truthy } + end + end + describe 'when using reserved namespaces' do subject { build(:cluster_platform_kubernetes, namespace: namespace) } @@ -202,7 +232,7 @@ describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching let!(:cluster) { create(:cluster, :project, platform_kubernetes: kubernetes) } let(:kubernetes) { create(:cluster_platform_kubernetes, api_url: api_url, ca_cert: ca_pem) } let(:api_url) { 'https://kube.domain.com' } - let(:ca_pem) { 'CA PEM DATA' } + let(:ca_pem) { File.read(Rails.root.join('spec/fixtures/clusters/sample_cert.pem')) } subject { kubernetes.predefined_variables(project: cluster.project) } -- cgit v1.2.3 From 7e5c2d23dd430acd04b24d5fa2f59b362b31c1bc Mon Sep 17 00:00:00 2001 From: Lukas Eipert Date: Thu, 7 Feb 2019 23:18:07 +0100 Subject: Disable caching for sprockets for now --- .gitlab-ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c14fb0c2028..4e8453726a3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -388,11 +388,13 @@ flaky-examples-check: .assets-compile-cache: &assets-compile-cache cache: - key: "assets-compile:vendor_ruby:.yarn-cache:tmp_cache_assets_sprockets:v3" + key: "assets-compile:vendor_ruby:.yarn-cache:tmp_cache_assets_sprockets:v4" paths: - vendor/ruby/ - .yarn-cache/ - - tmp/cache/assets/sprockets + # We have disabled caching of sprockets for now, as it fails to pick up changes in SCSS: + # https://gitlab.com/gitlab-org/gitlab-ce/issues/57431 + # - tmp/cache/assets/sprockets compile-assets: <<: *dedicated-runner -- cgit v1.2.3 From 4d16acb22b96fee701e647be8b8fdfb7731848bb Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 1 Feb 2019 10:05:31 -0800 Subject: Improve NFS benchmarking doc to include read performance test --- .../operations/filesystem_benchmarking.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/doc/administration/operations/filesystem_benchmarking.md b/doc/administration/operations/filesystem_benchmarking.md index 0397452e650..40f191eaeb8 100644 --- a/doc/administration/operations/filesystem_benchmarking.md +++ b/doc/administration/operations/filesystem_benchmarking.md @@ -9,11 +9,11 @@ Normally when talking about filesystem performance the biggest concern is with Network Filesystems (NFS). However, even some local disks can have slow IO. The information on this page can be used for either scenario. -## Write Performance +## Executing benchmarks -The following one-line command is a quick benchmark for filesystem write +The following one-line commands provide a quick benchmark for filesystem write and read performance. This will write 1,000 small files to the directory in which it is -executed. +executed, and then read the same 1,000 files. 1. Change into the root of the appropriate [repository storage path](../repository_storage_paths.md). @@ -27,13 +27,18 @@ executed. ```sh time for i in {0..1000}; do echo 'test' > "test${i}.txt"; done ``` +1. To benchmark read performance, run the command: + + ```sh + time for i in {0..1000}; do cat "test${i}.txt" > /dev/null; done + ``` 1. Remove the test files: ```sh cd ../; rm -rf test ``` -The output of the `time for ...` command will look similar to the following. The +The output of the `time for ...` commands will look similar to the following. The important metric is the `real` time. ```sh @@ -42,6 +47,12 @@ $ time for i in {0..1000}; do echo 'test' > "test${i}.txt"; done real 0m0.116s user 0m0.025s sys 0m0.091s + +$ time for i in {0..1000}; do cat "test${i}.txt" > /dev/null; done + +real 0m3.118s +user 0m1.267s +sys 0m1.663s ``` From experience with multiple customers, this task should take under 10 -- cgit v1.2.3 From 0cca346ea3028b024792d2d656f1824f5e310943 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Wed, 6 Feb 2019 23:26:28 -0800 Subject: Include benchmarking with fio --- .../operations/filesystem_benchmarking.md | 68 +++++++++++++++++++--- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/doc/administration/operations/filesystem_benchmarking.md b/doc/administration/operations/filesystem_benchmarking.md index 40f191eaeb8..2c187d39c1b 100644 --- a/doc/administration/operations/filesystem_benchmarking.md +++ b/doc/administration/operations/filesystem_benchmarking.md @@ -7,10 +7,69 @@ systems. Normally when talking about filesystem performance the biggest concern is with Network Filesystems (NFS). However, even some local disks can have slow -IO. The information on this page can be used for either scenario. +I/O. The information on this page can be used for either scenario. ## Executing benchmarks +### Benchmarking with `fio` + +We recommend using +[fio](https://fio.readthedocs.io/en/latest/fio_doc.html) to test I/O +performance. This test should be run both on the NFS server and on the +application nodes that talk to the NFS server. + +To install: + +- On Ubuntu: `apt install fio`. +- On `yum`-managed environments: `yum install fio` + +Then run the following: + +```sh +fio --randrepeat=1 --ioengine=libaio --direct=1 --gtod_reduce=1 --name=test --filename=/path/to/git-data/testfile --bs=4k --iodepth=64 --size=4G --readwrite=randrw --rwmixread=75 +``` + +This will create a 4GB file in `/path/to/git-data/testfile`. It performs +4KB reads and writes using a 75%/25% split within the file, with 64 +operations running at a time. Be sure to delete the file after the test +completes. + +The output will vary depending on what version of `fio` installed. The following +is an example output from `fio` v2.2.10 on a networked solid-state drive (SSD): + +``` +test: (g=0): rw=randrw, bs=4K-4K/4K-4K/4K-4K, ioengine=libaio, iodepth=64 + fio-2.2.10 + Starting 1 process + test: Laying out IO file(s) (1 file(s) / 1024MB) + Jobs: 1 (f=1): [m(1)] [100.0% done] [131.4MB/44868KB/0KB /s] [33.7K/11.3K/0 iops] [eta 00m:00s] + test: (groupid=0, jobs=1): err= 0: pid=10287: Sat Feb 2 17:40:10 2019 + read : io=784996KB, bw=133662KB/s, iops=33415, runt= 5873msec + write: io=263580KB, bw=44880KB/s, iops=11219, runt= 5873msec + cpu : usr=6.56%, sys=23.11%, ctx=266267, majf=0, minf=8 + IO depths : 1=0.1%, 2=0.1%, 4=0.1%, 8=0.1%, 16=0.1%, 32=0.1%, >=64=100.0% + submit : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0% + complete : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.1%, >=64=0.0% + issued : total=r=196249/w=65895/d=0, short=r=0/w=0/d=0, drop=r=0/w=0/d=0 + latency : target=0, window=0, percentile=100.00%, depth=64 + + Run status group 0 (all jobs): + READ: io=784996KB, aggrb=133661KB/s, minb=133661KB/s, maxb=133661KB/s, mint=5873msec, maxt=5873msec + WRITE: io=263580KB, aggrb=44879KB/s, minb=44879KB/s, maxb=44879KB/s, mint=5873msec, maxt=5873msec +``` + +Notice the `iops` values in this output. In this example, the SSD +performed 33,415 read operations per second and 11,219 write operations +per second. A spinning disk might yield 2,000 and 700 read and write +operations per second. + +### Simple benchmarking + +NOTE: **Note:** This test is naive but may be useful if `fio` is not +available on the system. It's possible to receive good results on this +test but still have poor performance due to read speed and various other +factors. + The following one-line commands provide a quick benchmark for filesystem write and read performance. This will write 1,000 small files to the directory in which it is executed, and then read the same 1,000 files. @@ -56,9 +115,4 @@ sys 0m1.663s ``` From experience with multiple customers, this task should take under 10 -seconds to indicate good filesystem performance. - -NOTE: **Note:** -This test is naive and only evaluates write performance. It's possible to -receive good results on this test but still have poor performance due to read -speed and various other factors. \ No newline at end of file +seconds to indicate good filesystem performance. -- cgit v1.2.3 From 66fbc2ca07e621c161789527469597fb19631c05 Mon Sep 17 00:00:00 2001 From: Evan Read Date: Fri, 8 Feb 2019 10:46:45 +1000 Subject: Add missing punctuation --- doc/administration/operations/filesystem_benchmarking.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/administration/operations/filesystem_benchmarking.md b/doc/administration/operations/filesystem_benchmarking.md index 2c187d39c1b..c0c242733a2 100644 --- a/doc/administration/operations/filesystem_benchmarking.md +++ b/doc/administration/operations/filesystem_benchmarking.md @@ -21,7 +21,7 @@ application nodes that talk to the NFS server. To install: - On Ubuntu: `apt install fio`. -- On `yum`-managed environments: `yum install fio` +- On `yum`-managed environments: `yum install fio`. Then run the following: -- cgit v1.2.3 From 382eac7c3320531dbcf97057ef5364b8b2eb6af0 Mon Sep 17 00:00:00 2001 From: Ben Bodenmiller Date: Fri, 8 Feb 2019 02:47:32 +0000 Subject: docs: improve per-repository deploy keys --- doc/ssh/README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/doc/ssh/README.md b/doc/ssh/README.md index 80de39c207a..1b53be15b44 100644 --- a/doc/ssh/README.md +++ b/doc/ssh/README.md @@ -114,7 +114,8 @@ To create a new SSH key pair: and want to tell which is which. It is optional. 1. Next, you will be prompted to input a file path to save your SSH key pair to. - If you don't already have an SSH key pair, use the suggested path by pressing + If you don't already have an SSH key pair and aren't generating a [deploy key](#deploy-keys), + use the suggested path by pressing Enter. Using the suggested path will normally allow your SSH client to automatically use the SSH key pair with no additional configuration. @@ -128,7 +129,7 @@ To create a new SSH key pair: Enter twice. If, in any case, you want to add or change the password of your SSH key pair, - you can use the `-p`flag: + you can use the `-p` flag: ``` ssh-keygen -p -o -f @@ -258,7 +259,8 @@ Integration (CI) server. By using deploy keys, you don't have to set up a dummy user account. If you are a project maintainer or owner, you can add a deploy key in the -project settings under the section 'Repository'. Specify a title for the new +project's **Settings > Repository** page by expanding the +**Deploy Keys** section. Specify a title for the new deploy key and paste a public SSH key. After this, the machine that uses the corresponding private SSH key has read-only or read-write (if enabled) access to the project. @@ -300,8 +302,8 @@ of broader usage for something like "Anywhere you need to give read access to your repository". Once a GitLab administrator adds the Global Deployment key, project maintainers -and owners can add it in project's **Settings > Repository** section by expanding the -**Deploy Key** section and clicking **Enable** next to the appropriate key listed +and owners can add it in project's **Settings > Repository** page by expanding the +**Deploy Keys** section and clicking **Enable** next to the appropriate key listed under **Public deploy keys available to any project**. NOTE: **Note:** -- cgit v1.2.3 From 470a86670fed96899a342f0b53a047799921055d Mon Sep 17 00:00:00 2001 From: James Fargher Date: Thu, 7 Feb 2019 16:00:32 +1300 Subject: Add missing argument to DeploymentService#predefined_variables --- app/models/project_services/deployment_service.rb | 2 +- spec/factories/projects.rb | 4 ++++ spec/factories/services.rb | 6 ++++++ spec/models/project_spec.rb | 8 ++++++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/models/project_services/deployment_service.rb b/app/models/project_services/deployment_service.rb index 6dae4f3a4a6..80aa2101509 100644 --- a/app/models/project_services/deployment_service.rb +++ b/app/models/project_services/deployment_service.rb @@ -11,7 +11,7 @@ class DeploymentService < Service %w() end - def predefined_variables + def predefined_variables(project:) [] end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 18fab395cc2..f7ef34d773b 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -322,6 +322,10 @@ FactoryBot.define do kubernetes_service end + factory :mock_deployment_project, parent: :project do + mock_deployment_service + end + factory :prometheus_project, parent: :project do after :create do |project| project.create_prometheus_service( diff --git a/spec/factories/services.rb b/spec/factories/services.rb index 5be56a49903..0d8c26a2ee9 100644 --- a/spec/factories/services.rb +++ b/spec/factories/services.rb @@ -26,6 +26,12 @@ FactoryBot.define do }) end + factory :mock_deployment_service do + project + type 'MockDeploymentService' + active true + end + factory :prometheus_service do project active true diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index c1767ed0535..ac9362339e5 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -2548,6 +2548,14 @@ describe Project do end end + context 'when project uses mock deployment service' do + let(:project) { create(:mock_deployment_project) } + + it 'returns an empty array' do + expect(project.deployment_variables).to eq [] + end + end + context 'when project has a deployment service' do shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do it 'returns variables from this service' do -- cgit v1.2.3 From 8ab5b3eb9d34baa15690c46661c396f2320192d2 Mon Sep 17 00:00:00 2001 From: Sanad Liaquat Date: Fri, 8 Feb 2019 12:28:55 +0500 Subject: Use search_and_select instead of select_item --- qa/qa/page/project/new.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qa/qa/page/project/new.rb b/qa/qa/page/project/new.rb index a588af07e4a..9f1867ef8a5 100644 --- a/qa/qa/page/project/new.rb +++ b/qa/qa/page/project/new.rb @@ -26,7 +26,7 @@ module QA def choose_test_namespace click_element :project_namespace_select - select_item(Runtime::Namespace.path) + search_and_select(Runtime::Namespace.path) end def go_to_import_project -- cgit v1.2.3 From b177d5233b63177a2c52289183f3693f19561ec1 Mon Sep 17 00:00:00 2001 From: Constance Okoghenun Date: Fri, 8 Feb 2019 08:37:07 +0100 Subject: Checking MR status date for related MR status tooltip Changed related MR status tooltip content when MR status date is not available. --- app/views/projects/issues/_merge_requests_status.html.haml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/views/projects/issues/_merge_requests_status.html.haml b/app/views/projects/issues/_merge_requests_status.html.haml index 43e4c8db93f..90838a75214 100644 --- a/app/views/projects/issues/_merge_requests_status.html.haml +++ b/app/views/projects/issues/_merge_requests_status.html.haml @@ -12,11 +12,14 @@ - mr_status_class = 'closed' - else - mr_status_date = merge_request.created_at - - mr_status_title = _('Opened') + - mr_status_title = mr_status_date ? _('Opened') : _('Open') - mr_status_icon = 'issue-open-m' - mr_status_class = 'open' -- mr_status_tooltip = "
#{mr_status_title} #{time_ago_in_words(mr_status_date)} ago
#{l(mr_status_date.to_time, format: time_format)}" +- if mr_status_date + - mr_status_tooltip = "
#{mr_status_title} #{time_ago_in_words(mr_status_date)} ago
#{l(mr_status_date.to_time, format: time_format)}" +- else + - mr_status_tooltip = "
#{mr_status_title}
" %span.mr-status-wrapper.suggestion-help-hover{ class: css_class, data: { toggle: 'tooltip', placement: 'bottom', html: 'true', title: mr_status_tooltip } } = sprite_icon(mr_status_icon, size: 16, css_class: "merge-request-status #{mr_status_class}") -- cgit v1.2.3 From 4cf4d65f17e1c86decaa81b09c0a8afcd61c80c3 Mon Sep 17 00:00:00 2001 From: Rajakavitha Kodhandapani Date: Fri, 8 Feb 2019 11:11:54 +0000 Subject: Update index.md --- doc/user/index.md | 44 +++++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/doc/user/index.md b/doc/user/index.md index fc68404d0c2..36aba5e01c6 100644 --- a/doc/user/index.md +++ b/doc/user/index.md @@ -12,28 +12,22 @@ includes, except [GitLab administrator](../README.md#administrator-documentation settings, unless you have admin privileges to install, configure, and upgrade your GitLab instance. -For GitLab.com, admin privileges are restricted to the GitLab team. +Admin privileges for [GitLab.com](https://gitlab.com/) are restricted to the GitLab team. -If you run your own GitLab instance and are looking for the administration settings, -please refer to the [administration](../README.md#administrator-documentation) -documentation. +For more information on configuring GitLab self-managed instances, see [Administrator documentation](../README.md#administrator-documentation). ## Overview -GitLab is a fully integrated software development platform that enables you -and your team to work cohesively, faster, transparently, and effectively, -since the discussion of a new idea until taking that idea to production all -the way through, from within the same platform. +GitLab is a fully integrated software development platform that enables your team to be transparent, fast, effective, and cohesive from discussion on a new idea to production, all on the same platform. -Please check this page for an overview on [GitLab's features](https://about.gitlab.com/features/). +For more information, see [All GitLab Features](https://about.gitlab.com/features/). ### Concepts -For an overview on concepts involved when developing code on GitLab, -read the articles on: +To get familiar with the concepts needed to develop code on GitLab, read the following articles: -- [Mastering Code Review With GitLab](https://about.gitlab.com/2017/03/17/demo-mastering-code-review-with-gitlab/). -- [GitLab Workflow, an Overview](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/#gitlab-workflow-use-case-scenario). +- [Demo: Mastering Code Review With GitLab](https://about.gitlab.com/2017/03/17/demo-mastering-code-review-with-gitlab/). +- [GitLab Workflow: An Overview](https://about.gitlab.com/2016/10/25/gitlab-workflow-an-overview/#gitlab-workflow-use-case-scenario). - [Tutorial: It's all connected in GitLab](https://about.gitlab.com/2016/03/08/gitlab-tutorial-its-all-connected/): an overview on code collaboration with GitLab. - [Trends in Version Control Land: Microservices](https://about.gitlab.com/2016/08/16/trends-in-version-control-land-microservices/). - [Trends in Version Control Land: Innersourcing](https://about.gitlab.com/2016/07/07/trends-version-control-innersourcing/). @@ -42,16 +36,16 @@ read the articles on: GitLab is a Git-based platform that integrates a great number of essential tools for software development and deployment, and project management: -- Code hosting in repositories with version control -- Track proposals for new implementations, bug reports, and feedback with a +- Hosting code in repositories with version control +- Tracking proposals for new implementations, bug reports, and feedback with a fully featured [Issue Tracker](project/issues/index.md#issue-tracker) -- Organize and prioritize with [Issue Boards](project/issues/index.md#issue-boards) -- Code review in [Merge Requests](project/merge_requests/index.md) with live-preview changes per +- Organizing and prioritizing with [Issue Boards](project/issues/index.md#issue-boards) +- Reviewing code in [Merge Requests](project/merge_requests/index.md) with live-preview changes per branch with [Review Apps](../ci/review_apps/index.md) -- Build, test and deploy with built-in [Continuous Integration](../ci/README.md) -- Deploy your personal and professional static websites with [GitLab Pages](project/pages/index.md) -- Integrate with Docker with [GitLab Container Registry](project/container_registry.md) -- Track the development lifecycle with [GitLab Cycle Analytics](project/cycle_analytics.md) +- Building, testing and deploying with built-in [Continuous Integration](../ci/README.md) +- Deploying personal and professional static websites with [GitLab Pages](project/pages/index.md) +- Integrating with Docker by using [GitLab Container Registry](project/container_registry.md) +- Tracking the development lifecycle by usingn [GitLab Cycle Analytics](project/cycle_analytics.md) With GitLab Enterprise Edition, you can also: @@ -68,15 +62,15 @@ and [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board. - [Export issues as CSV](https://docs.gitlab.com/ee/user/project/issues/csv_export.html) - View your entire CI/CD pipeline involving more than one project with [Multiple-Project Pipeline Graphs](https://docs.gitlab.com/ee/ci/multi_project_pipeline_graphs.html) - [Lock files](https://docs.gitlab.com/ee/user/project/file_lock.html) to prevent conflicts -- View of the current health and status of each CI environment running on Kubernetes with [Deploy Boards](https://docs.gitlab.com/ee/user/project/deploy_boards.html) -- Leverage your continuous delivery method with [Canary Deployments](https://docs.gitlab.com/ee/user/project/canary_deployments.html) +- View the current health and status of each CI environment running on Kubernetes with [Deploy Boards](https://docs.gitlab.com/ee/user/project/deploy_boards.html) +- Leverage continuous delivery method with [Canary Deployments](https://docs.gitlab.com/ee/user/project/canary_deployments.html) You can also [integrate](project/integrations/project_services.md) GitLab with numerous third-party applications, such as Mattermost, Microsoft Teams, HipChat, Trello, Slack, Bamboo CI, JIRA, and a lot more. ## Projects -In GitLab, you can create [projects](project/index.md) for numerous reasons, such as, host -your code, use it as an issue tracker, collaborate on code, and continuously +In GitLab, you can create [projects](project/index.md) to host +your code, track issues, collaborate on code, and continuously build, test, and deploy your app with built-in GitLab CI/CD. Or, you can do it all at once, from one single project. -- cgit v1.2.3 From b4c2dd73875aa50d2a72e694203388446599d8eb Mon Sep 17 00:00:00 2001 From: Sanad Liaquat Date: Fri, 8 Feb 2019 16:20:31 +0500 Subject: Wait for viewers to load Adds qa-spinner class and wait for it to go away before making assertions. --- app/views/projects/blob/viewers/_loading.html.haml | 2 +- qa/qa/page/project/show.rb | 10 ++++++++++ .../3_create/repository/push_http_private_token_spec.rb | 6 +++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml index 120c0540335..df1f3e4e01b 100644 --- a/app/views/projects/blob/viewers/_loading.html.haml +++ b/app/views/projects/blob/viewers/_loading.html.haml @@ -1,2 +1,2 @@ .text-center.prepend-top-default.append-bottom-default - = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…') + = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…', class: 'qa-spinner') diff --git a/qa/qa/page/project/show.rb b/qa/qa/page/project/show.rb index 945b244df15..9c21d9ddbfa 100644 --- a/qa/qa/page/project/show.rb +++ b/qa/qa/page/project/show.rb @@ -51,6 +51,16 @@ module QA element :branches_dropdown end + view 'app/views/projects/blob/viewers/_loading.html.haml' do + element :spinner + end + + def wait_for_viewers_to_load + wait(reload: false) do + has_no_element?(:spinner) + end + end + def create_first_new_file! within_element(:quick_actions) do click_link_with_text 'New file' diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/push_http_private_token_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/push_http_private_token_spec.rb index 3310a873a60..73f020e7d05 100644 --- a/qa/qa/specs/features/browser_ui/3_create/repository/push_http_private_token_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/repository/push_http_private_token_spec.rb @@ -22,7 +22,11 @@ module QA end push.project.visit! - Page::Project::Show.perform(&:wait_for_push) + + Page::Project::Show.perform do |page| + page.wait_for_push + page.wait_for_viewers_to_load + end expect(page).to have_content('README.md') expect(page).to have_content('This is a test project') -- cgit v1.2.3 From 48bcd5248f6c1e8471393fe07ed043c4fefcb7e2 Mon Sep 17 00:00:00 2001 From: Andrew Newdigate Date: Mon, 4 Feb 2019 14:44:51 +0200 Subject: Provide a performance bar link to the Jaeger UI Jaeger is a distributed tracing tool. This change adds a "Tracing" link to the performance bar to directly link to a current request in Jaeger. This is useful for two reasons: 1 - it provides affordance to developers that the distributed tracing tool is available, so that it can quickly be discovered. 2 - it allows developers to quickly find a specific trace without having to manually navigate to a second user-interface. --- .../components/performance_bar_app.vue | 7 +++ changelogs/unreleased/an-peek-jaeger.yml | 5 ++ config/initializers/peek.rb | 1 + lib/gitlab/tracing.rb | 19 +++++++ lib/peek/views/tracing.rb | 13 +++++ spec/lib/gitlab/tracing_spec.rb | 66 ++++++++++++++++++++++ 6 files changed, 111 insertions(+) create mode 100644 changelogs/unreleased/an-peek-jaeger.yml create mode 100644 lib/peek/views/tracing.rb create mode 100644 spec/lib/gitlab/tracing_spec.rb diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 74faa35358d..1ec2784cc5a 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -134,6 +134,13 @@ export default { >ms / {{ currentRequest.details.gc.invokes }} gc
+ Date: Fri, 8 Feb 2019 12:53:35 +0000 Subject: Move permission check of manual actions of deployments --- .../environments/components/container.vue | 10 +------- .../environments/components/environment_item.vue | 10 ++------ .../environments/components/environments_app.vue | 5 ---- .../environments/components/environments_table.vue | 8 ------ .../folder/environments_folder_bundle.js | 2 -- .../folder/environments_folder_view.vue | 5 ---- app/assets/javascripts/environments/index.js | 2 -- app/helpers/environments_helper.rb | 1 - app/serializers/deployment_entity.rb | 10 ++++++-- app/views/projects/environments/index.html.haml | 1 - ...mission-check-manual-actions-on-deployments.yml | 5 ++++ .../environments/environment_item_spec.js | 2 -- .../environments/environment_table_spec.js | 1 - .../environments/environments_app_spec.js | 1 - .../folder/environments_folder_view_spec.js | 1 - spec/serializers/deployment_entity_spec.rb | 30 ++++++++++++++++++++-- spec/serializers/environment_serializer_spec.rb | 4 +++ 17 files changed, 48 insertions(+), 50 deletions(-) create mode 100644 changelogs/unreleased/move-permission-check-manual-actions-on-deployments.yml diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index bd402c0eea5..6ece8b92a30 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -22,10 +22,6 @@ export default { type: Object, required: true, }, - canCreateDeployment: { - type: Boolean, - required: true, - }, canReadEnvironment: { type: Boolean, required: true, @@ -51,11 +47,7 @@ export default {
- + ({}), }, - canCreateDeployment: { - type: Boolean, - required: false, - default: false, - }, - canReadEnvironment: { type: Boolean, required: false, @@ -151,7 +145,7 @@ export default { }, actions() { - if (!this.model || !this.model.last_deployment || !this.canCreateDeployment) { + if (!this.model || !this.model.last_deployment) { return []; } @@ -561,7 +555,7 @@ export default { /> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 87c1d44dd40..aa2417d3194 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -24,10 +24,6 @@ export default { type: Boolean, required: true, }, - canCreateDeployment: { - type: Boolean, - required: true, - }, canReadEnvironment: { type: Boolean, required: true, @@ -106,7 +102,6 @@ export default { :is-loading="isLoading" :environments="state.environments" :pagination="state.paginationInformation" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" @onChangePage="onChangePage" > diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 75bdf87137f..e2c304de00a 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -23,12 +23,6 @@ export default { required: false, default: false, }, - - canCreateDeployment: { - type: Boolean, - required: false, - default: false, - }, }, methods: { folderUrl(model) { @@ -64,7 +58,6 @@ export default { is="environment-item" :key="`environment-item-${i}`" :model="model" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" /> @@ -79,7 +72,6 @@ export default { v-for="(children, index) in model.children" :key="`env-item-${i}-${index}`" :model="children" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" /> diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index 982e550e73c..56e7f69cad6 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -18,7 +18,6 @@ export default () => endpoint: environmentsData.environmentsDataEndpoint, folderName: environmentsData.environmentsDataFolderName, cssContainerClass: environmentsData.cssClass, - canCreateDeployment: parseBoolean(environmentsData.environmentsDataCanCreateDeployment), canReadEnvironment: parseBoolean(environmentsData.environmentsDataCanReadEnvironment), }; }, @@ -28,7 +27,6 @@ export default () => endpoint: this.endpoint, folderName: this.folderName, cssContainerClass: this.cssContainerClass, - canCreateDeployment: this.canCreateDeployment, canReadEnvironment: this.canReadEnvironment, }, }); diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index d6f0b6115a6..80f0e00400b 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -23,10 +23,6 @@ export default { type: String, required: true, }, - canCreateDeployment: { - type: Boolean, - required: true, - }, canReadEnvironment: { type: Boolean, required: true, @@ -55,7 +51,6 @@ export default { :is-loading="isLoading" :environments="state.environments" :pagination="state.paginationInformation" - :can-create-deployment="canCreateDeployment" :can-read-environment="canReadEnvironment" @onChangePage="onChangePage" /> diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index d366e7550b7..6af66d0f86e 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -20,7 +20,6 @@ export default () => helpPagePath: environmentsData.helpPagePath, cssContainerClass: environmentsData.cssClass, canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment), - canCreateDeployment: parseBoolean(environmentsData.canCreateDeployment), canReadEnvironment: parseBoolean(environmentsData.canReadEnvironment), }; }, @@ -32,7 +31,6 @@ export default () => helpPagePath: this.helpPagePath, cssContainerClass: this.cssContainerClass, canCreateEnvironment: this.canCreateEnvironment, - canCreateDeployment: this.canCreateDeployment, canReadEnvironment: this.canReadEnvironment, }, }); diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index b3935ae350d..365b94f5a3e 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -11,7 +11,6 @@ module EnvironmentsHelper { "endpoint" => folder_project_environments_path(@project, @folder, format: :json), "folder-name" => @folder, - "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, "can-read-environment" => can?(current_user, :read_environment, @project).to_s } end diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index aa1d9e6292c..34ae06278c8 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -24,6 +24,12 @@ class DeploymentEntity < Grape::Entity expose :user, using: UserEntity expose :commit, using: CommitEntity expose :deployable, using: JobEntity - expose :manual_actions, using: JobEntity - expose :scheduled_actions, using: JobEntity + expose :manual_actions, using: JobEntity, if: -> (*) { can_create_deployment? } + expose :scheduled_actions, using: JobEntity, if: -> (*) { can_create_deployment? } + + private + + def can_create_deployment? + can?(request.current_user, :create_deployment, request.project) + end end diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index d66de7ab698..99cbbc11acd 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -2,7 +2,6 @@ - page_title _("Environments") #environments-list-view{ data: { environments_data: environments_list_data, - "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, "can-read-environment" => can?(current_user, :read_environment, @project).to_s, "can-create-environment" => can?(current_user, :create_environment, @project).to_s, "new-environment-path" => new_project_environment_path(@project), diff --git a/changelogs/unreleased/move-permission-check-manual-actions-on-deployments.yml b/changelogs/unreleased/move-permission-check-manual-actions-on-deployments.yml new file mode 100644 index 00000000000..9e979b48ad1 --- /dev/null +++ b/changelogs/unreleased/move-permission-check-manual-actions-on-deployments.yml @@ -0,0 +1,5 @@ +--- +title: Move permission check of manual actions of deployments +merge_request: 24660 +author: +type: other diff --git a/spec/javascripts/environments/environment_item_spec.js b/spec/javascripts/environments/environment_item_spec.js index 7618c2f50ce..a89e50045da 100644 --- a/spec/javascripts/environments/environment_item_spec.js +++ b/spec/javascripts/environments/environment_item_spec.js @@ -25,7 +25,6 @@ describe('Environment item', () => { component = new EnvironmentItem({ propsData: { model: mockItem, - canCreateDeployment: false, canReadEnvironment: true, service: {}, }, @@ -117,7 +116,6 @@ describe('Environment item', () => { component = new EnvironmentItem({ propsData: { model: environment, - canCreateDeployment: true, canReadEnvironment: true, service: {}, }, diff --git a/spec/javascripts/environments/environment_table_spec.js b/spec/javascripts/environments/environment_table_spec.js index 0e5e50a59a5..52895f35f3a 100644 --- a/spec/javascripts/environments/environment_table_spec.js +++ b/spec/javascripts/environments/environment_table_spec.js @@ -26,7 +26,6 @@ describe('Environment table', () => { vm = mountComponent(Component, { environments: [mockItem], - canCreateDeployment: false, canReadEnvironment: true, }); diff --git a/spec/javascripts/environments/environments_app_spec.js b/spec/javascripts/environments/environments_app_spec.js index e2d81eb454a..9220f7a264f 100644 --- a/spec/javascripts/environments/environments_app_spec.js +++ b/spec/javascripts/environments/environments_app_spec.js @@ -9,7 +9,6 @@ describe('Environment', () => { const mockData = { endpoint: 'environments.json', canCreateEnvironment: true, - canCreateDeployment: true, canReadEnvironment: true, cssContainerClass: 'container', newEnvironmentPath: 'environments/new', diff --git a/spec/javascripts/environments/folder/environments_folder_view_spec.js b/spec/javascripts/environments/folder/environments_folder_view_spec.js index 7f0a9475d5f..d9ee7e74e28 100644 --- a/spec/javascripts/environments/folder/environments_folder_view_spec.js +++ b/spec/javascripts/environments/folder/environments_folder_view_spec.js @@ -13,7 +13,6 @@ describe('Environments Folder View', () => { const mockData = { endpoint: 'environments.json', folderName: 'review', - canCreateDeployment: true, canReadEnvironment: true, cssContainerClass: 'container', }; diff --git a/spec/serializers/deployment_entity_spec.rb b/spec/serializers/deployment_entity_spec.rb index cfa5414b40f..894fd7a0a12 100644 --- a/spec/serializers/deployment_entity_spec.rb +++ b/spec/serializers/deployment_entity_spec.rb @@ -1,14 +1,22 @@ require 'spec_helper' describe DeploymentEntity do - let(:user) { create(:user) } + let(:user) { developer } + let(:developer) { create(:user) } + let(:reporter) { create(:user) } + let(:project) { create(:project) } let(:request) { double('request') } - let(:deployment) { create(:deployment) } + let(:deployment) { create(:deployment, deployable: build, project: project) } + let(:build) { create(:ci_build, :manual, pipeline: pipeline) } + let(:pipeline) { create(:ci_pipeline, project: project, user: user) } let(:entity) { described_class.new(deployment, request: request) } subject { entity.as_json } before do + project.add_developer(developer) + project.add_reporter(reporter) allow(request).to receive(:current_user).and_return(user) + allow(request).to receive(:project).and_return(project) end it 'exposes internal deployment id' do @@ -23,6 +31,24 @@ describe DeploymentEntity do expect(subject).to include(:created_at) end + context 'when the pipeline has another manual action' do + let(:other_build) { create(:ci_build, :manual, name: 'another deploy', pipeline: pipeline) } + let!(:other_deployment) { create(:deployment, deployable: other_build) } + + it 'returns another manual action' do + expect(subject[:manual_actions].count).to eq(1) + expect(subject[:manual_actions].first[:name]).to eq('another deploy') + end + + context 'when user is a reporter' do + let(:user) { reporter } + + it 'returns another manual action' do + expect(subject[:manual_actions]).not_to be_present + end + end + end + describe 'scheduled_actions' do let(:project) { create(:project, :repository) } let(:pipeline) { create(:ci_pipeline, project: project, user: user) } diff --git a/spec/serializers/environment_serializer_spec.rb b/spec/serializers/environment_serializer_spec.rb index 87493a28d1f..3541bd5f12e 100644 --- a/spec/serializers/environment_serializer_spec.rb +++ b/spec/serializers/environment_serializer_spec.rb @@ -10,6 +10,10 @@ describe EnvironmentSerializer do .represent(resource) end + before do + project.add_developer(user) + end + context 'when there is a single object provided' do let(:project) { create(:project, :repository) } let(:deployable) { create(:ci_build) } -- cgit v1.2.3 From 60b3ea9493deae26ab83cadf1d868ee421fcfff5 Mon Sep 17 00:00:00 2001 From: Evan Read Date: Fri, 8 Feb 2019 13:57:31 +0000 Subject: Refactor snippets API documentation page --- doc/api/snippets.md | 216 ++++++++++++++++++++++++++++++++++----------------- doc/user/snippets.md | 7 +- 2 files changed, 152 insertions(+), 71 deletions(-) diff --git a/doc/api/snippets.md b/doc/api/snippets.md index 2cbd041d132..f90447e124e 100644 --- a/doc/api/snippets.md +++ b/doc/api/snippets.md @@ -1,43 +1,100 @@ # Snippets API -> [Introduced][ce-6373] in GitLab 8.15. +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6373) in GitLab 8.15. + +Snippets API operates on [snippets](../user/snippets.md). ## Snippet visibility level Snippets in GitLab can be either private, internal, or public. You can set it with the `visibility` field in the snippet. -Constants for snippet visibility levels are: +Valid values for snippet visibility levels are: -| Visibility | Description | -| ---------- | ----------- | -| `private` | The snippet is visible only to the snippet creator | -| `internal` | The snippet is visible for any logged in user | -| `public` | The snippet can be accessed without any authentication | +| Visibility | Description | +|:-----------|:----------------------------------------------------| +| `private` | Snippet is visible only to the snippet creator. | +| `internal` | Snippet is visible for any logged in user. | +| `public` | Snippet can be accessed without any authentication. | -## List snippets +## List all snippets for a user -Get a list of current user's snippets. +Get a list of the current user's snippets. -``` +```text GET /snippets ``` -## Single snippet +Example request: -Get a single snippet. +```sh +curl --header "PRIVATE-TOKEN: " https://gitlab.example.com/api/v4/snippets +``` + +Example response: +```json +[ + { + "id": 42, + "title": "Voluptatem iure ut qui aut et consequatur quaerat.", + "file_name": "mclaughlin.rb", + "description": null, + "visibility": "internal", + "author": { + "id": 22, + "name": "User 0", + "username": "user0", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80&d=identicon", + "web_url": "http://localhost:3000/user0" + }, + "updated_at": "2018-09-18T01:12:26.383Z", + "created_at": "2018-09-18T01:12:26.383Z", + "project_id": null, + "web_url": "http://localhost:3000/snippets/42", + "raw_url": "http://localhost:3000/snippets/42/raw" + }, + { + "id": 41, + "title": "Ut praesentium non et atque.", + "file_name": "ondrickaemard.rb", + "description": null, + "visibility": "internal", + "author": { + "id": 22, + "name": "User 0", + "username": "user0", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80&d=identicon", + "web_url": "http://localhost:3000/user0" + }, + "updated_at": "2018-09-18T01:12:26.360Z", + "created_at": "2018-09-18T01:12:26.360Z", + "project_id": null, + "web_url": "http://localhost:3000/snippets/41", + "raw_url": "http://localhost:3000/snippets/41/raw" + } +] ``` + +## Get a single snippet + +Get a single snippet. + +```text GET /snippets/:id ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | Integer | yes | The ID of a snippet | +| Attribute | Type | Required | Description | +|:----------|:--------|:---------|:---------------------------| +| `id` | integer | yes | ID of snippet to retrieve. | + +Example request: -```bash +```sh curl --header "PRIVATE-TOKEN: " https://gitlab.example.com/api/v4/snippets/1 ``` @@ -69,46 +126,52 @@ Example response: Get a single snippet's raw contents. -``` +```text GET /snippets/:id/raw ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | Integer | yes | The ID of a snippet | +| Attribute | Type | Required | Description | +|:----------|:--------|:---------|:---------------------------| +| `id` | integer | yes | ID of snippet to retrieve. | -```bash +Example request: + +```sh curl --header "PRIVATE-TOKEN: " https://gitlab.example.com/api/v4/snippets/1/raw ``` Example response: -``` +```text Hello World snippet ``` ## Create new snippet -Creates a new snippet. The user must have permission to create new snippets. +Create a new snippet. -``` +NOTE: **Note:** +The user must have permission to create new snippets. + +```text POST /snippets ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `title` | String | yes | The title of a snippet | -| `file_name` | String | yes | The name of a snippet file | -| `content` | String | yes | The content of a snippet | -| `description` | String | no | The description of a snippet | -| `visibility` | String | no | The snippet's visibility | +| Attribute | Type | Required | Description | +|:--------------|:-------|:---------|:---------------------------------------------------| +| `title` | string | yes | Title of a snippet. | +| `file_name` | string | yes | Name of a snippet file. | +| `content` | string | yes | Content of a snippet. | +| `description` | string | no | Description of a snippet. | +| `visibility` | string | no | Snippet's [visibility](#snippet-visibility-level). | +Example request: -```bash +```sh curl --request POST \ --data '{"title": "This is a snippet", "content": "Hello world", "description": "Hello World snippet", "file_name": "test.txt", "visibility": "internal" }' \ --header 'Content-Type: application/json' \ @@ -142,25 +205,29 @@ Example response: ## Update snippet -Updates an existing snippet. The user must have permission to change an existing snippet. +Update an existing snippet. -``` +NOTE: **Note:** +The user must have permission to change an existing snippet. + +```text PUT /snippets/:id ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | Integer | yes | The ID of a snippet | -| `title` | String | no | The title of a snippet | -| `file_name` | String | no | The name of a snippet file | -| `description` | String | no | The description of a snippet | -| `content` | String | no | The content of a snippet | -| `visibility` | String | no | The snippet's visibility | +| Attribute | Type | Required | Description | +|:--------------|:--------|:---------|:---------------------------------------------------| +| `id` | integer | yes | ID of snippet to update. | +| `title` | string | no | Title of a snippet. | +| `file_name` | string | no | Name of a snippet file. | +| `description` | string | no | Description of a snippet. | +| `content` | string | no | Content of a snippet. | +| `visibility` | string | no | Snippet's [visibility](#snippet-visibility-level). | +Example request: -```bash +```sh curl --request PUT \ --data '{"title": "foo", "content": "bar"}' \ --header 'Content-Type: application/json' \ @@ -194,38 +261,49 @@ Example response: ## Delete snippet -Deletes an existing snippet. +Delete an existing snippet. -``` +```text DELETE /snippets/:id ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | Integer | yes | The ID of a snippet | +| Attribute | Type | Required | Description | +|:----------|:--------|:---------|:-------------------------| +| `id` | integer | yes | ID of snippet to delete. | +Example request: -``` +```sh curl --request DELETE --header "PRIVATE-TOKEN: " "https://gitlab.example.com/api/v4/snippets/1" ``` -upon successful delete a `204 No content` HTTP code shall be expected, with no data, -but if the snippet is non-existent, a `404 Not Found` will be returned. +The following are possible return codes: -## Explore all public snippets +| Code | Description | +|:------|:--------------------------------------------| +| `204` | Delete was successful. No data is returned. | +| `404` | The snippet wasn't found. | -``` +## List all public snippets + +List all public snippets. + +```text GET /snippets/public ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `per_page` | Integer | no | number of snippets to return per page | -| `page` | Integer | no | the page to retrieve | +Parameters: + +| Attribute | Type | Required | Description | +|:-----------|:--------|:---------|:---------------------------------------| +| `per_page` | integer | no | Number of snippets to return per page. | +| `page` | integer | no | Page to retrieve. | -```bash +Example request: + +```sh curl --header "PRIVATE-TOKEN: " https://gitlab.example.com/api/v4/snippets/public?per_page=2&page=1 ``` @@ -273,21 +351,22 @@ Example response: ## Get user agent details -> **Notes:** -> [Introduced][ce-29508] in GitLab 9.4. - +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12655) in GitLab 9.4. -Available only for admins. +NOTE: **Note:** +Available only for administrators. -``` +```text GET /snippets/:id/user_agent_detail ``` -| Attribute | Type | Required | Description | -|-------------|---------|----------|--------------------------------------| -| `id` | Integer | yes | The ID of a snippet | +| Attribute | Type | Required | Description | +|:----------|:--------|:---------|:---------------| +| `id` | integer | yes | ID of snippet. | -```bash +Example request: + +```sh curl --request GET --header "PRIVATE-TOKEN: " https://gitlab.example.com/api/v4/snippets/1/user_agent_detail ``` @@ -300,6 +379,3 @@ Example response: "akismet_submitted": false } ``` - -[ce-6373]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6373 -[ce-29508]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12655 diff --git a/doc/user/snippets.md b/doc/user/snippets.md index 5c9f6ffb163..569bdc9e2d5 100644 --- a/doc/user/snippets.md +++ b/doc/user/snippets.md @@ -4,7 +4,12 @@ With GitLab Snippets you can store and share bits of code and text with other us ![GitLab Snippet](img/gitlab_snippet.png) -There are 2 types of snippets, personal snippets and project snippets. +Snippets can be maintained using [snippets API](../api/snippets.md). + +There are two types of snippets: + +- Personal snippets. +- Project snippets. ## Personal snippets -- cgit v1.2.3 From 37b860d7bf24bfe5c6414bcd4009ce85a1cafd46 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 8 Feb 2019 14:06:24 +0000 Subject: Fixes diff web worker not loading in staging https://gitlab.com/gitlab-org/quality/staging/issues/34 --- app/assets/javascripts/ide/index.js | 11 +---------- app/assets/javascripts/lib/utils/webpack.js | 10 ++++++++++ app/assets/javascripts/mr_notes/index.js | 3 +++ 3 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 app/assets/javascripts/lib/utils/webpack.js diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 5a2b680c2f7..cdfebd19fa4 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -6,6 +6,7 @@ import ide from './components/ide.vue'; import store from './stores'; import router from './ide_router'; import { parseBoolean } from '../lib/utils/common_utils'; +import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; Vue.use(Translate); @@ -60,16 +61,6 @@ export function initIde(el, options = {}) { }); } -// tell webpack to load assets from origin so that web workers don't break -export function resetServiceWorkersPublicPath() { - // __webpack_public_path__ is a global variable that can be used to adjust - // the webpack publicPath setting at runtime. - // see: https://webpack.js.org/guides/public-path/ - const relativeRootPath = (gon && gon.relative_url_root) || ''; - const webpackAssetPath = `${relativeRootPath}/assets/webpack/`; - __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase -} - /** * Start the IDE. * diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js new file mode 100644 index 00000000000..308ad9784e4 --- /dev/null +++ b/app/assets/javascripts/lib/utils/webpack.js @@ -0,0 +1,10 @@ +// tell webpack to load assets from origin so that web workers don't break +// eslint-disable-next-line import/prefer-default-export +export function resetServiceWorkersPublicPath() { + // __webpack_public_path__ is a global variable that can be used to adjust + // the webpack publicPath setting at runtime. + // see: https://webpack.js.org/guides/public-path/ + const relativeRootPath = (gon && gon.relative_url_root) || ''; + const webpackAssetPath = `${relativeRootPath}/assets/webpack/`; + __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase +} diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index e4d72eb8318..9e99aa4f724 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -7,8 +7,11 @@ import discussionCounter from '../notes/components/discussion_counter.vue'; import initDiscussionFilters from '../notes/discussion_filters'; import store from './stores'; import MergeRequest from '../merge_request'; +import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; export default function initMrNotes() { + resetServiceWorkersPublicPath(); + const mrShowNode = document.querySelector('.merge-request'); // eslint-disable-next-line no-new new MergeRequest({ -- cgit v1.2.3 From 0477ff637cb55d33db9bdebc5e3689350575ffe3 Mon Sep 17 00:00:00 2001 From: Winnie Hellmann Date: Fri, 8 Feb 2019 14:13:01 +0000 Subject: Refactor draft_note_spec.js to Vue test utils (CE backport) --- app/assets/javascripts/notes/components/note_actions.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index cad0d382fa2..91b9e5de374 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -135,6 +135,7 @@ export default { {{ accessLevel }}
- + +
@@ -79,7 +123,7 @@ export default { pointer-events: none; } -.tree-list-icon { +.tree-list-icon:not(button) { pointer-events: none; } diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 5cf7fa3422d..46a44841c31 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -16,6 +16,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] + before_action only: [:show] do + push_frontend_feature_flag(:diff_tree_filtering, default_enabled: true) + end + def index @merge_requests = @issuables diff --git a/locale/gitlab.pot b/locale/gitlab.pot index df4975877df..0ca7a1427ef 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4494,6 +4494,9 @@ msgstr "" msgid "MergeRequest| %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}" msgstr "" +msgid "MergeRequest|Filter files" +msgstr "" + msgid "MergeRequest|No files found" msgstr "" -- cgit v1.2.3 From 3c79fded342bddf7048e0776d9f07545d71c535a Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Fri, 8 Feb 2019 15:13:01 +0000 Subject: Mention when the Registry API was introduced --- doc/api/container_registry.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/doc/api/container_registry.md b/doc/api/container_registry.md index b70854103e8..c77ed39e4dc 100644 --- a/doc/api/container_registry.md +++ b/doc/api/container_registry.md @@ -1,5 +1,7 @@ # Container Registry API +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/55978) in GitLab 11.8. + This is the API docs of the [GitLab Container Registry](../user/project/container_registry.md). ## List registry repositories @@ -42,7 +44,7 @@ Example response: ## Delete registry repository -Get a list of repository commits in a project. +Delete a repository in registry. This operation is executed asynchronously and might take some time to get executed. -- cgit v1.2.3 From 81b875f0d034e10d0cb0225cd99535c936b2be68 Mon Sep 17 00:00:00 2001 From: Jan Provaznik Date: Fri, 8 Feb 2019 16:27:56 +0100 Subject: Added tests for merge_request_status partial --- .../issues/_merge_requests_status.html.haml_spec.rb | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 spec/views/projects/issues/_merge_requests_status.html.haml_spec.rb diff --git a/spec/views/projects/issues/_merge_requests_status.html.haml_spec.rb b/spec/views/projects/issues/_merge_requests_status.html.haml_spec.rb new file mode 100644 index 00000000000..ce861567826 --- /dev/null +++ b/spec/views/projects/issues/_merge_requests_status.html.haml_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe 'projects/issues/_merge_requests_status.html.haml' do + it 'shows date of status change in tooltip' do + merge_request = create(:merge_request, created_at: 1.month.ago) + + render partial: 'projects/issues/merge_requests_status', + locals: { merge_request: merge_request, css_class: '' } + + expect(rendered).to match("Opened.*about 1 month ago") + end + + it 'shows only status in tooltip if date is not set' do + merge_request = create(:merge_request, created_at: 1.month.ago, state: :closed) + + render partial: 'projects/issues/merge_requests_status', + locals: { merge_request: merge_request, css_class: '' } + + expect(rendered).to match("Closed") + end +end -- cgit v1.2.3 From 4dc05b8f44886642893b499734d5c4df25ab195d Mon Sep 17 00:00:00 2001 From: Andrew Fontaine Date: Fri, 8 Feb 2019 16:09:24 +0000 Subject: Update `chart.js` to 2.7.2 --- app/assets/javascripts/lib/utils/chart_utils.js | 83 ++++++++++++++++++++++ .../pages/projects/graphs/charts/index.js | 73 ++++++++++--------- .../pages/projects/pipelines/charts/index.js | 81 ++++++++++++--------- app/views/projects/graphs/charts.html.haml | 12 ++-- .../projects/pipelines/charts/_pipeline_times.haml | 6 +- .../projects/pipelines/charts/_pipelines.haml | 9 ++- package.json | 2 +- yarn.lock | 41 ++++++++--- 8 files changed, 219 insertions(+), 88 deletions(-) create mode 100644 app/assets/javascripts/lib/utils/chart_utils.js diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js new file mode 100644 index 00000000000..0f78756aac8 --- /dev/null +++ b/app/assets/javascripts/lib/utils/chart_utils.js @@ -0,0 +1,83 @@ +const commonTooltips = () => ({ + mode: 'x', + intersect: false, +}); + +const adjustedFontScale = () => ({ + fontSize: 8, +}); + +const yAxesConfig = (shouldAdjustFontSize = false) => ({ + yAxes: [ + { + ticks: { + beginAtZero: true, + ...(shouldAdjustFontSize ? adjustedFontScale() : {}), + }, + }, + ], +}); + +const xAxesConfig = (shouldAdjustFontSize = false) => ({ + xAxes: [ + { + ticks: { + ...(shouldAdjustFontSize ? adjustedFontScale() : {}), + }, + }, + ], +}); + +const commonChartOptions = () => ({ + responsive: true, + maintainAspectRatio: false, + legend: false, +}); + +export const barChartOptions = shouldAdjustFontSize => ({ + ...commonChartOptions(), + scales: { + ...yAxesConfig(shouldAdjustFontSize), + ...xAxesConfig(shouldAdjustFontSize), + }, + tooltips: { + ...commonTooltips(), + displayColors: false, + callbacks: { + title() { + return ''; + }, + label({ xLabel, yLabel }) { + return `${xLabel}: ${yLabel}`; + }, + }, + }, +}); + +export const pieChartOptions = commonChartOptions; + +export const lineChartOptions = ({ width, numberOfPoints, shouldAdjustFontSize }) => ({ + ...commonChartOptions(), + scales: { + ...yAxesConfig(shouldAdjustFontSize), + ...xAxesConfig(shouldAdjustFontSize), + }, + elements: { + point: { + hitRadius: width / (numberOfPoints * 2), + }, + }, + tooltips: { + ...commonTooltips(), + caretSize: 0, + multiKeyBackground: 'rgba(0,0,0,0)', + callbacks: { + labelColor({ datasetIndex }, { config }) { + return { + backgroundColor: config.data.datasets[datasetIndex].backgroundColor, + borderColor: 'rgba(0,0,0,0)', + }; + }, + }, + }, +}); diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index 26d7fa7371d..314519ee442 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -2,42 +2,44 @@ import $ from 'jquery'; import Chart from 'chart.js'; import _ from 'underscore'; +import { barChartOptions, pieChartOptions } from '~/lib/utils/chart_utils'; + document.addEventListener('DOMContentLoaded', () => { const projectChartData = JSON.parse(document.getElementById('projectChartData').innerHTML); - const responsiveChart = (selector, data) => { - const options = { - scaleOverlay: true, - responsive: true, - pointHitDetectionRadius: 2, - maintainAspectRatio: false, - }; + const barChart = (selector, data) => { // get selector by context const ctx = selector.get(0).getContext('2d'); // pointing parent container to make chart.js inherit its width const container = $(selector).parent(); - const generateChart = () => { - selector.attr('width', $(container).width()); - if (window.innerWidth < 768) { - // Scale fonts if window width lower than 768px (iPad portrait) - options.scaleFontSize = 8; - } - return new Chart(ctx).Bar(data, options); - }; - // enabling auto-resizing - $(window).resize(generateChart); - return generateChart(); + selector.attr('width', $(container).width()); + + // Scale fonts if window width lower than 768px (iPad portrait) + const shouldAdjustFontSize = window.innerWidth < 768; + return new Chart(ctx, { + type: 'bar', + data, + options: barChartOptions(shouldAdjustFontSize), + }); + }; + + const pieChart = (context, data) => { + const options = pieChartOptions(); + + return new Chart(context, { + type: 'pie', + data, + options, + }); }; const chartData = data => ({ labels: Object.keys(data), datasets: [ { - fillColor: 'rgba(220,220,220,0.5)', - strokeColor: 'rgba(220,220,220,1)', - barStrokeWidth: 1, - barValueSpacing: 1, - barDatasetSpacing: 1, + backgroundColor: 'rgba(220,220,220,0.5)', + borderColor: 'rgba(220,220,220,1)', + borderWidth: 1, data: _.values(data), }, ], @@ -59,24 +61,27 @@ document.addEventListener('DOMContentLoaded', () => { }; const hourData = chartData(projectChartData.hour); - responsiveChart($('#hour-chart'), hourData); + barChart($('#hour-chart'), hourData); const weekDays = reorderWeekDays(projectChartData.weekDays, gon.first_day_of_week); const dayData = chartData(weekDays); - responsiveChart($('#weekday-chart'), dayData); + barChart($('#weekday-chart'), dayData); const monthData = chartData(projectChartData.month); - responsiveChart($('#month-chart'), monthData); + barChart($('#month-chart'), monthData); - const data = projectChartData.languages; + const data = { + datasets: [ + { + data: projectChartData.languages.map(x => x.value), + backgroundColor: projectChartData.languages.map(x => x.color), + hoverBackgroundColor: projectChartData.languages.map(x => x.highlight), + }, + ], + labels: projectChartData.languages.map(x => x.label), + }; const ctx = $('#languages-chart') .get(0) .getContext('2d'); - const options = { - scaleOverlay: true, - responsive: true, - maintainAspectRatio: false, - }; - - new Chart(ctx).Pie(data, options); + pieChart(ctx, data); }); diff --git a/app/assets/javascripts/pages/projects/pipelines/charts/index.js b/app/assets/javascripts/pages/projects/pipelines/charts/index.js index 48353f3b4ef..9fa580d2ba9 100644 --- a/app/assets/javascripts/pages/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/charts/index.js @@ -1,29 +1,31 @@ import $ from 'jquery'; import Chart from 'chart.js'; -const options = { - scaleOverlay: true, - responsive: true, - maintainAspectRatio: false, -}; +import { barChartOptions, lineChartOptions } from '~/lib/utils/chart_utils'; + +const SUCCESS_LINE_COLOR = '#1aaa55'; + +const TOTAL_LINE_COLOR = '#707070'; -const buildChart = chartScope => { +const buildChart = (chartScope, shouldAdjustFontSize) => { const data = { labels: chartScope.labels, datasets: [ { - fillColor: '#707070', - strokeColor: '#707070', - pointColor: '#707070', - pointStrokeColor: '#EEE', - data: chartScope.totalValues, + backgroundColor: SUCCESS_LINE_COLOR, + borderColor: SUCCESS_LINE_COLOR, + pointBackgroundColor: SUCCESS_LINE_COLOR, + pointBorderColor: '#fff', + data: chartScope.successValues, + fill: 'origin', }, { - fillColor: '#1aaa55', - strokeColor: '#1aaa55', - pointColor: '#1aaa55', - pointStrokeColor: '#fff', - data: chartScope.successValues, + backgroundColor: TOTAL_LINE_COLOR, + borderColor: TOTAL_LINE_COLOR, + pointBackgroundColor: TOTAL_LINE_COLOR, + pointBorderColor: '#EEE', + data: chartScope.totalValues, + fill: '-1', }, ], }; @@ -31,36 +33,51 @@ const buildChart = chartScope => { .get(0) .getContext('2d'); - new Chart(ctx).Line(data, options); + return new Chart(ctx, { + type: 'line', + data, + options: lineChartOptions({ + width: ctx.canvas.width, + numberOfPoints: chartScope.totalValues.length, + shouldAdjustFontSize, + }), + }); }; -document.addEventListener('DOMContentLoaded', () => { - const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML); - const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML); +const buildBarChart = (chartTimesData, shouldAdjustFontSize) => { const data = { labels: chartTimesData.labels, datasets: [ { - fillColor: 'rgba(220,220,220,0.5)', - strokeColor: 'rgba(220,220,220,1)', - barStrokeWidth: 1, + backgroundColor: 'rgba(220,220,220,0.5)', + borderColor: 'rgba(220,220,220,1)', + borderWidth: 1, barValueSpacing: 1, barDatasetSpacing: 1, data: chartTimesData.values, }, ], }; - - if (window.innerWidth < 768) { - // Scale fonts if window width lower than 768px (iPad portrait) - options.scaleFontSize = 8; - } - - new Chart( + return new Chart( $('#build_timesChart') .get(0) .getContext('2d'), - ).Bar(data, options); + { + type: 'bar', + data, + options: barChartOptions(shouldAdjustFontSize), + }, + ); +}; + +document.addEventListener('DOMContentLoaded', () => { + const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML); + const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML); + + // Scale fonts if window width lower than 768px (iPad portrait) + const shouldAdjustFontSize = window.innerWidth < 768; + + buildBarChart(chartTimesData, shouldAdjustFontSize); - chartsData.forEach(scope => buildChart(scope)); + chartsData.forEach(scope => buildChart(scope, shouldAdjustFontSize)); }); diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index b0e22a35fe1..60160f521ad 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -55,23 +55,23 @@ #{@commits_graph.authors} = (_("Authors: %{authors}") % { authors: "#{authors}" }).html_safe .col-md-6 + %p.slead + = _("Commits per day of month") %div - %p.slead - = _("Commits per day of month") %canvas#month-chart .row .col-md-6 .col-md-6 + %p.slead + = _("Commits per weekday") %div - %p.slead - = _("Commits per weekday") %canvas#weekday-chart .row .col-md-6 .col-md-6 + %p.slead + = _("Commits per day hour (UTC)") %div - %p.slead - = _("Commits per day hour (UTC)") %canvas#hour-chart -# haml-lint:disable InlineJavaScript diff --git a/app/views/projects/pipelines/charts/_pipeline_times.haml b/app/views/projects/pipelines/charts/_pipeline_times.haml index c23fe6ff170..c0ac79ed5f8 100644 --- a/app/views/projects/pipelines/charts/_pipeline_times.haml +++ b/app/views/projects/pipelines/charts/_pipeline_times.haml @@ -1,7 +1,7 @@ -%div - %p.light - = _("Commit duration in minutes for last 30 commits") +%p.light + = _("Commit duration in minutes for last 30 commits") +%div %canvas#build_timesChart{ height: 200 } -# haml-lint:disable InlineJavaScript diff --git a/app/views/projects/pipelines/charts/_pipelines.haml b/app/views/projects/pipelines/charts/_pipelines.haml index 14b3d47a9c2..47f1f074210 100644 --- a/app/views/projects/pipelines/charts/_pipelines.haml +++ b/app/views/projects/pipelines/charts/_pipelines.haml @@ -13,18 +13,21 @@ %p.light = _("Pipelines for last week") (#{date_from_to(Date.today - 7.days, Date.today)}) - %canvas#weekChart{ height: 200 } + %div + %canvas#weekChart{ height: 200 } .prepend-top-default %p.light = _("Pipelines for last month") (#{date_from_to(Date.today - 30.days, Date.today)}) - %canvas#monthChart{ height: 200 } + %div + %canvas#monthChart{ height: 200 } .prepend-top-default %p.light = _("Pipelines for last year") - %canvas#yearChart.padded{ height: 250 } + %div + %canvas#yearChart.padded{ height: 250 } -# haml-lint:disable InlineJavaScript %script#pipelinesChartsData{ type: "application/json" } diff --git a/package.json b/package.json index 7110baef8b3..c88ade87af6 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "bootstrap": "4.1.3", "brace-expansion": "^1.1.8", "cache-loader": "^2.0.1", - "chart.js": "1.0.2", + "chart.js": "2.7.2", "classlist-polyfill": "^1.2.0", "clipboard": "^1.7.1", "codesandbox-api": "^0.0.20", diff --git a/yarn.lock b/yarn.lock index 9d0ba6640f0..8bff9c59113 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2122,10 +2122,28 @@ chardet@^0.5.0: resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc= -chart.js@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-1.0.2.tgz#ad57d2229cfd8ccf5955147e8121b4911e69dfe7" - integrity sha1-rVfSIpz9jM9ZVRR+gSG0kR5p3+c= +chart.js@2.7.2: + version "2.7.2" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.7.2.tgz#3c9fde4dc5b95608211bdefeda7e5d33dffa5714" + integrity sha512-90wl3V9xRZ8tnMvMlpcW+0Yg13BelsGS9P9t0ClaDxv/hdypHDr/YAGf+728m11P5ljwyB0ZHfPKCapZFqSqYA== + dependencies: + chartjs-color "^2.1.0" + moment "^2.10.2" + +chartjs-color-string@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.5.0.tgz#8d3752d8581d86687c35bfe2cb80ac5213ceb8c1" + integrity sha512-amWNvCOXlOUYxZVDSa0YOab5K/lmEhbFNKI55PWc4mlv28BDzA7zaoQTGxSBgJMHIW+hGX8YUrvw/FH4LyhwSQ== + dependencies: + color-name "^1.0.0" + +chartjs-color@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.2.0.tgz#84a2fb755787ed85c39dd6dd8c7b1d88429baeae" + integrity sha1-hKL7dVeH7YXDndbdjHsdiEKbrq4= + dependencies: + chartjs-color-string "^0.5.0" + color-convert "^0.5.3" check-types@^7.3.0: version "7.3.0" @@ -2296,6 +2314,11 @@ collection-visit@^1.0.0: map-visit "^1.0.0" object-visit "^1.0.0" +color-convert@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd" + integrity sha1-vbbGnOZg+t/+CwAHzER+G59ygr0= + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -2303,7 +2326,7 @@ color-convert@^1.9.0: dependencies: color-name "1.1.3" -color-name@1.1.3: +color-name@1.1.3, color-name@^1.0.0: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= @@ -6985,10 +7008,10 @@ mkdirp@0.5.x, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: dependencies: minimist "0.0.8" -moment@2.x, moment@^2.21.0: - version "2.22.2" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" - integrity sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y= +moment@2.x, moment@^2.10.2, moment@^2.21.0: + version "2.23.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.23.0.tgz#759ea491ac97d54bac5ad776996e2a58cc1bc225" + integrity sha512-3IE39bHVqFbWWaPOMHZF98Q9c3LDKGTmypMiTM2QygGXXElkFWIH7GxfmlwmY2vwa+wmNsoYZmG2iusf1ZjJoA== monaco-editor-webpack-plugin@^1.7.0: version "1.7.0" -- cgit v1.2.3 From 13a64a5c5c991e5abfccc1529382e7572d5cdbc8 Mon Sep 17 00:00:00 2001 From: Mayra Cabrera Date: Fri, 8 Feb 2019 10:10:46 -0600 Subject: Removes milestone from cluster base domain 12.0 milestone was removed from cluster base domain on AutoDevOps documentation to avoid making fake promises. --- doc/topics/autodevops/index.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 463bdd59282..91be3e3d45d 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -128,7 +128,7 @@ Auto Deploy, and Auto Monitoring will be silently skipped. NOTE: **Note** `AUTO_DEVOPS_DOMAIN` environment variable is deprecated and -[is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959) in GitLab 12.0. +[is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959). The Auto DevOps base domain is required if you want to make use of [Auto Review Apps](#auto-review-apps) and [Auto Deploy](#auto-deploy). It can be defined @@ -211,8 +211,7 @@ other environments. NOTE: **Note:** From GitLab 11.8, `KUBE_INGRESS_BASE_DOMAIN` replaces `AUTO_DEVOPS_DOMAIN`. -`AUTO_DEVOPS_DOMAIN` [is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959) -in GitLab 12.0. +`AUTO_DEVOPS_DOMAIN` [is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959). ## Enabling/Disabling Auto DevOps @@ -685,7 +684,7 @@ also be customized, and you can easily use a [custom buildpack](#custom-buildpac | **Variable** | **Description** | | ------------ | --------------- | -| `AUTO_DEVOPS_DOMAIN` | The [Auto DevOps domain](#auto-devops-domain). By default, set automatically by the [Auto DevOps setting](#enabling-auto-devops). This variable is deprecated and [is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959) in GitLab 12.0. Use `KUBE_INGRESS_BASE_DOMAIN` instead. | +| `AUTO_DEVOPS_DOMAIN` | The [Auto DevOps domain](#auto-devops-domain). By default, set automatically by the [Auto DevOps setting](#enabling-auto-devops). This variable is deprecated and [is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959). Use `KUBE_INGRESS_BASE_DOMAIN` instead. | | `AUTO_DEVOPS_CHART` | The Helm Chart used to deploy your apps; defaults to the one [provided by GitLab](https://gitlab.com/charts/auto-deploy-app). | | `AUTO_DEVOPS_CHART_REPOSITORY` | The Helm Chart repository used to search for charts; defaults to `https://charts.gitlab.io`. | | `REPLICAS` | The number of replicas to deploy; defaults to 1. | -- cgit v1.2.3 From 20ce370f4bcacaf86a0178505eb183e3d59e2406 Mon Sep 17 00:00:00 2001 From: Constance Okoghenun Date: Fri, 8 Feb 2019 19:10:58 +0100 Subject: Improved readability of issues related MR status spec --- spec/views/projects/issues/_merge_requests_status.html.haml_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/views/projects/issues/_merge_requests_status.html.haml_spec.rb b/spec/views/projects/issues/_merge_requests_status.html.haml_spec.rb index ce861567826..02c225292ce 100644 --- a/spec/views/projects/issues/_merge_requests_status.html.haml_spec.rb +++ b/spec/views/projects/issues/_merge_requests_status.html.haml_spec.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true require 'spec_helper' describe 'projects/issues/_merge_requests_status.html.haml' do @@ -11,7 +12,7 @@ describe 'projects/issues/_merge_requests_status.html.haml' do end it 'shows only status in tooltip if date is not set' do - merge_request = create(:merge_request, created_at: 1.month.ago, state: :closed) + merge_request = create(:merge_request, state: :closed) render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: '' } -- cgit v1.2.3 From a5378665a1dc0b9c8dc3a4fa279a0eb78aac5aac Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Tue, 9 Oct 2018 11:33:49 +0100 Subject: Remove HipChat integration from GitLab --- .rubocop_todo.yml | 1 - Gemfile | 3 - Gemfile.lock | 4 - app/models/project.rb | 1 - app/models/project_services/hipchat_service.rb | 311 ---------------- app/models/service.rb | 1 - changelogs/unreleased/52424-goodbye-hipchat.yml | 5 + config/initializers/hipchat_client_patch.rb | 14 - .../20190107151020_add_services_type_index.rb | 20 + .../20190107151029_remove_hipchat_services.rb | 16 + db/schema.rb | 1 + doc/api/services.md | 39 -- doc/integration/README.md | 4 +- doc/project_services/hipchat.md | 1 - doc/university/glossary/README.md | 2 +- doc/user/index.md | 4 +- doc/user/project/integrations/hipchat.md | 53 --- doc/user/project/integrations/project_services.md | 1 - lib/api/services.rb | 39 -- spec/factories/services.rb | 6 - .../projects/services/disable_triggers_spec.rb | 5 +- .../services/user_activates_hipchat_spec.rb | 38 -- .../projects/services/user_views_services_spec.rb | 3 +- spec/lib/gitlab/import_export/all_models.yml | 1 - spec/lib/gitlab/import_export/project.json | 22 -- .../project_services/hipchat_service_spec.rb | 408 --------------------- spec/models/project_spec.rb | 1 - vendor/licenses.csv | 1 - 28 files changed, 53 insertions(+), 952 deletions(-) delete mode 100644 app/models/project_services/hipchat_service.rb create mode 100644 changelogs/unreleased/52424-goodbye-hipchat.yml delete mode 100644 config/initializers/hipchat_client_patch.rb create mode 100644 db/migrate/20190107151020_add_services_type_index.rb create mode 100644 db/migrate/20190107151029_remove_hipchat_services.rb delete mode 100644 doc/project_services/hipchat.md delete mode 100644 doc/user/project/integrations/hipchat.md delete mode 100644 spec/features/projects/services/user_activates_hipchat_spec.rb delete mode 100644 spec/models/project_services/hipchat_service_spec.rb diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 77ad4753c84..97e39ce99cb 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -268,7 +268,6 @@ Rails/Presence: - 'app/models/clusters/platforms/kubernetes.rb' - 'app/models/concerns/mentionable.rb' - 'app/models/concerns/token_authenticatable.rb' - - 'app/models/project_services/hipchat_service.rb' - 'app/models/project_services/irker_service.rb' - 'app/models/project_services/jira_service.rb' - 'app/models/project_services/kubernetes_service.rb' diff --git a/Gemfile b/Gemfile index a3b01c275ce..3ff78312559 100644 --- a/Gemfile +++ b/Gemfile @@ -202,9 +202,6 @@ gem 'connection_pool', '~> 2.0' # Discord integration gem 'discordrb-webhooks-blackst0ne', '~> 3.3', require: false -# HipChat integration -gem 'hipchat', '~> 1.5.0' - # JIRA integration gem 'jira-ruby', '~> 1.4' diff --git a/Gemfile.lock b/Gemfile.lock index 0b2bd2c96bd..9aea8919287 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -363,9 +363,6 @@ GEM hashie (>= 3.0) health_check (2.6.0) rails (>= 4.0) - hipchat (1.5.2) - httparty - mimemagic html-pipeline (2.8.4) activesupport (>= 2) nokogiri (>= 1.4) @@ -1043,7 +1040,6 @@ DEPENDENCIES hangouts-chat (~> 0.0.5) hashie-forbidden_attributes health_check (~> 2.6.0) - hipchat (~> 1.5.0) html-pipeline (~> 2.8) html2text httparty (~> 0.13.3) diff --git a/app/models/project.rb b/app/models/project.rb index 8f746f6e094..58df4019450 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -147,7 +147,6 @@ class Project < ActiveRecord::Base has_one :pipelines_email_service has_one :irker_service has_one :pivotaltracker_service - has_one :hipchat_service has_one :flowdock_service has_one :assembla_service has_one :asana_service diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb deleted file mode 100644 index a69b7b4c4b6..00000000000 --- a/app/models/project_services/hipchat_service.rb +++ /dev/null @@ -1,311 +0,0 @@ -# frozen_string_literal: true - -class HipchatService < Service - include ActionView::Helpers::SanitizeHelper - - MAX_COMMITS = 3 - HIPCHAT_ALLOWED_TAGS = %w[ - a b i strong em br img pre code - table th tr td caption colgroup col thead tbody tfoot - ul ol li dl dt dd - ].freeze - - prop_accessor :token, :room, :server, :color, :api_version - boolean_accessor :notify_only_broken_pipelines, :notify - validates :token, presence: true, if: :activated? - - def initialize_properties - if properties.nil? - self.properties = {} - self.notify_only_broken_pipelines = true - end - end - - def title - 'HipChat' - end - - def description - 'Private group chat and IM' - end - - def self.to_param - 'hipchat' - end - - def fields - [ - { type: 'text', name: 'token', placeholder: 'Room token', required: true }, - { type: 'text', name: 'room', placeholder: 'Room name or ID' }, - { type: 'checkbox', name: 'notify' }, - { type: 'select', name: 'color', choices: %w(yellow red green purple gray random) }, - { type: 'text', name: 'api_version', - placeholder: 'Leave blank for default (v2)' }, - { type: 'text', name: 'server', - placeholder: 'Leave blank for default. https://hipchat.example.com' }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' } - ] - end - - def self.supported_events - %w(push issue confidential_issue merge_request note confidential_note tag_push pipeline) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - message = create_message(data) - return unless message.present? - - gate[room].send('GitLab', message, message_options(data)) # rubocop:disable GitlabSecurity/PublicSend - end - - def test(data) - begin - result = execute(data) - rescue StandardError => error - return { success: false, result: error } - end - - { success: true, result: result } - end - - private - - def gate - options = { api_version: api_version.present? ? api_version : 'v2' } - options[:server_url] = server unless server.blank? - @gate ||= HipChat::Client.new(token, options) - end - - def message_options(data = nil) - { notify: notify.present? && Gitlab::Utils.to_boolean(notify), color: message_color(data) } - end - - def create_message(data) - object_kind = data[:object_kind] - - case object_kind - when "push", "tag_push" - create_push_message(data) - when "issue" - create_issue_message(data) unless update?(data) - when "merge_request" - create_merge_request_message(data) unless update?(data) - when "note" - create_note_message(data) - when "pipeline" - create_pipeline_message(data) if should_pipeline_be_notified?(data) - end - end - - def render_line(text) - markdown(text.lines.first.chomp, pipeline: :single_line) if text - end - - def create_push_message(push) - ref_type = Gitlab::Git.tag_ref?(push[:ref]) ? 'tag' : 'branch' - ref = Gitlab::Git.ref_name(push[:ref]) - - before = push[:before] - after = push[:after] - - message = [] - message << "#{push[:user_name]} " - - if Gitlab::Git.blank_ref?(before) - message << "pushed new #{ref_type} #{ref}"\ - " to #{project_link}\n" - elsif Gitlab::Git.blank_ref?(after) - message << "removed #{ref_type} #{ref} from #{project_name} \n" - else - message << "pushed to #{ref_type} #{ref} " - message << "of #{project.full_name.gsub!(/\s/, '')} " - message << "(Compare changes)" - - push[:commits].take(MAX_COMMITS).each do |commit| - message << "
- #{render_line(commit[:message])} (#{commit[:id][0..5]})" - end - - if push[:commits].count > MAX_COMMITS - message << "
... #{push[:commits].count - MAX_COMMITS} more commits" - end - end - - message.join - end - - def markdown(text, options = {}) - return "" unless text - - context = { - project: project, - pipeline: :email - } - - Banzai.render(text, context) - - context.merge!(options) - - html = Banzai.render_and_post_process(text, context) - sanitized_html = sanitize(html, tags: HIPCHAT_ALLOWED_TAGS, attributes: %w[href title alt]) - - sanitized_html.truncate(200, separator: ' ', omission: '...') - end - - def create_issue_message(data) - user_name = data[:user][:name] - - obj_attr = data[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) - title = render_line(obj_attr[:title]) - state = obj_attr[:state] - issue_iid = obj_attr[:iid] - issue_url = obj_attr[:url] - description = obj_attr[:description] - - issue_link = "issue ##{issue_iid}" - - message = ["#{user_name} #{state} #{issue_link} in #{project_link}: #{title}"] - message << "
#{markdown(description)}
" - - message.join - end - - def create_merge_request_message(data) - user_name = data[:user][:name] - - obj_attr = data[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) - merge_request_id = obj_attr[:iid] - state = obj_attr[:state] - description = obj_attr[:description] - title = render_line(obj_attr[:title]) - - merge_request_url = "#{project_url}/merge_requests/#{merge_request_id}" - merge_request_link = "merge request !#{merge_request_id}" - message = ["#{user_name} #{state} #{merge_request_link} in " \ - "#{project_link}: #{title}"] - - message << "
#{markdown(description)}
" - message.join - end - - def format_title(title) - "#{render_line(title)}" - end - - def create_note_message(data) - data = HashWithIndifferentAccess.new(data) - user_name = data[:user][:name] - - obj_attr = HashWithIndifferentAccess.new(data[:object_attributes]) - note = obj_attr[:note] - note_url = obj_attr[:url] - noteable_type = obj_attr[:noteable_type] - commit_id = nil - - case noteable_type - when "Commit" - commit_attr = HashWithIndifferentAccess.new(data[:commit]) - commit_id = commit_attr[:id] - subject_desc = commit_id - subject_desc = Commit.truncate_sha(subject_desc) - subject_type = "commit" - title = format_title(commit_attr[:message]) - when "Issue" - subj_attr = HashWithIndifferentAccess.new(data[:issue]) - subject_id = subj_attr[:iid] - subject_desc = "##{subject_id}" - subject_type = "issue" - title = format_title(subj_attr[:title]) - when "MergeRequest" - subj_attr = HashWithIndifferentAccess.new(data[:merge_request]) - subject_id = subj_attr[:iid] - subject_desc = "!#{subject_id}" - subject_type = "merge request" - title = format_title(subj_attr[:title]) - when "Snippet" - subj_attr = HashWithIndifferentAccess.new(data[:snippet]) - subject_id = subj_attr[:id] - subject_desc = "##{subject_id}" - subject_type = "snippet" - title = format_title(subj_attr[:title]) - end - - subject_html = "#{subject_type} #{subject_desc}" - message = ["#{user_name} commented on #{subject_html} in #{project_link}: "] - message << title - - message << "
#{markdown(note, ref: commit_id)}
" - message.join - end - - def create_pipeline_message(data) - pipeline_attributes = data[:object_attributes] - pipeline_id = pipeline_attributes[:id] - ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' - ref = pipeline_attributes[:ref] - user_name = (data[:user] && data[:user][:name]) || 'API' - status = pipeline_attributes[:status] - duration = pipeline_attributes[:duration] - - branch_link = "#{ref}" - pipeline_url = "##{pipeline_id}" - - "#{project_link}: Pipeline #{pipeline_url} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status(status)} in #{duration} second(s)" - end - - def message_color(data) - pipeline_status_color(data) || color || 'yellow' - end - - def pipeline_status_color(data) - return unless data && data[:object_kind] == 'pipeline' - - case data[:object_attributes][:status] - when 'success' - 'green' - else - 'red' - end - end - - def project_name - project.full_name.gsub(/\s/, '') - end - - def project_url - project.web_url - end - - def project_link - "#{project_name}" - end - - def update?(data) - data[:object_attributes][:action] == 'update' - end - - def humanized_status(status) - case status - when 'success' - 'passed' - else - status - end - end - - def should_pipeline_be_notified?(data) - case data[:object_attributes][:status] - when 'success' - !notify_only_broken_pipelines? - when 'failed' - true - else - false - end - end -end diff --git a/app/models/service.rb b/app/models/service.rb index 9dcb0aab0a3..3461e0bfe70 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -255,7 +255,6 @@ class Service < ActiveRecord::Base external_wiki flowdock hangouts_chat - hipchat irker jira kubernetes diff --git a/changelogs/unreleased/52424-goodbye-hipchat.yml b/changelogs/unreleased/52424-goodbye-hipchat.yml new file mode 100644 index 00000000000..26dc904af5f --- /dev/null +++ b/changelogs/unreleased/52424-goodbye-hipchat.yml @@ -0,0 +1,5 @@ +--- +title: Remove HipChat integration from GitLab +merge_request: 22223 +author: +type: removed diff --git a/config/initializers/hipchat_client_patch.rb b/config/initializers/hipchat_client_patch.rb deleted file mode 100644 index aec265312bb..00000000000 --- a/config/initializers/hipchat_client_patch.rb +++ /dev/null @@ -1,14 +0,0 @@ -# This monkey patches the HTTParty used in https://github.com/hipchat/hipchat-rb. -module HipChat - class Client - connection_adapter ::Gitlab::ProxyHTTPConnectionAdapter - end - - class Room - connection_adapter ::Gitlab::ProxyHTTPConnectionAdapter - end - - class User - connection_adapter ::Gitlab::ProxyHTTPConnectionAdapter - end -end diff --git a/db/migrate/20190107151020_add_services_type_index.rb b/db/migrate/20190107151020_add_services_type_index.rb new file mode 100644 index 00000000000..26b5bd58750 --- /dev/null +++ b/db/migrate/20190107151020_add_services_type_index.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddServicesTypeIndex < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :services, :type unless index_exists?(:services, :type) + end + + def down + remove_concurrent_index :services, :type if index_exists?(:services, :type) + end +end diff --git a/db/migrate/20190107151029_remove_hipchat_services.rb b/db/migrate/20190107151029_remove_hipchat_services.rb new file mode 100644 index 00000000000..4741ec88907 --- /dev/null +++ b/db/migrate/20190107151029_remove_hipchat_services.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveHipchatServices < ActiveRecord::Migration[5.0] + DOWNTIME = false + + def up + execute "DELETE FROM services WHERE type = 'HipchatService'" + end + + def down + # no-op + end +end diff --git a/db/schema.rb b/db/schema.rb index 023eee5f33e..0f7e9ad4996 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1936,6 +1936,7 @@ ActiveRecord::Schema.define(version: 20190204115450) do t.boolean "confidential_note_events", default: true t.index ["project_id"], name: "index_services_on_project_id", using: :btree t.index ["template"], name: "index_services_on_template", using: :btree + t.index ["type"], name: "index_services_on_type", using: :btree end create_table "shards", force: :cascade do |t| diff --git a/doc/api/services.md b/doc/api/services.md index 868bcdd07fc..2a8ce39e570 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -449,45 +449,6 @@ Get Hangouts Chat service settings for a project. GET /projects/:id/services/hangouts_chat ``` -## HipChat - -Private group chat and IM - -### Create/Edit HipChat service - -Set HipChat service for a project. - -``` -PUT /projects/:id/services/hipchat -``` - -Parameters: - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `token` | string | true | Room token | -| `color` | string | false | The room color | -| `notify` | boolean | false | Enable notifications | -| `room` | string | false |Room name or ID | -| `api_version` | string | false | Leave blank for default (v2) | -| `server` | string | false | Leave blank for default. For example, `https://hipchat.example.com`. | - -### Delete HipChat service - -Delete HipChat service for a project. - -``` -DELETE /projects/:id/services/hipchat -``` - -### Get HipChat service settings - -Get HipChat service settings for a project. - -``` -GET /projects/:id/services/hipchat -``` - ## Irker (IRC gateway) Send IRC messages, on update, to a list of recipients through an Irker gateway. diff --git a/doc/integration/README.md b/doc/integration/README.md index 8a93d4cb84b..7941cb42667 100644 --- a/doc/integration/README.md +++ b/doc/integration/README.md @@ -30,8 +30,8 @@ Bitbucket.org account ## Project services -Integration with services such as Campfire, Flowdock, HipChat, -Pivotal Tracker, and Slack are available in the form of a [Project Service][]. +Integration with services such as Campfire, Flowdock, Pivotal Tracker, and Slack +are available in the form of a [Project Service][]. [Project Service]: ../user/project/integrations/project_services.md diff --git a/doc/project_services/hipchat.md b/doc/project_services/hipchat.md deleted file mode 100644 index 4ae9f6c6b2e..00000000000 --- a/doc/project_services/hipchat.md +++ /dev/null @@ -1 +0,0 @@ -This document was moved to [user/project/integrations/hipchat.md](../user/project/integrations/hipchat.md). diff --git a/doc/university/glossary/README.md b/doc/university/glossary/README.md index 404a686f1cf..cf148a424db 100644 --- a/doc/university/glossary/README.md +++ b/doc/university/glossary/README.md @@ -41,7 +41,7 @@ Objects (usually binary and large) created by a build process. These can include ### Atlassian -A [company](https://www.atlassian.com) that develops software products for developers and project managers including Bitbucket, Jira, Hipchat, Confluence, Bamboo. +A [company](https://www.atlassian.com) that develops software products for developers and project managers including Bitbucket, Jira, Confluence, Bamboo. ### Audit Log diff --git a/doc/user/index.md b/doc/user/index.md index fc68404d0c2..f34b5ad6718 100644 --- a/doc/user/index.md +++ b/doc/user/index.md @@ -71,7 +71,9 @@ and [Multiple Issue Boards](https://docs.gitlab.com/ee/user/project/issue_board. - View of the current health and status of each CI environment running on Kubernetes with [Deploy Boards](https://docs.gitlab.com/ee/user/project/deploy_boards.html) - Leverage your continuous delivery method with [Canary Deployments](https://docs.gitlab.com/ee/user/project/canary_deployments.html) -You can also [integrate](project/integrations/project_services.md) GitLab with numerous third-party applications, such as Mattermost, Microsoft Teams, HipChat, Trello, Slack, Bamboo CI, JIRA, and a lot more. +You can also [integrate](project/integrations/project_services.md) GitLab with +numerous third-party applications, such as Mattermost, Microsoft Teams, Trello, +Slack, Bamboo CI, JIRA, and a lot more. ## Projects diff --git a/doc/user/project/integrations/hipchat.md b/doc/user/project/integrations/hipchat.md deleted file mode 100644 index 0fd847d415f..00000000000 --- a/doc/user/project/integrations/hipchat.md +++ /dev/null @@ -1,53 +0,0 @@ -# Atlassian HipChat - -GitLab provides a way to send HipChat notifications upon a number of events, -such as when a user pushes code, creates a branch or tag, adds a comment, and -creates a merge request. - -## Setup - -GitLab requires the use of a HipChat v2 API token to work. v1 tokens are -not supported at this time. Note the differences between v1 and v2 tokens: - -HipChat v1 API (legacy) supports "API Auth Tokens" in the Group API menu. A v1 -token is allowed to send messages to *any* room. - -HipChat v2 API has tokens that are can be created using the Integrations tab -in the Group or Room admin page. By design, these are lightweight tokens that -allow GitLab to send messages only to *one* room. - -### Complete these steps in HipChat - -1. Go to: -1. Click on "Group Admin" -> "Integrations". -1. Find "Build Your Own!" and click "Create". -1. Select the desired room, name the integration "GitLab", and click "Create". -1. In the "Send messages to this room by posting this URL" column, you should -see a URL in the format: - -``` -https://api.hipchat.com/v2/room//notification?auth_token= -``` - -HipChat is now ready to accept messages from GitLab. Next, set up the HipChat -service in GitLab. - -### Complete these steps in GitLab - -1. Navigate to the project you want to configure for notifications. -1. Navigate to the [Integrations page](project_services.md#accessing-the-project-services) -1. Click "HipChat". -1. Select the "Active" checkbox. -1. Insert the `token` field from the URL into the `Token` field on the Web page. -1. Insert the `room` field from the URL into the `Room` field on the Web page. -1. Save or optionally click "Test Settings". - -## Troubleshooting - -If you do not see notifications, make sure you are using a HipChat v2 API -token, not a v1 token. - -Note that the v2 token is tied to a specific room. If you want to be able to -specify arbitrary rooms, you can create an API token for a specific user in -HipChat under "Account settings" and "API access". Use the `XXX` value under -`auth_token=XXX`. diff --git a/doc/user/project/integrations/project_services.md b/doc/user/project/integrations/project_services.md index be45ce46dfd..cec9018b67f 100644 --- a/doc/user/project/integrations/project_services.md +++ b/doc/user/project/integrations/project_services.md @@ -36,7 +36,6 @@ Click on the service links to see further configuration instructions and details | External Wiki | Replaces the link to the internal wiki with a link to an external wiki | | Flowdock | Flowdock is a collaboration web app for technical teams | | [Hangouts Chat](hangouts_chat.md) | Receive events notifications in Google Hangouts Chat | -| [HipChat](hipchat.md) | Private group chat and IM | | [Irker (IRC gateway)](irker.md) | Send IRC messages, on update, to a list of recipients through an Irker gateway | | [JIRA](jira.md) | JIRA issue tracker | | JetBrains TeamCity CI | A continuous integration and build server | diff --git a/lib/api/services.rb b/lib/api/services.rb index 637b5a8a89a..36bdba2d765 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -370,44 +370,6 @@ module API desc: 'The Hangouts Chat webhook. e.g. https://chat.googleapis.com/v1/spaces…' } ], - 'hipchat' => [ - { - required: true, - name: :token, - type: String, - desc: 'The room token' - }, - { - required: false, - name: :room, - type: String, - desc: 'The room name or ID' - }, - { - required: false, - name: :color, - type: String, - desc: 'The room color' - }, - { - required: false, - name: :notify, - type: Boolean, - desc: 'Enable notifications' - }, - { - required: false, - name: :api_version, - type: String, - desc: 'Leave blank for default (v2)' - }, - { - required: false, - name: :server, - type: String, - desc: 'Leave blank for default. https://hipchat.example.com' - } - ], 'irker' => [ { required: true, @@ -691,7 +653,6 @@ module API ExternalWikiService, FlowdockService, HangoutsChatService, - HipchatService, IrkerService, JiraService, KubernetesService, diff --git a/spec/factories/services.rb b/spec/factories/services.rb index 5be56a49903..841b68c74af 100644 --- a/spec/factories/services.rb +++ b/spec/factories/services.rb @@ -56,10 +56,4 @@ FactoryBot.define do project_key: 'jira-key' ) end - - factory :hipchat_service do - project - type 'HipchatService' - token 'test_token' - end end diff --git a/spec/features/projects/services/disable_triggers_spec.rb b/spec/features/projects/services/disable_triggers_spec.rb index 1a13fe03a67..65b597da269 100644 --- a/spec/features/projects/services/disable_triggers_spec.rb +++ b/spec/features/projects/services/disable_triggers_spec.rb @@ -14,10 +14,11 @@ describe 'Disable individual triggers' do end context 'service has multiple supported events' do - let(:service_name) { 'HipChat' } + let(:service_name) { 'JIRA' } it 'shows trigger checkboxes' do - event_count = HipchatService.supported_events.count + event_count = JiraService.supported_events.count + expect(event_count).to be > 1 expect(page).to have_content "Trigger" expect(page).to have_css(checkbox_selector, count: event_count) diff --git a/spec/features/projects/services/user_activates_hipchat_spec.rb b/spec/features/projects/services/user_activates_hipchat_spec.rb deleted file mode 100644 index 2f5313c91f9..00000000000 --- a/spec/features/projects/services/user_activates_hipchat_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -require 'spec_helper' - -describe 'User activates HipChat' do - let(:project) { create(:project) } - let(:user) { create(:user) } - - before do - project.add_maintainer(user) - sign_in(user) - - visit(project_settings_integrations_path(project)) - - click_link('HipChat') - end - - context 'with standart settings' do - it 'activates service' do - check('Active') - fill_in('Room', with: 'gitlab') - fill_in('Token', with: 'verySecret') - click_button('Save') - - expect(page).to have_content('HipChat activated.') - end - end - - context 'with custom settings' do - it 'activates service' do - check('Active') - fill_in('Room', with: 'gitlab_custom') - fill_in('Token', with: 'secretCustom') - fill_in('Server', with: 'https://chat.example.com') - click_button('Save') - - expect(page).to have_content('HipChat activated.') - end - end -end diff --git a/spec/features/projects/services/user_views_services_spec.rb b/spec/features/projects/services/user_views_services_spec.rb index e9c8cf0fe34..b0a838a7d2b 100644 --- a/spec/features/projects/services/user_views_services_spec.rb +++ b/spec/features/projects/services/user_views_services_spec.rb @@ -14,7 +14,6 @@ describe 'User views services' do it 'shows the list of available services' do expect(page).to have_content('Project services') expect(page).to have_content('Campfire') - expect(page).to have_content('HipChat') expect(page).to have_content('Assembla') expect(page).to have_content('Pushover') expect(page).to have_content('Atlassian Bamboo') @@ -22,5 +21,7 @@ describe 'User views services' do expect(page).to have_content('Asana') expect(page).to have_content('Irker (IRC gateway)') expect(page).to have_content('Packagist') + expect(page).to have_content('Mattermost') + expect(page).to have_content('Slack') end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 6897ac8a3a8..c15b360b563 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -219,7 +219,6 @@ project: - packagist_service - pivotaltracker_service - prometheus_service -- hipchat_service - flowdock_service - assembla_service - asana_service diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index 58949f76bd6..1327f414498 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -6774,28 +6774,6 @@ "default": false, "wiki_page_events": true }, - { - "id": 93, - "title": "HipChat", - "project_id": 5, - "created_at": "2016-06-14T15:01:51.219Z", - "updated_at": "2016-06-14T15:01:51.219Z", - "active": false, - "properties": { - "notify_only_broken_pipelines": true - }, - "template": false, - "push_events": true, - "issues_events": true, - "merge_requests_events": true, - "tag_push_events": true, - "note_events": true, - "pipeline_events": true, - "type": "HipchatService", - "category": "common", - "default": false, - "wiki_page_events": true - }, { "id": 91, "title": "Flowdock", diff --git a/spec/models/project_services/hipchat_service_spec.rb b/spec/models/project_services/hipchat_service_spec.rb deleted file mode 100644 index b0fd2ceead0..00000000000 --- a/spec/models/project_services/hipchat_service_spec.rb +++ /dev/null @@ -1,408 +0,0 @@ -require 'spec_helper' - -describe HipchatService do - describe "Associations" do - it { is_expected.to belong_to :project } - it { is_expected.to have_one :service_hook } - end - - describe 'Validations' do - context 'when service is active' do - before do - subject.active = true - end - - it { is_expected.to validate_presence_of(:token) } - end - - context 'when service is inactive' do - before do - subject.active = false - end - - it { is_expected.not_to validate_presence_of(:token) } - end - end - - describe "Execute" do - let(:hipchat) { described_class.new } - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - let(:api_url) { 'https://hipchat.example.com/v2/room/123456/notification?auth_token=verySecret' } - let(:project_name) { project.full_name.gsub(/\s/, '') } - let(:token) { 'verySecret' } - let(:server_url) { 'https://hipchat.example.com'} - let(:push_sample_data) do - Gitlab::DataBuilder::Push.build_sample(project, user) - end - - before do - allow(hipchat).to receive_messages( - project_id: project.id, - project: project, - room: 123456, - server: server_url, - token: token - ) - WebMock.stub_request(:post, api_url) - end - - it 'tests and return errors' do - allow(hipchat).to receive(:execute).and_raise(StandardError, 'no such room') - result = hipchat.test(push_sample_data) - - expect(result[:success]).to be_falsey - expect(result[:result].to_s).to eq('no such room') - end - - it 'uses v1 if version is provided' do - allow(hipchat).to receive(:api_version).and_return('v1') - expect(HipChat::Client).to receive(:new).with( - token, - api_version: 'v1', - server_url: server_url - ).and_return(double(:hipchat_service).as_null_object) - hipchat.execute(push_sample_data) - end - - it 'uses v2 as the version when nothing is provided' do - allow(hipchat).to receive(:api_version).and_return('') - expect(HipChat::Client).to receive(:new).with( - token, - api_version: 'v2', - server_url: server_url - ).and_return(double(:hipchat_service).as_null_object) - hipchat.execute(push_sample_data) - end - - context 'push events' do - it "calls Hipchat API for push events" do - hipchat.execute(push_sample_data) - - expect(WebMock).to have_requested(:post, api_url).once - end - - it "creates a push message" do - message = hipchat.send(:create_push_message, push_sample_data) - - push_sample_data[:object_attributes] - branch = push_sample_data[:ref].gsub('refs/heads/', '') - expect(message).to include("#{user.name} pushed to branch " \ - "#{branch} of " \ - "#{project_name}") - end - end - - context 'tag_push events' do - let(:push_sample_data) do - Gitlab::DataBuilder::Push.build( - project, - user, - Gitlab::Git::BLANK_SHA, - '1' * 40, - 'refs/tags/test', - []) - end - - it "calls Hipchat API for tag push events" do - hipchat.execute(push_sample_data) - - expect(WebMock).to have_requested(:post, api_url).once - end - - it "creates a tag push message" do - message = hipchat.send(:create_push_message, push_sample_data) - - push_sample_data[:object_attributes] - expect(message).to eq("#{user.name} pushed new tag " \ - "test to " \ - "#{project_name}\n") - end - end - - context 'issue events' do - let(:issue) { create(:issue, title: 'Awesome issue', description: '**please** fix') } - let(:issue_service) { Issues::CreateService.new(project, user) } - let(:issues_sample_data) { issue_service.hook_data(issue, 'open') } - - it "calls Hipchat API for issue events" do - hipchat.execute(issues_sample_data) - - expect(WebMock).to have_requested(:post, api_url).once - end - - it "creates an issue message" do - message = hipchat.send(:create_issue_message, issues_sample_data) - - obj_attr = issues_sample_data[:object_attributes] - expect(message).to eq("#{user.name} opened " \ - "issue ##{obj_attr["iid"]} in " \ - "#{project_name}: " \ - "Awesome issue" \ - "
please fix
") - end - end - - context 'merge request events' do - let(:merge_request) { create(:merge_request, description: '**please** fix', title: 'Awesome merge request', target_project: project, source_project: project) } - let(:merge_service) { MergeRequests::CreateService.new(project, user) } - let(:merge_sample_data) { merge_service.hook_data(merge_request, 'open') } - - it "calls Hipchat API for merge requests events" do - hipchat.execute(merge_sample_data) - - expect(WebMock).to have_requested(:post, api_url).once - end - - it "creates a merge request message" do - message = hipchat.send(:create_merge_request_message, - merge_sample_data) - - obj_attr = merge_sample_data[:object_attributes] - expect(message).to eq("#{user.name} opened " \ - "merge request !#{obj_attr["iid"]} in " \ - "#{project_name}: " \ - "Awesome merge request" \ - "
please fix
") - end - end - - context "Note events" do - let(:user) { create(:user) } - let(:project) { create(:project, :repository, creator: user) } - - context 'when commit comment event triggered' do - let(:commit_note) do - create(:note_on_commit, author: user, project: project, - commit_id: project.repository.commit.id, - note: 'a comment on a commit') - end - - it "calls Hipchat API for commit comment events" do - data = Gitlab::DataBuilder::Note.build(commit_note, user) - hipchat.execute(data) - - expect(WebMock).to have_requested(:post, api_url).once - - message = hipchat.send(:create_message, data) - - obj_attr = data[:object_attributes] - commit_id = Commit.truncate_sha(data[:commit][:id]) - title = hipchat.send(:format_title, data[:commit][:message]) - - expect(message).to eq("#{user.name} commented on " \ - "commit #{commit_id} in " \ - "#{project_name}: " \ - "#{title}" \ - "
a comment on a commit
") - end - end - - context 'when merge request comment event triggered' do - let(:merge_request) do - create(:merge_request, source_project: project, - target_project: project) - end - - let(:merge_request_note) do - create(:note_on_merge_request, noteable: merge_request, - project: project, - note: "merge request **note**") - end - - it "calls Hipchat API for merge request comment events" do - data = Gitlab::DataBuilder::Note.build(merge_request_note, user) - hipchat.execute(data) - - expect(WebMock).to have_requested(:post, api_url).once - - message = hipchat.send(:create_message, data) - - obj_attr = data[:object_attributes] - merge_id = data[:merge_request]['iid'] - title = data[:merge_request]['title'] - - expect(message).to eq("#{user.name} commented on " \ - "merge request !#{merge_id} in " \ - "#{project_name}: " \ - "#{title}" \ - "
merge request note
") - end - end - - context 'when issue comment event triggered' do - let(:issue) { create(:issue, project: project) } - let(:issue_note) do - create(:note_on_issue, noteable: issue, project: project, - note: "issue **note**") - end - - it "calls Hipchat API for issue comment events" do - data = Gitlab::DataBuilder::Note.build(issue_note, user) - hipchat.execute(data) - - message = hipchat.send(:create_message, data) - - obj_attr = data[:object_attributes] - issue_id = data[:issue]['iid'] - title = data[:issue]['title'] - - expect(message).to eq("#{user.name} commented on " \ - "issue ##{issue_id} in " \ - "#{project_name}: " \ - "#{title}" \ - "
issue note
") - end - - context 'with confidential issue' do - before do - issue.update!(confidential: true) - end - - it 'calls Hipchat API with issue comment' do - data = Gitlab::DataBuilder::Note.build(issue_note, user) - hipchat.execute(data) - - message = hipchat.send(:create_message, data) - - expect(message).to include("
issue note
") - end - end - end - - context 'when snippet comment event triggered' do - let(:snippet) { create(:project_snippet, project: project) } - let(:snippet_note) do - create(:note_on_project_snippet, noteable: snippet, - project: project, - note: "snippet note") - end - - it "calls Hipchat API for snippet comment events" do - data = Gitlab::DataBuilder::Note.build(snippet_note, user) - hipchat.execute(data) - - expect(WebMock).to have_requested(:post, api_url).once - - message = hipchat.send(:create_message, data) - - obj_attr = data[:object_attributes] - snippet_id = data[:snippet]['id'] - title = data[:snippet]['title'] - - expect(message).to eq("#{user.name} commented on " \ - "snippet ##{snippet_id} in " \ - "#{project_name}: " \ - "#{title}" \ - "
snippet note
") - end - end - end - - context 'pipeline events' do - let(:pipeline) { create(:ci_empty_pipeline, user: create(:user)) } - let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) } - - context 'for failed' do - before do - pipeline.drop - end - - it "calls Hipchat API" do - hipchat.execute(data) - - expect(WebMock).to have_requested(:post, api_url).once - end - - it "creates a build message" do - message = hipchat.__send__(:create_pipeline_message, data) - - project_url = project.web_url - project_name = project.full_name.gsub(/\s/, '') - pipeline_attributes = data[:object_attributes] - ref = pipeline_attributes[:ref] - ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' - duration = pipeline_attributes[:duration] - user_name = data[:user][:name] - - expect(message).to eq("#{project_name}: " \ - "Pipeline ##{pipeline.id} " \ - "of #{ref} #{ref_type} " \ - "by #{user_name} failed in #{duration} second(s)") - end - end - - context 'for succeeded' do - before do - pipeline.succeed - end - - it "calls Hipchat API" do - hipchat.notify_only_broken_pipelines = false - hipchat.execute(data) - expect(WebMock).to have_requested(:post, api_url).once - end - - it "notifies only broken" do - hipchat.notify_only_broken_pipelines = true - hipchat.execute(data) - expect(WebMock).not_to have_requested(:post, api_url).once - end - end - end - - context "#message_options" do - it "is set to the defaults" do - expect(hipchat.__send__(:message_options)).to eq({ notify: false, color: 'yellow' }) - end - - it "sets notify to true" do - allow(hipchat).to receive(:notify).and_return('1') - - expect(hipchat.__send__(:message_options)).to eq({ notify: true, color: 'yellow' }) - end - - it "sets the color" do - allow(hipchat).to receive(:color).and_return('red') - - expect(hipchat.__send__(:message_options)).to eq({ notify: false, color: 'red' }) - end - - context 'with a successful build' do - it 'uses the green color' do - data = { object_kind: 'pipeline', - object_attributes: { status: 'success' } } - - expect(hipchat.__send__(:message_options, data)).to eq({ notify: false, color: 'green' }) - end - end - - context 'with a failed build' do - it 'uses the red color' do - data = { object_kind: 'pipeline', - object_attributes: { status: 'failed' } } - - expect(hipchat.__send__(:message_options, data)).to eq({ notify: false, color: 'red' }) - end - end - end - end - - context 'with UrlBlocker' do - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - let(:hipchat) { described_class.new(project: project) } - let(:push_sample_data) { Gitlab::DataBuilder::Push.build_sample(project, user) } - - describe '#execute' do - before do - hipchat.server = 'http://localhost:9123' - end - - it 'raises UrlBlocker for localhost' do - expect(Gitlab::UrlBlocker).to receive(:validate!).and_call_original - expect { hipchat.execute(push_sample_data) }.to raise_error(Gitlab::HTTP::BlockedUrlError) - end - end - end -end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index c1767ed0535..7884e13ef54 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -41,7 +41,6 @@ describe Project do it { is_expected.to have_one(:pipelines_email_service) } it { is_expected.to have_one(:irker_service) } it { is_expected.to have_one(:pivotaltracker_service) } - it { is_expected.to have_one(:hipchat_service) } it { is_expected.to have_one(:flowdock_service) } it { is_expected.to have_one(:assembla_service) } it { is_expected.to have_one(:slack_slash_commands_service) } diff --git a/vendor/licenses.csv b/vendor/licenses.csv index d706d76358a..ed79ec5bac3 100644 --- a/vendor/licenses.csv +++ b/vendor/licenses.csv @@ -520,7 +520,6 @@ hashie-forbidden_attributes,0.1.1,MIT he,1.1.1,MIT health_check,2.6.0,MIT highlight.js,9.13.1,New BSD -hipchat,1.5.2,MIT hmac-drbg,1.0.1,MIT hoopy,0.1.4,MIT html-pipeline,2.8.4,MIT -- cgit v1.2.3 From 4d4af21a834a2a535810f48b8abf9683302629ea Mon Sep 17 00:00:00 2001 From: Merve Date: Sat, 9 Feb 2019 13:23:45 +0000 Subject: Docs: correct word --- doc/development/documentation/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/development/documentation/index.md b/doc/development/documentation/index.md index 828f9bfeec6..ac1d28c09df 100644 --- a/doc/development/documentation/index.md +++ b/doc/development/documentation/index.md @@ -12,7 +12,7 @@ In addition to this page, the following resources to help craft and contribute d - [Structure and template](structure.md) - Learn the typical parts of a doc page and how to write each one. - [Workflow](workflow.md) - A landing page for our key workflows: - [Feature-change documentation workflow](feature-change-workflow.md) - Adding required documentation when developing a GitLab feature. - - [Documentation improvement worflow](improvement-workflow.md) - New content not associated with a new feature. + - [Documentation improvement workflow](improvement-workflow.md) - New content not associated with a new feature. - [Markdown Guide](https://about.gitlab.com/handbook/product/technical-writing/markdown-guide/) - A reference for the markdown implementation used by GitLab's documentation site and about.gitlab.com. - [Site architecture](site_architecture/index.md) - How docs.gitlab.com is built. -- cgit v1.2.3 From 3820eca5c47574378b3ddee219287dcb318e2f55 Mon Sep 17 00:00:00 2001 From: Jasper Maes Date: Sat, 9 Feb 2019 16:40:03 +0100 Subject: Directly inheriting from ActiveRecord::Migration is deprecated --- changelogs/unreleased/deprecated-migration-inheritance.yml | 5 +++++ .../20181027114222_add_first_day_of_week_to_user_preferences.rb | 2 +- .../20181028120717_add_first_day_of_week_to_application_settings.rb | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/deprecated-migration-inheritance.yml diff --git a/changelogs/unreleased/deprecated-migration-inheritance.yml b/changelogs/unreleased/deprecated-migration-inheritance.yml new file mode 100644 index 00000000000..814c511195b --- /dev/null +++ b/changelogs/unreleased/deprecated-migration-inheritance.yml @@ -0,0 +1,5 @@ +--- +title: Directly inheriting from ActiveRecord::Migration is deprecated +merge_request: 25066 +author: Jasper Maes +type: other diff --git a/db/migrate/20181027114222_add_first_day_of_week_to_user_preferences.rb b/db/migrate/20181027114222_add_first_day_of_week_to_user_preferences.rb index a0e76c2186e..96ba5fbd816 100644 --- a/db/migrate/20181027114222_add_first_day_of_week_to_user_preferences.rb +++ b/db/migrate/20181027114222_add_first_day_of_week_to_user_preferences.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class AddFirstDayOfWeekToUserPreferences < ActiveRecord::Migration +class AddFirstDayOfWeekToUserPreferences < ActiveRecord::Migration[5.0] DOWNTIME = false def change diff --git a/db/migrate/20181028120717_add_first_day_of_week_to_application_settings.rb b/db/migrate/20181028120717_add_first_day_of_week_to_application_settings.rb index 53cfaa289f6..e9a8c1011ad 100644 --- a/db/migrate/20181028120717_add_first_day_of_week_to_application_settings.rb +++ b/db/migrate/20181028120717_add_first_day_of_week_to_application_settings.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class AddFirstDayOfWeekToApplicationSettings < ActiveRecord::Migration +class AddFirstDayOfWeekToApplicationSettings < ActiveRecord::Migration[5.0] include Gitlab::Database::MigrationHelpers disable_ddl_transaction! -- cgit v1.2.3 From 294a2a70edda5236146a0f3cbb069e42900fb9d3 Mon Sep 17 00:00:00 2001 From: Katrin Leinweber <724395-katrinleinweber@users.noreply.gitlab.com> Date: Sun, 10 Feb 2019 09:26:05 +0100 Subject: Externalize date picker string --- app/assets/javascripts/vue_shared/components/pikaday.vue | 3 ++- app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue | 3 ++- locale/gitlab.pot | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue index 13eb46437dd..fa502b9beb9 100644 --- a/app/assets/javascripts/vue_shared/components/pikaday.vue +++ b/app/assets/javascripts/vue_shared/components/pikaday.vue @@ -1,6 +1,7 @@