diff options
95 files changed, 1923 insertions, 509 deletions
diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue index 06f436adb8e..6fee40fb061 100644 --- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue +++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue @@ -107,7 +107,7 @@ export default { v-if="!popoverDismissed" show :target="target" - placement="rightbottom" + placement="right" trigger="manual" container="viewport" :css-classes="['suggest-gitlab-ci-yml', 'ml-4']" diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql new file mode 100644 index 00000000000..b64ceb8e2c9 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql @@ -0,0 +1,9 @@ +#import "../fragments/user.fragment.graphql" + +query usersSearch($search: String!) { + users(search: $search) { + nodes { + ...User + } + } +} diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index 4d2955a8d3d..ab83f1ecc14 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import ContextualSidebar from './contextual_sidebar'; import initFlyOutNav from './fly_out_nav'; +import { setNotification } from './whats_new/utils/notification'; function hideEndFade($scrollingTabs) { $scrollingTabs.each(function scrollTabsLoop() { @@ -14,25 +15,17 @@ function hideEndFade($scrollingTabs) { function initDeferred() { $(document).trigger('init.scrolling-tabs'); - const whatsNewTriggerEl = document.querySelector('.js-whats-new-trigger'); - if (whatsNewTriggerEl) { - const storageKey = whatsNewTriggerEl.getAttribute('data-storage-key'); + const appEl = document.getElementById('whats-new-app'); + if (!appEl) return; - $('.header-help').on('show.bs.dropdown', () => { - const displayNotification = JSON.parse(localStorage.getItem(storageKey)); - if (displayNotification === false) { - $('.js-whats-new-notification-count').remove(); - } - }); - - whatsNewTriggerEl.addEventListener('click', () => { - import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new') - .then(({ default: initWhatsNew }) => { - initWhatsNew(); - }) - .catch(() => {}); - }); - } + setNotification(appEl); + document.querySelector('.js-whats-new-trigger').addEventListener('click', () => { + import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new') + .then(({ default: initWhatsNew }) => { + initWhatsNew(appEl); + }) + .catch(() => {}); + }); } export default function initLayoutNav() { diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index fe4e2cee69f..344f8dee5ea 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -102,14 +102,6 @@ MergeRequest.prototype.initMRBtnListeners = function() { return $('.btn-close, .btn-reopen').on('click', function(e) { const $this = $(this); const shouldSubmit = $this.hasClass('btn-comment'); - if ($this.hasClass('js-btn-issue-action')) { - const url = $this.data('endpoint'); - return axios - .put(url) - .then(() => window.location.reload()) - .catch(() => createFlash(__('Something went wrong.'))); - } - if (shouldSubmit && $this.data('submitted')) { return; } @@ -171,10 +163,6 @@ MergeRequest.decreaseCounter = function(by = 1) { MergeRequest.hideCloseButton = function() { const el = document.querySelector('.merge-request .js-issuable-actions'); - const closeDropdownItem = el.querySelector('li.close-item'); - if (closeDropdownItem) { - closeDropdownItem.classList.add('hidden'); - } // Dropdown for mobile screen el.querySelector('li.js-close-item').classList.add('hidden'); }; diff --git a/app/assets/javascripts/notes/components/multiline_comment_utils.js b/app/assets/javascripts/notes/components/multiline_comment_utils.js index dbae10c8f6c..2451400e980 100644 --- a/app/assets/javascripts/notes/components/multiline_comment_utils.js +++ b/app/assets/javascripts/notes/components/multiline_comment_utils.js @@ -103,9 +103,15 @@ export function getCommentedLines(selectedCommentPosition, diffLines) { }; } + const findLineCodeIndex = line => position => { + return [position.line_code, position.left?.line_code, position.right?.line_code].includes( + line.line_code, + ); + }; + const { start, end } = selectedCommentPosition; - const startLine = diffLines.findIndex(l => l.line_code === start.line_code); - const endLine = diffLines.findIndex(l => l.line_code === end.line_code); + const startLine = diffLines.findIndex(findLineCodeIndex(start)); + const endLine = diffLines.findIndex(findLineCodeIndex(end)); return { startLine, endLine }; } diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js index a57c9718156..2b9e7a2815e 100644 --- a/app/assets/javascripts/whats_new/index.js +++ b/app/assets/javascripts/whats_new/index.js @@ -1,26 +1,34 @@ import Vue from 'vue'; +import { mapState } from 'vuex'; import App from './components/app.vue'; import store from './store'; +import { getStorageKey, setNotification } from './utils/notification'; let whatsNewApp; -export default () => { +export default el => { if (whatsNewApp) { store.dispatch('openDrawer'); } else { - const whatsNewElm = document.getElementById('whats-new-app'); + const storageKey = getStorageKey(el); whatsNewApp = new Vue({ - el: whatsNewElm, + el, store, components: { App, }, + computed: { + ...mapState(['open']), + }, + watch: { + open() { + setNotification(el); + }, + }, render(createElement) { return createElement('app', { - props: { - storageKey: whatsNewElm.getAttribute('data-storage-key'), - }, + props: { storageKey }, }); }, }); diff --git a/app/assets/javascripts/whats_new/utils/notification.js b/app/assets/javascripts/whats_new/utils/notification.js new file mode 100644 index 00000000000..f261a089554 --- /dev/null +++ b/app/assets/javascripts/whats_new/utils/notification.js @@ -0,0 +1,17 @@ +export const getStorageKey = appEl => appEl.getAttribute('data-storage-key'); + +export const setNotification = appEl => { + const storageKey = getStorageKey(appEl); + const notificationEl = document.querySelector('.header-help'); + let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count'); + + if (JSON.parse(localStorage.getItem(storageKey)) === false) { + notificationEl.classList.remove('with-notifications'); + if (notificationCountEl) { + notificationCountEl.parentElement.removeChild(notificationCountEl); + notificationCountEl = null; + } + } else { + notificationEl.classList.add('with-notifications'); + } +}; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 196fb3a7088..a93c70c75d3 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -103,7 +103,8 @@ @include transition(color); } -a { +a, +.notification-dot { @include transition(background-color, color, border); } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 52319d9658b..0286c2f517b 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -556,12 +556,17 @@ border: 1px solid $gray-normal; } -.header-user-notification-dot { +.notification-dot { background-color: $orange-300; height: 12px; width: 12px; - right: 8px; - top: -8px; + margin-top: -15px; + pointer-events: none; + visibility: hidden; +} + +.with-notifications .notification-dot { + visibility: visible; } .with-performance-bar .navbar-gitlab { diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss index f357d508d5d..f237d57aa88 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/pages/detail_page.scss @@ -41,12 +41,6 @@ @include media-breakpoint-down(xs) { width: 100%; margin-top: 10px; - - > .issue-btn-group { - > .btn { - width: 100%; - } - } } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 57725aa3002..e5528c25e82 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -890,24 +890,6 @@ } } -.issuable-close-dropdown { - .dropdown-menu { - min-width: 270px; - left: auto; - right: 0; - } - - .description { - .text { - margin: 0; - } - } - - .dropdown-toggle > .icon { - margin: 0 3px; - } -} - /* * Following overrides are done to prevent * legacy dropdown styles from influencing diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index 85115cfd5d9..417377b514e 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -64,14 +64,20 @@ color: $search-and-nav-links; > a { + .notification-dot { + border: 2px solid $nav-svg-color; + } + + &.header-help-dropdown-toggle { + .notification-dot { + background-color: $search-and-nav-links; + } + } + &.header-user-dropdown-toggle { .header-user-avatar { border-color: $search-and-nav-links; } - - .header-user-notification-dot { - border: 2px solid $nav-svg-color; - } } &:hover, @@ -84,9 +90,14 @@ fill: currentColor; } - &.header-user-dropdown-toggle .header-user-notification-dot { + .notification-dot { + will-change: border-color, background-color; border-color: $nav-svg-color + 33; } + + &.header-help-dropdown-toggle .notification-dot { + background-color: $white; + } } } @@ -101,9 +112,15 @@ } } - &.header-user-dropdown-toggle .header-user-notification-dot { + .notification-dot { border-color: $white; } + + &.header-help-dropdown-toggle { + .notification-dot { + background-color: $nav-svg-color; + } + } } .impersonated-user, diff --git a/app/controllers/concerns/dependency_proxy/auth.rb b/app/controllers/concerns/dependency_proxy/auth.rb new file mode 100644 index 00000000000..22618ca6366 --- /dev/null +++ b/app/controllers/concerns/dependency_proxy/auth.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module DependencyProxy + module Auth + extend ActiveSupport::Concern + + included do + # We disable `authenticate_user!` since the `DependencyProxy::Auth` performs auth using JWT token + skip_before_action :authenticate_user!, raise: false + prepend_before_action :authenticate_user_from_jwt_token! + end + + def authenticate_user_from_jwt_token! + return unless dependency_proxy_for_private_groups? + + authenticate_with_http_token do |token, _| + user = user_from_token(token) + sign_in(user) if user + end + + request_bearer_token! unless current_user + end + + private + + def dependency_proxy_for_private_groups? + Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: false) + end + + def request_bearer_token! + # unfortunately, we cannot use https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token.html#method-i-authentication_request + response.headers['WWW-Authenticate'] = ::DependencyProxy::Registry.authenticate_header + render plain: '', status: :unauthorized + end + + def user_from_token(token) + token_payload = DependencyProxy::AuthTokenService.decoded_token_payload(token) + User.find(token_payload['user_id']) + rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::ImmatureSignature + nil + end + end +end diff --git a/app/controllers/concerns/dependency_proxy/group_access.rb b/app/controllers/concerns/dependency_proxy/group_access.rb new file mode 100644 index 00000000000..2a923d02752 --- /dev/null +++ b/app/controllers/concerns/dependency_proxy/group_access.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module DependencyProxy + module GroupAccess + extend ActiveSupport::Concern + + included do + before_action :verify_dependency_proxy_enabled! + before_action :authorize_read_dependency_proxy! + end + + private + + def verify_dependency_proxy_enabled! + render_404 unless group.dependency_proxy_feature_available? + end + + def authorize_read_dependency_proxy! + access_denied! unless can?(current_user, :read_dependency_proxy, group) + end + + def authorize_admin_dependency_proxy! + access_denied! unless can?(current_user, :admin_dependency_proxy, group) + end + end +end diff --git a/app/controllers/concerns/dependency_proxy_access.rb b/app/controllers/concerns/dependency_proxy_access.rb deleted file mode 100644 index 5036d0cfce4..00000000000 --- a/app/controllers/concerns/dependency_proxy_access.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module DependencyProxyAccess - extend ActiveSupport::Concern - - included do - before_action :verify_dependency_proxy_enabled! - before_action :authorize_read_dependency_proxy! - end - - private - - def verify_dependency_proxy_enabled! - render_404 unless group.dependency_proxy_feature_available? - end - - def authorize_read_dependency_proxy! - access_denied! unless can?(current_user, :read_dependency_proxy, group) - end - - def authorize_admin_dependency_proxy! - access_denied! unless can?(current_user, :admin_dependency_proxy, group) - end -end diff --git a/app/controllers/groups/dependency_proxies_controller.rb b/app/controllers/groups/dependency_proxies_controller.rb index 367dbafdd59..b896b240daf 100644 --- a/app/controllers/groups/dependency_proxies_controller.rb +++ b/app/controllers/groups/dependency_proxies_controller.rb @@ -2,7 +2,7 @@ module Groups class DependencyProxiesController < Groups::ApplicationController - include DependencyProxyAccess + include DependencyProxy::GroupAccess before_action :authorize_admin_dependency_proxy!, only: :update before_action :dependency_proxy diff --git a/app/controllers/groups/dependency_proxy_auth_controller.rb b/app/controllers/groups/dependency_proxy_auth_controller.rb new file mode 100644 index 00000000000..e3e9bd88e24 --- /dev/null +++ b/app/controllers/groups/dependency_proxy_auth_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Groups::DependencyProxyAuthController < ApplicationController + include DependencyProxy::Auth + + feature_category :dependency_proxy + + def authenticate + render plain: '', status: :ok + end +end diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb index f46902ef90f..22aea424998 100644 --- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb +++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class Groups::DependencyProxyForContainersController < Groups::ApplicationController - include DependencyProxyAccess + include DependencyProxy::Auth + include DependencyProxy::GroupAccess include SendFileUpload before_action :ensure_token_granted! @@ -9,7 +10,7 @@ class Groups::DependencyProxyForContainersController < Groups::ApplicationContro attr_reader :token - feature_category :package_registry + feature_category :dependency_proxy def manifest result = DependencyProxy::PullManifestService.new(image, tag, token).execute diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 5199bb25c8c..85ee2204324 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -11,7 +11,8 @@ class JwtController < ApplicationController feature_category :authentication_and_authorization SERVICES = { - Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService + ::Auth::ContainerRegistryAuthenticationService::AUDIENCE => ::Auth::ContainerRegistryAuthenticationService, + ::Auth::DependencyProxyAuthenticationService::AUDIENCE => ::Auth::DependencyProxyAuthenticationService }.freeze def auth diff --git a/app/controllers/profiles/gpg_keys_controller.rb b/app/controllers/profiles/gpg_keys_controller.rb index 7f04927f517..f1d4f9b2cc0 100644 --- a/app/controllers/profiles/gpg_keys_controller.rb +++ b/app/controllers/profiles/gpg_keys_controller.rb @@ -2,6 +2,7 @@ class Profiles::GpgKeysController < Profiles::ApplicationController before_action :set_gpg_key, only: [:destroy, :revoke] + skip_before_action :authenticate_user!, only: [:get_keys] feature_category :users @@ -39,6 +40,24 @@ class Profiles::GpgKeysController < Profiles::ApplicationController end end + # Get all gpg keys of a user(params[:username]) in a text format + def get_keys + if params[:username].present? + begin + user = UserFinder.new(params[:username]).find_by_username + if user.present? + render plain: user.gpg_keys.select(&:verified?).map(&:key).join("\n") + else + render_404 + end + rescue => e + render html: e.message + end + else + render_404 + end + end + private def gpg_key_params diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 1f998e0083a..4610756192a 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -15,7 +15,7 @@ class Projects::JobsController < Projects::ApplicationController before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize before_action :verify_proxy_request!, only: :proxy_websocket_authorize before_action do - push_frontend_feature_flag(:ci_job_line_links, @project) + push_frontend_feature_flag(:ci_job_line_links, @project, default_enabled: true) end before_action only: :index do frontend_experimentation_tracking_data(:jobs_empty_state, 'click_button') diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb index 639e403d797..3cf0a23b7f6 100644 --- a/app/controllers/repositories/git_http_controller.rb +++ b/app/controllers/repositories/git_http_controller.rb @@ -80,6 +80,8 @@ module Repositories return if Gitlab::Database.read_only? return unless repo_type.project? + OnboardingProgressService.new(project.namespace).execute(action: :git_read) + if Feature.enabled?(:project_statistics_sync, project, default_enabled: true) Projects::FetchStatisticsIncrementService.new(project).execute else diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 1f289265916..547977c01a9 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -319,12 +319,6 @@ module IssuablesHelper issuable_path(issuable, close_reopen_params(issuable, :reopen)) end - def toggle_draft_issuable_path(issuable) - wip_event = issuable.work_in_progress? ? 'unwip' : 'wip' - - issuable_path(issuable, { merge_request: { wip_event: wip_event } }) - end - def issuable_path(issuable, *options) polymorphic_path(issuable, *options) end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 9cb7edbaeb6..35ceddada5f 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -39,19 +39,6 @@ module MergeRequestsHelper end end - def ci_build_details_path(merge_request) - build_url = merge_request.source_project.ci_service.build_page(merge_request.diff_head_sha, merge_request.source_branch) - return unless build_url - - parsed_url = URI.parse(build_url) - - unless parsed_url.userinfo.blank? - parsed_url.userinfo = '' - end - - parsed_url.to_s - end - def merge_path_description(merge_request, separator) if merge_request.for_fork? "Project:Branches: #{@merge_request.source_project_path}:#{@merge_request.source_branch} #{separator} #{@merge_request.target_project.full_path}:#{@merge_request.target_branch}" @@ -166,6 +153,12 @@ module MergeRequestsHelper current_user.fork_of(project) end end + + def toggle_draft_merge_request_path(issuable) + wip_event = issuable.work_in_progress? ? 'unwip' : 'wip' + + issuable_path(issuable, { merge_request: { wip_event: wip_event } }) + end end MergeRequestsHelper.prepend_if_ee('EE::MergeRequestsHelper') diff --git a/app/models/dependency_proxy/registry.rb b/app/models/dependency_proxy/registry.rb index 471d5be2600..6492acf325a 100644 --- a/app/models/dependency_proxy/registry.rb +++ b/app/models/dependency_proxy/registry.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true class DependencyProxy::Registry - AUTH_URL = 'https://auth.docker.io'.freeze - LIBRARY_URL = 'https://registry-1.docker.io/v2'.freeze + AUTH_URL = 'https://auth.docker.io' + LIBRARY_URL = 'https://registry-1.docker.io/v2' + PROXY_AUTH_URL = Gitlab::Utils.append_path(Gitlab.config.gitlab.url, "jwt/auth") class << self def auth_url(image) @@ -17,6 +18,10 @@ class DependencyProxy::Registry "#{LIBRARY_URL}/#{image_path(image)}/blobs/#{blob_sha}" end + def authenticate_header + "Bearer realm=\"#{PROXY_AUTH_URL}\",service=\"#{::Auth::DependencyProxyAuthenticationService::AUDIENCE}\"" + end + private def image_path(image) diff --git a/app/models/namespace_onboarding_action.rb b/app/models/namespace_onboarding_action.rb index 7c3ab051d93..e1121279e2e 100644 --- a/app/models/namespace_onboarding_action.rb +++ b/app/models/namespace_onboarding_action.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class NamespaceOnboardingAction < ApplicationRecord - belongs_to :namespace + belongs_to :namespace, optional: false + + validates :action, presence: true ACTIONS = { - subscription_created: 1 + subscription_created: 1, + git_write: 2, + git_read: 4 }.freeze enum action: ACTIONS diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 9674f9a41da..ef799b01452 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -31,3 +31,5 @@ class UserDetail < ApplicationRecord self.bio = '' if bio_changed? && bio.nil? end end + +UserDetail.prepend_if_ee('EE::UserDetail') diff --git a/app/services/auth/dependency_proxy_authentication_service.rb b/app/services/auth/dependency_proxy_authentication_service.rb new file mode 100644 index 00000000000..1b8c16b7c79 --- /dev/null +++ b/app/services/auth/dependency_proxy_authentication_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Auth + class DependencyProxyAuthenticationService < BaseService + AUDIENCE = 'dependency_proxy' + HMAC_KEY = 'gitlab-dependency-proxy' + DEFAULT_EXPIRE_TIME = 1.minute + + def execute(authentication_abilities:) + return error('dependency proxy not enabled', 404) unless ::Gitlab.config.dependency_proxy.enabled + return error('access forbidden', 403) unless current_user + + { token: authorized_token.encoded } + end + + class << self + include ::Gitlab::Utils::StrongMemoize + + def secret + strong_memoize(:secret) do + OpenSSL::HMAC.hexdigest( + 'sha256', + ::Settings.attr_encrypted_db_key_base, + HMAC_KEY + ) + end + end + + def token_expire_at + Time.current + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes + end + end + + private + + def authorized_token + JSONWebToken::HMACToken.new(self.class.secret).tap do |token| + token['user_id'] = current_user.id + token.expire_time = self.class.token_expire_at + end + end + end +end diff --git a/app/services/dependency_proxy/auth_token_service.rb b/app/services/dependency_proxy/auth_token_service.rb new file mode 100644 index 00000000000..16279ed12b0 --- /dev/null +++ b/app/services/dependency_proxy/auth_token_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module DependencyProxy + class AuthTokenService < DependencyProxy::BaseService + attr_reader :token + + def initialize(token) + @token = token + end + + def execute + JSONWebToken::HMACToken.decode(token, ::Auth::DependencyProxyAuthenticationService.secret).first + end + + class << self + def decoded_token_payload(token) + self.new(token).execute + end + end + end +end diff --git a/app/services/onboarding_progress_service.rb b/app/services/onboarding_progress_service.rb new file mode 100644 index 00000000000..c45edcaaf33 --- /dev/null +++ b/app/services/onboarding_progress_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class OnboardingProgressService + def initialize(namespace) + @namespace = namespace + end + + def execute(action:) + NamespaceOnboardingAction.create_action(@namespace, action) + end +end diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb index 79b613f6a88..bd9588844ad 100644 --- a/app/services/post_receive_service.rb +++ b/app/services/post_receive_service.rb @@ -40,6 +40,8 @@ class PostReceiveService response.add_basic_message(redirect_message) response.add_basic_message(project_created_message) + + record_onboarding_progress end response @@ -90,6 +92,10 @@ class PostReceiveService banner&.message end + + def record_onboarding_progress + NamespaceOnboardingAction.create_action(project.namespace, :git_write) + end end PostReceiveService.prepend_if_ee('EE::PostReceiveService') diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml index ff1312eb763..abbfb2d3b91 100644 --- a/app/views/groups/dependency_proxies/show.html.haml +++ b/app/views/groups/dependency_proxies/show.html.haml @@ -7,7 +7,7 @@ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('user/packages/dependency_proxy/index') } = _('Create a local proxy for storing frequently used upstream images. %{link_start}Learn more%{link_end} about dependency proxies.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } -- if @group.public? +- if Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: false) || @group.public? - if can?(current_user, :admin_dependency_proxy, @group) = form_for(@dependency_proxy, method: :put, url: group_dependency_proxy_path(@group)) do |f| .form-group diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 794d1589172..8aba9426ec0 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -74,6 +74,7 @@ %span.gl-sr-only = s_('Nav|Help') = sprite_icon('question') + %span.notification-dot.rounded-circle.gl-absolute = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right = render 'layouts/header/help_dropdown' diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml new file mode 100644 index 00000000000..3a8629b3b6e --- /dev/null +++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml @@ -0,0 +1,37 @@ +- display_issuable_type = issuable_display_type(@merge_request) +- button_action_class = @merge_request.closed? ? 'btn-default' : 'btn-warning btn-warning-secondary' +- button_class = "btn gl-button #{!@merge_request.closed? && 'js-draft-toggle-button'}" +- toggle_class = "btn gl-button dropdown-toggle" + +.float-left.btn-group.gl-ml-3.gl-display-none.gl-display-md-flex + = link_to @merge_request.closed? ? reopen_issuable_path(@merge_request) : toggle_draft_merge_request_path(@merge_request), method: :put, class: "#{button_class} #{button_action_class}" do + - if @merge_request.closed? + = _('Reopen') + = display_issuable_type + - else + = @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft') + + - if !@merge_request.closed? || !issuable_author_is_current_user(@merge_request) + = button_tag type: 'button', class: "#{toggle_class} #{button_action_class}", data: { 'toggle' => 'dropdown' } do + %span.gl-sr-only= _('Toggle dropdown') + = sprite_icon "angle-down", size: 12 + + %ul.dropdown-menu.dropdown-menu-right + - if @merge_request.open? + %li + = link_to close_issuable_path(@merge_request), method: :put do + .description + %strong.title + = _('Close') + = display_issuable_type + + - unless issuable_author_is_current_user(@merge_request) + - unless @merge_request.closed? + %li.divider.droplab-item-ignore + + %li + %a{ href: new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) } + .description + %strong.title= _('Report abuse') + %p.text.gl-mb-0 + = _('Report %{display_issuable_type} that are abusive, inappropriate or spam.') % { display_issuable_type: display_issuable_type.pluralize } diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 5b30b6e3379..f7cc15cec5a 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -25,8 +25,8 @@ = sprite_icon('chevron-double-lg-left') .detail-page-header-actions.js-issuable-actions - .clearfix.issue-btn-group.dropdown - %button.gl-button.btn.btn-default.float-left.gl-display-md-none{ type: "button", data: { toggle: "dropdown" } } + .clearfix.dropdown + %button.gl-button.btn.btn-default.float-left.gl-display-md-none.gl-w-full{ type: "button", data: { toggle: "dropdown" } } Options = sprite_icon('chevron-down', css_class: 'gl-text-gray-500') .dropdown-menu.dropdown-menu-right @@ -35,12 +35,12 @@ %li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - if @merge_request.opened? %li - = link_to @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft'), toggle_draft_issuable_path(@merge_request), method: :put, class: "js-draft-toggle-button" + = link_to @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft'), toggle_draft_merge_request_path(@merge_request), method: :put, class: "js-draft-toggle-button" %li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] } = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request' - if can_reopen_merge_request %li{ class: merge_request_button_visibility(@merge_request, false) } - = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request' + = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, title: 'Reopen merge request' - unless @merge_request.merged? || current_user == @merge_request.author %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) @@ -48,6 +48,6 @@ = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-grouped js-issuable-edit qa-edit-button" - if can_update_merge_request && !are_close_and_open_buttons_hidden - = render 'shared/issuable/close_reopen_draft_report_toggle', issuable: @merge_request + = render 'projects/merge_requests/close_reopen_draft_report_toggle' - elsif !@merge_request.merged? = link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'gl-display-none gl-display-md-block gl-button btn btn-warning-secondary float-right gl-ml-3', title: _('Report abuse') diff --git a/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml deleted file mode 100644 index 250e1516318..00000000000 --- a/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml +++ /dev/null @@ -1,37 +0,0 @@ -- display_issuable_type = issuable_display_type(issuable) -- button_action_class = issuable.closed? ? 'btn-default' : 'btn-warning btn-warning-secondary' -- button_class = "btn gl-button #{!issuable.closed? && 'js-draft-toggle-button'}" -- toggle_class = "btn gl-button dropdown-toggle" - -.float-left.btn-group.gl-ml-3.issuable-close-dropdown.d-none.d-md-inline-flex.js-issuable-close-dropdown - = link_to issuable.closed? ? reopen_issuable_path(issuable) : toggle_draft_issuable_path(issuable), method: :put, class: "#{button_class} #{button_action_class}" do - - if issuable.closed? - = _('Reopen') - = display_issuable_type - - else - = issuable.work_in_progress? ? _('Mark as ready') : _('Mark as draft') - - - if !issuable.closed? || !issuable_author_is_current_user(issuable) - = button_tag type: 'button', class: "#{toggle_class} #{button_action_class}", data: { 'toggle' => 'dropdown' } do - %span.sr-only= _('Toggle dropdown') - = sprite_icon "angle-down", size: 12 - - %ul.js-issuable-close-menu.dropdown-menu.dropdown-menu-right - - if issuable.open? - %li - = link_to close_issuable_path(issuable), method: :put do - .description - %strong.title - = _('Close') - = display_issuable_type - - - unless issuable_author_is_current_user(issuable) - - unless issuable.closed? - %li.divider.droplab-item-ignore - - %li.report-item - %a.report-abuse-link{ href: new_abuse_report_path(user_id: issuable.author.id, ref_url: merge_request_url(issuable)) } - .description - %strong.title= _('Report abuse') - %p.text - = _('Report %{display_issuable_type} that are abusive, inappropriate or spam.') % { display_issuable_type: display_issuable_type.pluralize } diff --git a/changelogs/unreleased/277130-add-a-new-column-called-finding_uuid-into-the-vulnerability_feedba.yml b/changelogs/unreleased/277130-add-a-new-column-called-finding_uuid-into-the-vulnerability_feedba.yml new file mode 100644 index 00000000000..06c4318aaa1 --- /dev/null +++ b/changelogs/unreleased/277130-add-a-new-column-called-finding_uuid-into-the-vulnerability_feedba.yml @@ -0,0 +1,5 @@ +--- +title: Add new column `finding_uuid` into `vulnerability_feedback` table +merge_request: 48923 +author: +type: changed diff --git a/changelogs/unreleased/281727-feature-ci_job_line_links-default-true.yml b/changelogs/unreleased/281727-feature-ci_job_line_links-default-true.yml new file mode 100644 index 00000000000..3243afcdbab --- /dev/null +++ b/changelogs/unreleased/281727-feature-ci_job_line_links-default-true.yml @@ -0,0 +1,5 @@ +--- +title: Render http and https URLs as clickable links in Job logs +merge_request: 48758 +author: Łukasz Groszkowski @falxcerebri +type: added diff --git a/changelogs/unreleased/bump-managed-cluster-apps-v0-36-0.yml b/changelogs/unreleased/bump-managed-cluster-apps-v0-36-0.yml new file mode 100644 index 00000000000..76351175ada --- /dev/null +++ b/changelogs/unreleased/bump-managed-cluster-apps-v0-36-0.yml @@ -0,0 +1,5 @@ +--- +title: Bumps Managed-Cluster-Applications CI template to v0.36.0, which upgrades Runner +merge_request: 48444 +author: +type: changed diff --git a/changelogs/unreleased/dblessing_scim_provisioned_user.yml b/changelogs/unreleased/dblessing_scim_provisioned_user.yml new file mode 100644 index 00000000000..b95e6f36607 --- /dev/null +++ b/changelogs/unreleased/dblessing_scim_provisioned_user.yml @@ -0,0 +1,5 @@ +--- +title: Mark SCIM-created accounts as provisioned by group +merge_request: 48483 +author: +type: added diff --git a/changelogs/unreleased/gpg-keys-publicly-accessible.yml b/changelogs/unreleased/gpg-keys-publicly-accessible.yml new file mode 100644 index 00000000000..654419464bd --- /dev/null +++ b/changelogs/unreleased/gpg-keys-publicly-accessible.yml @@ -0,0 +1,5 @@ +--- +title: Add an URL to get user's GPG key if registerd +merge_request: 48321 +author: Shimura Rin @blackenedgold +type: added diff --git a/changelogs/unreleased/jdb-fix-comment-highlighting-unified-components.yml b/changelogs/unreleased/jdb-fix-comment-highlighting-unified-components.yml new file mode 100644 index 00000000000..1c08f1e9a76 --- /dev/null +++ b/changelogs/unreleased/jdb-fix-comment-highlighting-unified-components.yml @@ -0,0 +1,5 @@ +--- +title: Fix comment highlighting for unified diff components +merge_request: 49061 +author: +type: fixed diff --git a/changelogs/unreleased/update-internal-ids-last-value-for-epics.yml b/changelogs/unreleased/update-internal-ids-last-value-for-epics.yml new file mode 100644 index 00000000000..3b2de770efb --- /dev/null +++ b/changelogs/unreleased/update-internal-ids-last-value-for-epics.yml @@ -0,0 +1,5 @@ +--- +title: Fix last_value record in internal_ids for epics +merge_request: 48988 +author: +type: fixed diff --git a/config/feature_flags/development/ci_job_line_links.yml b/config/feature_flags/development/ci_job_line_links.yml index 458c0afb6d2..e14dd82833e 100644 --- a/config/feature_flags/development/ci_job_line_links.yml +++ b/config/feature_flags/development/ci_job_line_links.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/281727 milestone: '13.6' type: development group: group::continuous integration -default_enabled: false +default_enabled: true diff --git a/config/feature_flags/development/dependency_proxy_for_private_groups.yml b/config/feature_flags/development/dependency_proxy_for_private_groups.yml new file mode 100644 index 00000000000..60dc1b6f928 --- /dev/null +++ b/config/feature_flags/development/dependency_proxy_for_private_groups.yml @@ -0,0 +1,8 @@ +--- +name: dependency_proxy_for_private_groups +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46042 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/276777 +milestone: '13.7' +type: development +group: group::package +default_enabled: false diff --git a/config/routes/group.rb b/config/routes/group.rb index 3b52aae52e2..38c04369d2f 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -125,7 +125,7 @@ end # Dependency proxy for containers # Because docker adds v2 prefix to URI this need to be outside of usual group routes scope format: false do - get 'v2', to: proc { [200, {}, ['']] } # rubocop:disable Cop/PutGroupRoutesUnderScope + get 'v2' => 'groups/dependency_proxy_auth#authenticate' # rubocop:disable Cop/PutGroupRoutesUnderScope constraints image: Gitlab::PathRegex.container_image_regex, sha: Gitlab::PathRegex.container_image_blob_sha_regex do get 'v2/*group_id/dependency_proxy/containers/*image/manifests/*tag' => 'groups/dependency_proxy_for_containers#manifest' # rubocop:todo Cop/PutGroupRoutesUnderScope diff --git a/config/routes/user.rb b/config/routes/user.rb index 63329277e33..7af4bf2ac2a 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -54,9 +54,12 @@ scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) d end constraints(::Constraints::UserUrlConstrainer.new) do - # Get all keys of user + # Get all SSH keys of user get ':username.keys' => 'profiles/keys#get_keys', constraints: { username: Gitlab::PathRegex.root_namespace_route_regex } + # Get all GPG keys of user + get ':username.gpg' => 'profiles/gpg_keys#get_keys', constraints: { username: Gitlab::PathRegex.root_namespace_route_regex } + scope(path: ':username', as: :user, constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }, diff --git a/db/migrate/20201123161611_add_provisioned_by_group_to_user_details.rb b/db/migrate/20201123161611_add_provisioned_by_group_to_user_details.rb new file mode 100644 index 00000000000..6e4d0e84509 --- /dev/null +++ b/db/migrate/20201123161611_add_provisioned_by_group_to_user_details.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class AddProvisionedByGroupToUserDetails < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INDEX_NAME = 'index_user_details_on_provisioned_by_group_id' + + disable_ddl_transaction! + + def up + unless column_exists?(:user_details, :provisioned_by_group_id) + with_lock_retries { add_column(:user_details, :provisioned_by_group_id, :integer, limit: 8) } + end + + add_concurrent_index :user_details, :provisioned_by_group_id, name: INDEX_NAME + add_concurrent_foreign_key :user_details, :namespaces, column: :provisioned_by_group_id, on_delete: :nullify + end + + def down + with_lock_retries { remove_foreign_key_without_error :user_details, column: :provisioned_by_group_id } + + remove_concurrent_index_by_name :user_details, INDEX_NAME + + if column_exists?(:user_details, :provisioned_by_group_id) + with_lock_retries { remove_column(:user_details, :provisioned_by_group_id) } + end + end +end diff --git a/db/migrate/20201201163227_add_finding_uuid_to_vulnerability_feedback.rb b/db/migrate/20201201163227_add_finding_uuid_to_vulnerability_feedback.rb new file mode 100644 index 00000000000..a2e13806000 --- /dev/null +++ b/db/migrate/20201201163227_add_finding_uuid_to_vulnerability_feedback.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddFindingUuidToVulnerabilityFeedback < ActiveRecord::Migration[6.0] + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + add_column :vulnerability_feedback, :finding_uuid, :uuid + end +end diff --git a/db/migrate/20201202081429_update_internal_ids_last_value_for_epics.rb b/db/migrate/20201202081429_update_internal_ids_last_value_for_epics.rb new file mode 100644 index 00000000000..7f6aefde7da --- /dev/null +++ b/db/migrate/20201202081429_update_internal_ids_last_value_for_epics.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class UpdateInternalIdsLastValueForEpics < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def up + ApplicationRecord.connection.execute(<<-SQL.squish) + UPDATE internal_ids + SET last_value = epics_max_iids.maximum_iid + FROM + ( + SELECT + MAX(epics.iid) AS maximum_iid, + epics.group_id AS epics_group_id + FROM epics + GROUP BY epics.group_id + ) epics_max_iids + WHERE internal_ids.last_value < epics_max_iids.maximum_iid + AND namespace_id = epics_max_iids.epics_group_id + AND internal_ids.usage = 4 + SQL + end + + def down + # no-op + end +end diff --git a/db/schema_migrations/20201123161611 b/db/schema_migrations/20201123161611 new file mode 100644 index 00000000000..bcd28f2b4da --- /dev/null +++ b/db/schema_migrations/20201123161611 @@ -0,0 +1 @@ +9d69938cda6db1510ed17d087cc1a582af1e5482d65e4fb457e34011e09c3469
\ No newline at end of file diff --git a/db/schema_migrations/20201201163227 b/db/schema_migrations/20201201163227 new file mode 100644 index 00000000000..0366850ee2f --- /dev/null +++ b/db/schema_migrations/20201201163227 @@ -0,0 +1 @@ +cc978ac56ed177575706436c52125b51915dff97a20ed47ae0c7b16caa837313
\ No newline at end of file diff --git a/db/schema_migrations/20201202081429 b/db/schema_migrations/20201202081429 new file mode 100644 index 00000000000..2a8e170c0ff --- /dev/null +++ b/db/schema_migrations/20201202081429 @@ -0,0 +1 @@ +cbc6bfa122167e9a46edaa14351a73eeb10586fa0eb82f231c792384c9d7986c
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 2a0b85144f2..94f92b2af16 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -16998,6 +16998,7 @@ CREATE TABLE user_details ( cached_markdown_version integer, webauthn_xid text, other_role text, + provisioned_by_group_id bigint, CONSTRAINT check_245664af82 CHECK ((char_length(webauthn_xid) <= 100)), CONSTRAINT check_b132136b01 CHECK ((char_length(other_role) <= 100)) ); @@ -17376,7 +17377,8 @@ CREATE TABLE vulnerability_feedback ( merge_request_id integer, comment_author_id integer, comment text, - comment_timestamp timestamp with time zone + comment_timestamp timestamp with time zone, + finding_uuid uuid ); CREATE SEQUENCE vulnerability_feedback_id_seq @@ -22472,6 +22474,8 @@ CREATE INDEX index_user_custom_attributes_on_key_and_value ON user_custom_attrib CREATE UNIQUE INDEX index_user_custom_attributes_on_user_id_and_key ON user_custom_attributes USING btree (user_id, key); +CREATE INDEX index_user_details_on_provisioned_by_group_id ON user_details USING btree (provisioned_by_group_id); + CREATE UNIQUE INDEX index_user_details_on_user_id ON user_details USING btree (user_id); CREATE INDEX index_user_highest_roles_on_user_id_and_highest_access_level ON user_highest_roles USING btree (user_id, highest_access_level); @@ -23067,6 +23071,9 @@ ALTER TABLE ONLY project_features ALTER TABLE ONLY ci_pipelines ADD CONSTRAINT fk_190998ef09 FOREIGN KEY (external_pull_request_id) REFERENCES external_pull_requests(id) ON DELETE SET NULL; +ALTER TABLE ONLY user_details + ADD CONSTRAINT fk_190e4fcc88 FOREIGN KEY (provisioned_by_group_id) REFERENCES namespaces(id) ON DELETE SET NULL; + ALTER TABLE ONLY vulnerabilities ADD CONSTRAINT fk_1d37cddf91 FOREIGN KEY (epic_id) REFERENCES epics(id) ON DELETE SET NULL; diff --git a/doc/administration/geo/disaster_recovery/index.md b/doc/administration/geo/disaster_recovery/index.md index d1b9578ccce..8344b59a59f 100644 --- a/doc/administration/geo/disaster_recovery/index.md +++ b/doc/administration/geo/disaster_recovery/index.md @@ -133,6 +133,7 @@ Note the following when promoting a secondary: ``` 1. Promote the **secondary** node to the **primary** node. + DANGER: **Warning:** In GitLab 13.2 and 13.3, promoting a secondary node to a primary while the secondary is paused fails. Do not pause replication before promoting a diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index b5f6f828f5a..88e787c0577 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -14144,6 +14144,7 @@ type Mutation { namespaceIncreaseStorageTemporarily(input: NamespaceIncreaseStorageTemporarilyInput!): NamespaceIncreaseStorageTemporarilyPayload oncallScheduleCreate(input: OncallScheduleCreateInput!): OncallScheduleCreatePayload oncallScheduleDestroy(input: OncallScheduleDestroyInput!): OncallScheduleDestroyPayload + oncallScheduleUpdate(input: OncallScheduleUpdateInput!): OncallScheduleUpdatePayload pipelineCancel(input: PipelineCancelInput!): PipelineCancelPayload pipelineDestroy(input: PipelineDestroyInput!): PipelineDestroyPayload pipelineRetry(input: PipelineRetryInput!): PipelineRetryPayload @@ -14839,6 +14840,61 @@ type OncallScheduleDestroyPayload { } """ +Autogenerated input type of OncallScheduleUpdate +""" +input OncallScheduleUpdateInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The description of the on-call schedule + """ + description: String + + """ + The on-call schedule internal ID to update + """ + iid: String! + + """ + The name of the on-call schedule + """ + name: String + + """ + The project to update the on-call schedule in + """ + projectPath: ID! + + """ + The timezone of the on-call schedule + """ + timezone: String +} + +""" +Autogenerated return type of OncallScheduleUpdate +""" +type OncallScheduleUpdatePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!]! + + """ + The on-call schedule + """ + oncallSchedule: IncidentManagementOncallSchedule +} + +""" Represents a package """ type Package { diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 35480fce34d..228c7d1b37c 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -41159,6 +41159,33 @@ "deprecationReason": null }, { + "name": "oncallScheduleUpdate", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "OncallScheduleUpdateInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "OncallScheduleUpdatePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "pipelineCancel", "description": null, "args": [ @@ -44105,6 +44132,152 @@ "possibleTypes": null }, { + "kind": "INPUT_OBJECT", + "name": "OncallScheduleUpdateInput", + "description": "Autogenerated input type of OncallScheduleUpdate", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project to update the on-call schedule in", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "iid", + "description": "The on-call schedule internal ID to update", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "name", + "description": "The name of the on-call schedule", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "description", + "description": "The description of the on-call schedule", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "timezone", + "description": "The timezone of the on-call schedule", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "OncallScheduleUpdatePayload", + "description": "Autogenerated return type of OncallScheduleUpdate", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Errors encountered during execution of the mutation.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "oncallSchedule", + "description": "The on-call schedule", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "IncidentManagementOncallSchedule", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { "kind": "OBJECT", "name": "Package", "description": "Represents a package", diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index a1b9535cd6e..eb3661854d8 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -2272,6 +2272,16 @@ Autogenerated return type of OncallScheduleDestroy. | `errors` | String! => Array | Errors encountered during execution of the mutation. | | `oncallSchedule` | IncidentManagementOncallSchedule | The on-call schedule | +### OncallScheduleUpdatePayload + +Autogenerated return type of OncallScheduleUpdate. + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Errors encountered during execution of the mutation. | +| `oncallSchedule` | IncidentManagementOncallSchedule | The on-call schedule | + ### Package Represents a package. diff --git a/doc/development/documentation/styleguide/index.md b/doc/development/documentation/styleguide/index.md index 99b041ba5c7..73c83c614b5 100644 --- a/doc/development/documentation/styleguide/index.md +++ b/doc/development/documentation/styleguide/index.md @@ -587,7 +587,7 @@ tenses, words, and phrases: <!-- vale gitlab.Simplicity = NO --> - Avoid words like _easily_, _simply_, _handy_, and _useful._ If the user doesn't find the process to be these things, we lose their trust. -<!-- vale gitlab.Simplicity = NO --> +<!-- vale gitlab.Simplicity = YES --> ### Word usage clarifications diff --git a/doc/topics/autodevops/requirements.md b/doc/topics/autodevops/requirements.md index c3ae2a53b60..2f457829e11 100644 --- a/doc/topics/autodevops/requirements.md +++ b/doc/topics/autodevops/requirements.md @@ -6,8 +6,9 @@ info: To determine the technical writer assigned to the Stage/Group associated w # Requirements for Auto DevOps -You can set up Auto DevOps for [Kubernetes](#auto-devops-requirements-for-kubernetes) -or [Amazon Elastic Container Service (ECS)](#auto-devops-requirements-for-amazon-ecs). +You can set up Auto DevOps for [Kubernetes](#auto-devops-requirements-for-kubernetes), +[Amazon Elastic Container Service (ECS)](#auto-devops-requirements-for-amazon-ecs), +or [Amazon Cloud Compute](#auto-devops-requirements-for-amazon-ecs). For more information about Auto DevOps, see [the main Auto DevOps page](index.md) or the [quick start guide](quick_start_guide.md). @@ -140,3 +141,14 @@ it on its own. This template is designed to be used with Auto DevOps only. It ma unexpectedly causing your pipeline to fail if included on its own. Also, the job names within this template may also change. Do not override these jobs' names in your own pipeline, as the override stops working when the name changes. + +## Auto DevOps requirements for Amazon EC2 + +[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216008) in GitLab 13.6. + +You can target [AWS EC2](../../ci/cloud_deployment/index.md) +as a deployment platform instead of Kubernetes. To use Auto DevOps with AWS EC2, you must add a +specific environment variable. + +For more details, see [Custom build job for Auto DevOps](../../ci/cloud_deployment/index.md#custom-build-job-for-auto-devops) +for deployments to AWS EC2. diff --git a/doc/user/packages/dependency_proxy/index.md b/doc/user/packages/dependency_proxy/index.md index 8205820b0e3..46da5f64658 100644 --- a/doc/user/packages/dependency_proxy/index.md +++ b/doc/user/packages/dependency_proxy/index.md @@ -8,6 +8,8 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/7934) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.11. > - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/273655) to [GitLab Core](https://about.gitlab.com/pricing/) in GitLab 13.6. +> - [Support for private groups](https://gitlab.com/gitlab-org/gitlab/-/issues/11582) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.7. +> - Anonymous access to images in public groups is no longer available starting in [GitLab Premium](https://about.gitlab.com/pricing/) 13.7. The GitLab Dependency Proxy is a local proxy you can use for your frequently-accessed upstream images. @@ -17,9 +19,7 @@ upstream image from a registry, acting as a pull-through cache. ## Prerequisites -To use the Dependency Proxy: - -- Your group must be public. Authentication for private groups is [not supported yet](https://gitlab.com/gitlab-org/gitlab/-/issues/11582). +The Dependency Proxy must be [enabled by an administrator](../../../administration/packages/dependency_proxy.md). ### Supported images and packages @@ -58,6 +58,56 @@ Prerequisites: - Docker Hub must be available. Follow [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/241639) for progress on accessing images when Docker Hub is down. +### Authenticate with the Dependency Proxy + +Because the Dependency Proxy is storing Docker images in a space associated with your group, +you must authenticate against the Dependency Proxy. + +Follow the [instructions for using images from a private registry](../../../ci/docker/using_docker_images.md#define-an-image-from-a-private-container-registry), +but instead of using `registry.example.com:5000`, use your GitLab domain with no port `gitlab.example.com`. + +For example, to manually log in: + +```shell +docker login gitlab.example.com --username my_username --password my_password +``` + +You can authenticate using: + +- Your GitLab username and password. +- A [personal access token](../../../user/profile/personal_access_tokens.md) with the scope set to `read_registry` and `write_registry`. + +#### Authenticate within CI/CD + +To work with the Dependency Proxy in [GitLab CI/CD](../../../ci/README.md), you can use +`CI_REGISTRY_USER` and `CI_REGISTRY_PASSWORD`. + +```shell +docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" gitlab.example.com +``` + +You can use other [predefined variables](../../../ci/variables/predefined_variables.md) +to further generalize your CI script. For example: + +```yaml +# .gitlab-ci.yml + +dependency-proxy-pull-master: + # Official docker image. + image: docker:latest + stage: build + services: + - docker:dind + before_script: + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_SERVER_HOST":"$CI_SERVER_PORT" + script: + - docker pull "$CI_SERVER_HOST":"$CI_SERVER_PORT"/groupname/dependency_proxy/containers/alpine:latest +``` + +You can also use [custom environment variables](../../../ci/variables/README.md#custom-environment-variables) to store and access your personal access token or other valid credentials. + +### Store a Docker image in Dependency Proxy cache + To store a Docker image in Dependency Proxy storage: 1. Go to your group's **Packages & Registries > Dependency Proxy**. diff --git a/lib/gitlab/ci/templates/Managed-Cluster-Applications.gitlab-ci.yml b/lib/gitlab/ci/templates/Managed-Cluster-Applications.gitlab-ci.yml index 3f62d92ad13..23dfeda31cc 100644 --- a/lib/gitlab/ci/templates/Managed-Cluster-Applications.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Managed-Cluster-Applications.gitlab-ci.yml @@ -1,6 +1,6 @@ apply: stage: deploy - image: "registry.gitlab.com/gitlab-org/cluster-integration/cluster-applications:v0.34.1" + image: "registry.gitlab.com/gitlab-org/cluster-integration/cluster-applications:v0.36.0" environment: name: production variables: diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7a2e5473490..fcf397e3162 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -19085,18 +19085,39 @@ msgstr "" msgid "OnCallSchedules|Add a schedule" msgstr "" +msgid "OnCallSchedules|Add rotation" +msgstr "" + msgid "OnCallSchedules|Add schedule" msgstr "" msgid "OnCallSchedules|Create on-call schedules in GitLab" msgstr "" +msgid "OnCallSchedules|Failed to add rotation" +msgstr "" + msgid "OnCallSchedules|Failed to add schedule" msgstr "" +msgid "OnCallSchedules|Rotation length" +msgstr "" + +msgid "OnCallSchedules|Rotation name cannot be empty" +msgstr "" + +msgid "OnCallSchedules|Rotation participants cannot be empty" +msgstr "" + +msgid "OnCallSchedules|Rotation start date cannot be empty" +msgstr "" + msgid "OnCallSchedules|Route alerts directly to specific members of your team" msgstr "" +msgid "OnCallSchedules|Select participant" +msgstr "" + msgid "OnCallSchedules|Select timezone" msgstr "" @@ -19414,6 +19435,9 @@ msgstr "" msgid "Owner" msgstr "" +msgid "PST" +msgstr "" + msgid "Package Registry" msgstr "" @@ -25622,9 +25646,6 @@ msgstr "" msgid "Something went wrong, unable to search projects" msgstr "" -msgid "Something went wrong." -msgstr "" - msgid "Something went wrong. Please try again." msgstr "" @@ -26042,6 +26063,9 @@ msgstr "" msgid "Starts at (UTC)" msgstr "" +msgid "Starts on" +msgstr "" + msgid "State your message to activate" msgstr "" @@ -31405,6 +31429,9 @@ msgstr "" msgid "You have insufficient permissions to remove this HTTP integration" msgstr "" +msgid "You have insufficient permissions to update an on-call schedule for this project" +msgstr "" + msgid "You have insufficient permissions to update this HTTP integration" msgstr "" @@ -31920,6 +31947,9 @@ msgstr "" msgid "assign yourself" msgstr "" +msgid "at" +msgstr "" + msgid "at risk" msgstr "" diff --git a/rubocop/rubocop-migrations.yml b/rubocop/rubocop-migrations.yml index 5a5a78bf7f0..c175638ca2d 100644 --- a/rubocop/rubocop-migrations.yml +++ b/rubocop/rubocop-migrations.yml @@ -25,6 +25,7 @@ Migration/UpdateLargeTable: - :project_authorizations - :projects - :project_ci_cd_settings + - :project_settings - :project_features - :push_event_payloads - :resource_label_events diff --git a/scripts/update-workhorse b/scripts/update-workhorse index 0955f6a671a..3ada37e70a1 100755 --- a/scripts/update-workhorse +++ b/scripts/update-workhorse @@ -2,6 +2,7 @@ set -e WORKHORSE_DIR=workhorse/ WORKHORSE_REF="v$(cat GITLAB_WORKHORSE_VERSION)" +WORKHORSE_URL=${GITLAB_WORKHORSE_URL:-https://gitlab.com/gitlab-org/gitlab-workhorse.git} if [ $# -gt 1 ] || ([ $# = 1 ] && [ x$1 != xcheck ]); then echo "Usage: update-workhorse [check]" @@ -15,7 +16,7 @@ if [ -n "$clean" ] ; then exit 1 fi -git fetch https://gitlab.com/gitlab-org/gitlab-workhorse.git "$WORKHORSE_REF" +git fetch "$WORKHORSE_URL" "$WORKHORSE_REF" git rm -rf --quiet -- "$WORKHORSE_DIR" git read-tree --prefix="$WORKHORSE_DIR" -u FETCH_HEAD diff --git a/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb b/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb new file mode 100644 index 00000000000..857e0570621 --- /dev/null +++ b/spec/controllers/groups/dependency_proxy_auth_controller_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::DependencyProxyAuthController do + include DependencyProxyHelpers + + describe 'GET #authenticate' do + subject { get :authenticate } + + context 'feature flag disabled' do + before do + stub_feature_flags(dependency_proxy_for_private_groups: false) + end + + it 'returns successfully', :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(:success) + end + end + + context 'without JWT' do + it 'returns unauthorized with oauth realm', :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + expect(response.headers['WWW-Authenticate']).to eq DependencyProxy::Registry.authenticate_header + end + end + + context 'with valid JWT' do + let_it_be(:user) { create(:user) } + let(:jwt) { build_jwt(user) } + let(:token_header) { "Bearer #{jwt.encoded}" } + + before do + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:success) } + end + + context 'with invalid JWT' do + context 'bad user' do + let(:jwt) { build_jwt(double('bad_user', id: 999)) } + let(:token_header) { "Bearer #{jwt.encoded}" } + + before do + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'token with no user id' do + let(:token_header) { "Bearer #{build_jwt.encoded}" } + + before do + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'expired token' do + let_it_be(:user) { create(:user) } + let(:jwt) { build_jwt(user, expire_time: Time.zone.now - 1.hour) } + let(:token_header) { "Bearer #{jwt.encoded}" } + + before do + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:unauthorized) } + end + end + end +end diff --git a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb index 615b56ff22f..87956cc7287 100644 --- a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb +++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb @@ -3,8 +3,77 @@ require 'spec_helper' RSpec.describe Groups::DependencyProxyForContainersController do + include HttpBasicAuthHelpers + include DependencyProxyHelpers + + let_it_be(:user) { create(:user) } let(:group) { create(:group) } let(:token_response) { { status: :success, token: 'abcd1234' } } + let(:jwt) { build_jwt(user) } + let(:token_header) { "Bearer #{jwt.encoded}" } + + shared_examples 'without a token' do + before do + request.headers['HTTP_AUTHORIZATION'] = nil + end + + context 'feature flag disabled' do + before do + stub_feature_flags(dependency_proxy_for_private_groups: false) + end + + it { is_expected.to have_gitlab_http_status(:ok) } + end + + it { is_expected.to have_gitlab_http_status(:unauthorized) } + end + + shared_examples 'feature flag disabled with private group' do + before do + stub_feature_flags(dependency_proxy_for_private_groups: false) + end + + it 'redirects', :aggregate_failures do + group.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + + subject + + expect(response).to have_gitlab_http_status(:redirect) + expect(response.location).to end_with(new_user_session_path) + end + end + + shared_examples 'without permission' do + context 'with invalid user' do + before do + user = double('bad_user', id: 999) + token_header = "Bearer #{build_jwt(user).encoded}" + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'with valid user that does not have access' do + let(:group) { create(:group, :private) } + + before do + user = double('bad_user', id: 999) + token_header = "Bearer #{build_jwt(user).encoded}" + request.headers['HTTP_AUTHORIZATION'] = token_header + end + + it { is_expected.to have_gitlab_http_status(:not_found) } + end + + context 'when user is not found' do + before do + allow(User).to receive(:find).and_return(nil) + end + + it { is_expected.to have_gitlab_http_status(:unauthorized) } + end + end shared_examples 'not found when disabled' do context 'feature disabled' do @@ -27,6 +96,8 @@ RSpec.describe Groups::DependencyProxyForContainersController do allow_next_instance_of(DependencyProxy::RequestTokenService) do |instance| allow(instance).to receive(:execute).and_return(token_response) end + + request.headers['HTTP_AUTHORIZATION'] = token_header end describe 'GET #manifest' do @@ -46,6 +117,10 @@ RSpec.describe Groups::DependencyProxyForContainersController do enable_dependency_proxy end + it_behaves_like 'without a token' + it_behaves_like 'without permission' + it_behaves_like 'feature flag disabled with private group' + context 'remote token request fails' do let(:token_response) do { @@ -113,6 +188,10 @@ RSpec.describe Groups::DependencyProxyForContainersController do enable_dependency_proxy end + it_behaves_like 'without a token' + it_behaves_like 'without permission' + it_behaves_like 'feature flag disabled with private group' + context 'remote blob request fails' do let(:blob_response) do { diff --git a/spec/controllers/profiles/gpg_keys_controller_spec.rb b/spec/controllers/profiles/gpg_keys_controller_spec.rb new file mode 100644 index 00000000000..1860bb0c93b --- /dev/null +++ b/spec/controllers/profiles/gpg_keys_controller_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Profiles::GpgKeysController do + let(:user) { create(:user, email: GpgHelpers::User1.emails[0]) } + + describe 'POST #create' do + before do + sign_in(user) + end + + it 'creates a new key' do + expect do + post :create, params: { gpg_key: build(:gpg_key).attributes } + end.to change { GpgKey.count }.by(1) + end + end + + describe "#get_keys" do + describe "non existent user" do + it "does not generally work" do + get :get_keys, params: { username: 'not-existent' } + + expect(response).not_to be_successful + end + end + + describe "user with no keys" do + it "does generally work" do + get :get_keys, params: { username: user.username } + + expect(response).to be_successful + end + + it "renders all keys separated with a new line" do + get :get_keys, params: { username: user.username } + + expect(response.body).to eq("") + end + + it "responds with text/plain content type" do + get :get_keys, params: { username: user.username } + + expect(response.content_type).to eq("text/plain") + end + end + + describe "user with keys" do + let!(:gpg_key) { create(:gpg_key, user: user) } + let!(:another_gpg_key) { create(:another_gpg_key, user: user) } + + describe "while signed in" do + before do + sign_in(user) + end + + it "does generally work" do + get :get_keys, params: { username: user.username } + + expect(response).to be_successful + end + + it "renders all verified keys separated with a new line" do + get :get_keys, params: { username: user.username } + + expect(response.body).not_to eq('') + expect(response.body).to eq(user.gpg_keys.select(&:verified?).map(&:key).join("\n")) + + expect(response.body).to include(gpg_key.key) + expect(response.body).to include(another_gpg_key.key) + end + + it "responds with text/plain content type" do + get :get_keys, params: { username: user.username } + + expect(response.content_type).to eq("text/plain") + end + end + + describe 'when logged out' do + before do + sign_out(user) + end + + it "still does generally work" do + get :get_keys, params: { username: user.username } + + expect(response).to be_successful + end + + it "renders all verified keys separated with a new line" do + get :get_keys, params: { username: user.username } + + expect(response.body).not_to eq('') + expect(response.body).to eq(user.gpg_keys.map(&:key).join("\n")) + + expect(response.body).to include(gpg_key.key) + expect(response.body).to include(another_gpg_key.key) + end + + it "responds with text/plain content type" do + get :get_keys, params: { username: user.username } + + expect(response.content_type).to eq("text/plain") + end + end + + describe 'when revoked' do + before do + sign_in(user) + another_gpg_key.revoke + end + + it "doesn't render revoked keys" do + get :get_keys, params: { username: user.username } + + expect(response.body).not_to eq('') + + expect(response.body).to include(gpg_key.key) + expect(response.body).not_to include(another_gpg_key.key) + end + + it "doesn't render revoked keys for non-authorized users" do + sign_out(user) + get :get_keys, params: { username: user.username } + + expect(response.body).not_to eq('') + + expect(response.body).to include(gpg_key.key) + expect(response.body).not_to include(another_gpg_key.key) + end + end + end + end +end diff --git a/spec/controllers/repositories/git_http_controller_spec.rb b/spec/controllers/repositories/git_http_controller_spec.rb index 307c5b4725a..1cebb6a4804 100644 --- a/spec/controllers/repositories/git_http_controller_spec.rb +++ b/spec/controllers/repositories/git_http_controller_spec.rb @@ -167,6 +167,14 @@ RSpec.describe Repositories::GitHttpController do Projects::DailyStatisticsFinder.new(container).total_fetch_count }.from(0).to(1) end + + it 'records a namespace onboarding progress action' do + expect_next_instance_of(OnboardingProgressService) do |service| + expect(service).to receive(:execute).with(action: :git_read) + end + + send_request + end end end end diff --git a/spec/factories/gpg_keys.rb b/spec/factories/gpg_keys.rb index 9f321643174..5967d9ba9d3 100644 --- a/spec/factories/gpg_keys.rb +++ b/spec/factories/gpg_keys.rb @@ -10,5 +10,10 @@ FactoryBot.define do factory :gpg_key_with_subkeys do key { GpgHelpers::User1.public_key_with_extra_signing_key } end + + factory :another_gpg_key do + key { GpgHelpers::User1.public_key2 } + user + end end end diff --git a/spec/features/groups/dependency_proxy_spec.rb b/spec/features/groups/dependency_proxy_spec.rb index 9bbfdc488fb..51371ddc532 100644 --- a/spec/features/groups/dependency_proxy_spec.rb +++ b/spec/features/groups/dependency_proxy_spec.rb @@ -79,13 +79,19 @@ RSpec.describe 'Group Dependency Proxy' do sign_in(developer) end - context 'group is private' do - let(:group) { create(:group, :private) } + context 'feature flag is disabled' do + before do + stub_feature_flags(dependency_proxy_for_private_groups: false) + end - it 'informs user that feature is only available for public groups' do - visit path + context 'group is private' do + let(:group) { create(:group, :private) } - expect(page).to have_content('Dependency proxy feature is limited to public groups for now.') + it 'informs user that feature is only available for public groups' do + visit path + + expect(page).to have_content('Dependency proxy feature is limited to public groups for now.') + end end end diff --git a/spec/features/issuables/close_reopen_report_toggle_spec.rb b/spec/features/merge_request/close_reopen_report_toggle_spec.rb index 30cabce8fa2..8a4277d87c9 100644 --- a/spec/features/issuables/close_reopen_report_toggle_spec.rb +++ b/spec/features/merge_request/close_reopen_report_toggle_spec.rb @@ -7,44 +7,6 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do let(:user) { create(:user) } - shared_examples 'an issuable close/reopen/report toggle' do - let(:container) { find('.issuable-close-dropdown') } - let(:human_model_name) { issuable.model_name.human.downcase } - - it 'shows toggle' do - expect(page).to have_button("Close #{human_model_name}") - expect(page).to have_selector('.issuable-close-dropdown') - end - - it 'opens a dropdown when toggle is clicked' do - container.find('.dropdown-toggle').click - - expect(container).to have_selector('.dropdown-menu') - expect(container).to have_content("Close #{human_model_name}") - expect(container).to have_content('Report abuse') - expect(container).to have_content("Report #{human_model_name.pluralize} that are abusive, inappropriate or spam.") - - if issuable.is_a?(MergeRequest) - page.within('.js-issuable-close-dropdown') do - expect(page).to have_link('Close merge request') - end - else - expect(container).to have_selector('.close-item.droplab-item-selected') - end - - expect(container).to have_selector('.report-item') - expect(container).not_to have_selector('.report-item.droplab-item-selected') - expect(container).not_to have_selector('.reopen-item') - end - - it 'links to Report Abuse' do - container.find('.dropdown-toggle').click - container.find('.report-abuse-link').click - - expect(page).to have_content('Report abuse to admin') - end - end - context 'on a merge request' do let(:container) { find('.detail-page-header-actions') } let(:project) { create(:project, :repository) } @@ -60,7 +22,22 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do visit project_merge_request_path(project, issuable) end - it_behaves_like 'an issuable close/reopen/report toggle' + context 'close/reopen/report toggle' do + it 'opens a dropdown when toggle is clicked' do + click_button 'Toggle dropdown' + + expect(container).to have_link("Close merge request") + expect(container).to have_link('Report abuse') + expect(container).to have_text("Report merge requests that are abusive, inappropriate or spam.") + end + + it 'links to Report Abuse' do + click_button 'Toggle dropdown' + click_link 'Report abuse' + + expect(page).to have_content('Report abuse to admin') + end + end context 'when the merge request is open' do let(:issuable) { create(:merge_request, :opened, source_project: project) } diff --git a/spec/features/issuables/merge_request_discussion_lock_spec.rb b/spec/features/merge_request/merge_request_discussion_lock_spec.rb index 4e0265839f6..4e0265839f6 100644 --- a/spec/features/issuables/merge_request_discussion_lock_spec.rb +++ b/spec/features/merge_request/merge_request_discussion_lock_spec.rb diff --git a/spec/features/merge_request/user_reopens_merge_request_spec.rb b/spec/features/merge_request/user_reopens_merge_request_spec.rb index 4a05a3be59a..7cb8ca280cc 100644 --- a/spec/features/merge_request/user_reopens_merge_request_spec.rb +++ b/spec/features/merge_request/user_reopens_merge_request_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'User reopens a merge requests', :js do end it 'reopens a merge request' do - find('.js-issuable-close-dropdown .dropdown-toggle').click + find('.detail-page-header .dropdown-toggle').click click_link('Reopen merge request', match: :first) diff --git a/spec/features/merge_requests/user_squashes_merge_request_spec.rb b/spec/features/merge_request/user_squashes_merge_request_spec.rb index 84964bd0637..84964bd0637 100644 --- a/spec/features/merge_requests/user_squashes_merge_request_spec.rb +++ b/spec/features/merge_request/user_squashes_merge_request_spec.rb diff --git a/spec/features/merge_requests/user_views_diffs_commit_spec.rb b/spec/features/merge_request/user_views_diffs_commit_spec.rb index cf92603972e..cf92603972e 100644 --- a/spec/features/merge_requests/user_views_diffs_commit_spec.rb +++ b/spec/features/merge_request/user_views_diffs_commit_spec.rb diff --git a/spec/features/merge_request/user_sees_empty_state_spec.rb b/spec/features/merge_requests/user_sees_empty_state_spec.rb index ac07b31731d..ac07b31731d 100644 --- a/spec/features/merge_request/user_sees_empty_state_spec.rb +++ b/spec/features/merge_requests/user_sees_empty_state_spec.rb diff --git a/spec/frontend/fixtures/static/whats_new_notification.html b/spec/frontend/fixtures/static/whats_new_notification.html new file mode 100644 index 00000000000..30d5eea91cc --- /dev/null +++ b/spec/frontend/fixtures/static/whats_new_notification.html @@ -0,0 +1,6 @@ +<div class='whats-new-notification-fixture-root'> + <div class='app' data-storage-key='storage-key'></div> + <div class='header-help'> + <div class='js-whats-new-notification-count'></div> + </div> +</div> diff --git a/spec/frontend/helpers/vuex_action_helper.js b/spec/frontend/helpers/vuex_action_helper.js index 6c3569a2247..64dd3888d47 100644 --- a/spec/frontend/helpers/vuex_action_helper.js +++ b/spec/frontend/helpers/vuex_action_helper.js @@ -4,7 +4,7 @@ const noop = () => {}; * Helper for testing action with expected mutations inspired in * https://vuex.vuejs.org/en/testing.html * - * @param {Function} action to be tested + * @param {(Function|Object)} action to be tested, or object of named parameters * @param {Object} payload will be provided to the action * @param {Object} state will be provided to the action * @param {Array} [expectedMutations=[]] mutations expected to be committed @@ -39,15 +39,42 @@ const noop = () => {}; * [], // expected actions * ).then(done) * .catch(done.fail); + * + * @example + * await testAction({ + * action: actions.actionName, + * payload: { deleteListId: 1 }, + * state: { lists: [1, 2, 3] }, + * expectedMutations: [ { type: types.MUTATION} ], + * expectedActions: [], + * }) */ export default ( - action, - payload, - state, - expectedMutations = [], - expectedActions = [], - done = noop, + actionArg, + payloadArg, + stateArg, + expectedMutationsArg = [], + expectedActionsArg = [], + doneArg = noop, ) => { + let action = actionArg; + let payload = payloadArg; + let state = stateArg; + let expectedMutations = expectedMutationsArg; + let expectedActions = expectedActionsArg; + let done = doneArg; + + if (typeof actionArg !== 'function') { + ({ + action, + payload, + state, + expectedMutations = [], + expectedActions = [], + done = noop, + } = actionArg); + } + const mutations = []; const actions = []; diff --git a/spec/frontend/helpers/vuex_action_helper_spec.js b/spec/frontend/helpers/vuex_action_helper_spec.js index 61d05762a04..4d7bf21820a 100644 --- a/spec/frontend/helpers/vuex_action_helper_spec.js +++ b/spec/frontend/helpers/vuex_action_helper_spec.js @@ -1,166 +1,174 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; -import testAction from './vuex_action_helper'; - -describe('VueX test helper (testAction)', () => { - let originalExpect; - let assertion; - let mock; - const noop = () => {}; - - beforeEach(() => { - mock = new MockAdapter(axios); - /** - * In order to test the helper properly, we need to overwrite the Jest - * `expect` helper. We test that the testAction helper properly passes the - * dispatched actions/committed mutations to the Jest helper. - */ - originalExpect = expect; - assertion = null; - global.expect = actual => ({ - toEqual: () => { - originalExpect(actual).toEqual(assertion); - }, - }); - }); +import testActionFn from './vuex_action_helper'; - afterEach(() => { - mock.restore(); - global.expect = originalExpect; - }); +const testActionFnWithOptionsArg = (...args) => { + const [action, payload, state, expectedMutations, expectedActions, done] = args; + return testActionFn({ action, payload, state, expectedMutations, expectedActions, done }); +}; - it('properly passes state and payload to action', () => { - const exampleState = { FOO: 12, BAR: 3 }; - const examplePayload = { BAZ: 73, BIZ: 55 }; +describe.each([testActionFn, testActionFnWithOptionsArg])( + 'VueX test helper (testAction)', + testAction => { + let originalExpect; + let assertion; + let mock; + const noop = () => {}; - const action = ({ state }, payload) => { - originalExpect(state).toEqual(exampleState); - originalExpect(payload).toEqual(examplePayload); - }; + beforeEach(() => { + mock = new MockAdapter(axios); + /** + * In order to test the helper properly, we need to overwrite the Jest + * `expect` helper. We test that the testAction helper properly passes the + * dispatched actions/committed mutations to the Jest helper. + */ + originalExpect = expect; + assertion = null; + global.expect = actual => ({ + toEqual: () => { + originalExpect(actual).toEqual(assertion); + }, + }); + }); - assertion = { mutations: [], actions: [] }; + afterEach(() => { + mock.restore(); + global.expect = originalExpect; + }); - testAction(action, examplePayload, exampleState); - }); + it('properly passes state and payload to action', () => { + const exampleState = { FOO: 12, BAR: 3 }; + const examplePayload = { BAZ: 73, BIZ: 55 }; - describe('given a sync action', () => { - it('mocks committing mutations', () => { - const action = ({ commit }) => { - commit('MUTATION'); + const action = ({ state }, payload) => { + originalExpect(state).toEqual(exampleState); + originalExpect(payload).toEqual(examplePayload); }; - assertion = { mutations: [{ type: 'MUTATION' }], actions: [] }; + assertion = { mutations: [], actions: [] }; - testAction(action, null, {}, assertion.mutations, assertion.actions, noop); + testAction(action, examplePayload, exampleState); }); - it('mocks dispatching actions', () => { - const action = ({ dispatch }) => { - dispatch('ACTION'); - }; + describe('given a sync action', () => { + it('mocks committing mutations', () => { + const action = ({ commit }) => { + commit('MUTATION'); + }; - assertion = { actions: [{ type: 'ACTION' }], mutations: [] }; + assertion = { mutations: [{ type: 'MUTATION' }], actions: [] }; - testAction(action, null, {}, assertion.mutations, assertion.actions, noop); - }); + testAction(action, null, {}, assertion.mutations, assertion.actions, noop); + }); - it('works with done callback once finished', done => { - assertion = { mutations: [], actions: [] }; + it('mocks dispatching actions', () => { + const action = ({ dispatch }) => { + dispatch('ACTION'); + }; - testAction(noop, null, {}, assertion.mutations, assertion.actions, done); - }); + assertion = { actions: [{ type: 'ACTION' }], mutations: [] }; - it('returns a promise', done => { - assertion = { mutations: [], actions: [] }; + testAction(action, null, {}, assertion.mutations, assertion.actions, noop); + }); - testAction(noop, null, {}, assertion.mutations, assertion.actions) - .then(done) - .catch(done.fail); - }); - }); - - describe('given an async action (returning a promise)', () => { - let lastError; - const data = { FOO: 'BAR' }; - - const asyncAction = ({ commit, dispatch }) => { - dispatch('ACTION'); - - return axios - .get(TEST_HOST) - .catch(error => { - commit('ERROR'); - lastError = error; - throw error; - }) - .then(() => { - commit('SUCCESS'); - return data; - }); - }; + it('works with done callback once finished', done => { + assertion = { mutations: [], actions: [] }; - beforeEach(() => { - lastError = null; + testAction(noop, null, {}, assertion.mutations, assertion.actions, done); + }); + + it('returns a promise', done => { + assertion = { mutations: [], actions: [] }; + + testAction(noop, null, {}, assertion.mutations, assertion.actions) + .then(done) + .catch(done.fail); + }); }); - it('works with done callback once finished', done => { - mock.onGet(TEST_HOST).replyOnce(200, 42); + describe('given an async action (returning a promise)', () => { + let lastError; + const data = { FOO: 'BAR' }; - assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; + const asyncAction = ({ commit, dispatch }) => { + dispatch('ACTION'); - testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done); - }); + return axios + .get(TEST_HOST) + .catch(error => { + commit('ERROR'); + lastError = error; + throw error; + }) + .then(() => { + commit('SUCCESS'); + return data; + }); + }; - it('returns original data of successful promise while checking actions/mutations', done => { - mock.onGet(TEST_HOST).replyOnce(200, 42); + beforeEach(() => { + lastError = null; + }); - assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; + it('works with done callback once finished', done => { + mock.onGet(TEST_HOST).replyOnce(200, 42); - testAction(asyncAction, null, {}, assertion.mutations, assertion.actions) - .then(res => { - originalExpect(res).toEqual(data); - done(); - }) - .catch(done.fail); - }); + assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; + + testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done); + }); + + it('returns original data of successful promise while checking actions/mutations', done => { + mock.onGet(TEST_HOST).replyOnce(200, 42); - it('returns original error of rejected promise while checking actions/mutations', done => { - mock.onGet(TEST_HOST).replyOnce(500, ''); + assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; - assertion = { mutations: [{ type: 'ERROR' }], actions: [{ type: 'ACTION' }] }; + testAction(asyncAction, null, {}, assertion.mutations, assertion.actions) + .then(res => { + originalExpect(res).toEqual(data); + done(); + }) + .catch(done.fail); + }); - testAction(asyncAction, null, {}, assertion.mutations, assertion.actions) - .then(done.fail) - .catch(error => { - originalExpect(error).toBe(lastError); - done(); - }); + it('returns original error of rejected promise while checking actions/mutations', done => { + mock.onGet(TEST_HOST).replyOnce(500, ''); + + assertion = { mutations: [{ type: 'ERROR' }], actions: [{ type: 'ACTION' }] }; + + testAction(asyncAction, null, {}, assertion.mutations, assertion.actions) + .then(done.fail) + .catch(error => { + originalExpect(error).toBe(lastError); + done(); + }); + }); }); - }); - it('works with async actions not returning promises', done => { - const data = { FOO: 'BAR' }; + it('works with async actions not returning promises', done => { + const data = { FOO: 'BAR' }; - const asyncAction = ({ commit, dispatch }) => { - dispatch('ACTION'); + const asyncAction = ({ commit, dispatch }) => { + dispatch('ACTION'); - axios - .get(TEST_HOST) - .then(() => { - commit('SUCCESS'); - return data; - }) - .catch(error => { - commit('ERROR'); - throw error; - }); - }; + axios + .get(TEST_HOST) + .then(() => { + commit('SUCCESS'); + return data; + }) + .catch(error => { + commit('ERROR'); + throw error; + }); + }; - mock.onGet(TEST_HOST).replyOnce(200, 42); + mock.onGet(TEST_HOST).replyOnce(200, 42); - assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; + assertion = { mutations: [{ type: 'SUCCESS' }], actions: [{ type: 'ACTION' }] }; - testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done); - }); -}); + testAction(asyncAction, null, {}, assertion.mutations, assertion.actions, done); + }); + }, +); diff --git a/spec/frontend/notes/components/multiline_comment_utils_spec.js b/spec/frontend/notes/components/multiline_comment_utils_spec.js index af4394cc648..99b33e7cd5f 100644 --- a/spec/frontend/notes/components/multiline_comment_utils_spec.js +++ b/spec/frontend/notes/components/multiline_comment_utils_spec.js @@ -34,8 +34,17 @@ describe('Multiline comment utilities', () => { expect(getSymbol(type)).toEqual(result); }); }); - describe('getCommentedLines', () => { - const diffLines = [{ line_code: '1' }, { line_code: '2' }, { line_code: '3' }]; + const inlineDiffLines = [{ line_code: '1' }, { line_code: '2' }, { line_code: '3' }]; + const parallelDiffLines = inlineDiffLines.map(line => ({ + left: { ...line }, + right: { ...line }, + })); + + describe.each` + view | diffLines + ${'inline'} | ${inlineDiffLines} + ${'parallel'} | ${parallelDiffLines} + `('getCommentedLines $view view', ({ diffLines }) => { it('returns a default object when `selectedCommentPosition` is not provided', () => { expect(getCommentedLines(undefined, diffLines)).toEqual({ startLine: 4, endLine: 4 }); }); diff --git a/spec/frontend/whats_new/utils/notification_spec.js b/spec/frontend/whats_new/utils/notification_spec.js new file mode 100644 index 00000000000..e3e390f4394 --- /dev/null +++ b/spec/frontend/whats_new/utils/notification_spec.js @@ -0,0 +1,55 @@ +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { setNotification, getStorageKey } from '~/whats_new/utils/notification'; + +describe('~/whats_new/utils/notification', () => { + useLocalStorageSpy(); + + let wrapper; + + const findNotificationEl = () => wrapper.querySelector('.header-help'); + const findNotificationCountEl = () => wrapper.querySelector('.js-whats-new-notification-count'); + const getAppEl = () => wrapper.querySelector('.app'); + + beforeEach(() => { + loadFixtures('static/whats_new_notification.html'); + wrapper = document.querySelector('.whats-new-notification-fixture-root'); + }); + + afterEach(() => { + wrapper.remove(); + }); + + describe('setNotification', () => { + const subject = () => setNotification(getAppEl()); + + it("when storage key doesn't exist it adds notifications class", () => { + const notificationEl = findNotificationEl(); + + expect(notificationEl.classList).not.toContain('with-notifications'); + + subject(); + + expect(findNotificationCountEl()).toExist(); + expect(notificationEl.classList).toContain('with-notifications'); + }); + + it('removes class and count element when storage key is true', () => { + const notificationEl = findNotificationEl(); + notificationEl.classList.add('with-notifications'); + localStorage.setItem('storage-key', 'false'); + + expect(findNotificationCountEl()).toExist(); + + subject(); + + expect(findNotificationCountEl()).not.toExist(); + expect(notificationEl.classList).not.toContain('with-notifications'); + }); + }); + + describe('getStorageKey', () => { + it('retrieves the storage key data attribute from the el', () => { + expect(getStorageKey(getAppEl())).toBe('storage-key'); + }); + }); +}); diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb index 153dc19335b..377e2c43a72 100644 --- a/spec/helpers/merge_requests_helper_spec.rb +++ b/spec/helpers/merge_requests_helper_spec.rb @@ -6,26 +6,6 @@ RSpec.describe MergeRequestsHelper do include ActionView::Helpers::UrlHelper include ProjectForksHelper - describe 'ci_build_details_path' do - let(:project) { create(:project) } - let(:merge_request) { MergeRequest.new } - let(:ci_service) { CiService.new } - let(:last_commit) { Ci::Pipeline.new({}) } - - before do - allow(merge_request).to receive(:source_project).and_return(project) - allow(merge_request).to receive(:last_commit).and_return(last_commit) - allow(project).to receive(:ci_service).and_return(ci_service) - allow(last_commit).to receive(:sha).and_return('12d65c') - end - - it 'does not include api credentials in a link' do - allow(ci_service) - .to receive(:build_page).and_return("http://secretuser:secretpass@jenkins.example.com:8888/job/test1/scm/bySHA1/12d65c") - expect(helper.ci_build_details_path(merge_request)).not_to match("secret") - end - end - describe '#state_name_with_icon' do using RSpec::Parameterized::TableSyntax diff --git a/spec/migrations/update_internal_ids_last_value_for_epics_spec.rb b/spec/migrations/update_internal_ids_last_value_for_epics_spec.rb new file mode 100644 index 00000000000..b53db5db0f8 --- /dev/null +++ b/spec/migrations/update_internal_ids_last_value_for_epics_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'migrate', '20201202081429_update_internal_ids_last_value_for_epics.rb') + +RSpec.describe UpdateInternalIdsLastValueForEpics, :migration, schema: 20201124185639 do + let(:namespaces) { table(:namespaces) } + let(:users) { table(:users) } + let(:epics) { table(:epics) } + let(:internal_ids) { table(:internal_ids) } + + let!(:author) { users.create!(name: 'test', email: 'test@example.com', projects_limit: 0) } + let!(:group1) { namespaces.create!(type: 'Group', name: 'group1', path: 'group1') } + let!(:group2) { namespaces.create!(type: 'Group', name: 'group2', path: 'group2') } + let!(:group3) { namespaces.create!(type: 'Group', name: 'group3', path: 'group3') } + let!(:epic_last_value1) { internal_ids.create!(usage: 4, last_value: 5, namespace_id: group1.id) } + let!(:epic_last_value2) { internal_ids.create!(usage: 4, last_value: 5, namespace_id: group2.id) } + let!(:epic_last_value3) { internal_ids.create!(usage: 4, last_value: 5, namespace_id: group3.id) } + let!(:epic_1) { epics.create!(iid: 110, title: 'from epic 1', group_id: group1.id, author_id: author.id, title_html: 'any') } + let!(:epic_2) { epics.create!(iid: 5, title: 'from epic 1', group_id: group2.id, author_id: author.id, title_html: 'any') } + let!(:epic_3) { epics.create!(iid: 3, title: 'from epic 1', group_id: group3.id, author_id: author.id, title_html: 'any') } + + it 'updates out of sync internal_ids last_value' do + migrate! + + expect(internal_ids.find_by(usage: 4, namespace_id: group1.id).last_value).to eq(110) + expect(internal_ids.find_by(usage: 4, namespace_id: group2.id).last_value).to eq(5) + expect(internal_ids.find_by(usage: 4, namespace_id: group3.id).last_value).to eq(5) + end +end diff --git a/spec/models/dependency_proxy/registry_spec.rb b/spec/models/dependency_proxy/registry_spec.rb index 5bfa75a2eed..a888ee2b7f7 100644 --- a/spec/models/dependency_proxy/registry_spec.rb +++ b/spec/models/dependency_proxy/registry_spec.rb @@ -54,4 +54,11 @@ RSpec.describe DependencyProxy::Registry, type: :model do end end end + + describe '#authenticate_header' do + it 'returns the OAuth realm and service header' do + expect(described_class.authenticate_header) + .to eq("Bearer realm=\"#{Gitlab.config.gitlab.url}/jwt/auth\",service=\"dependency_proxy\"") + end + end end diff --git a/spec/models/namespace_onboarding_action_spec.rb b/spec/models/namespace_onboarding_action_spec.rb index 40ff965c134..70dcb989b32 100644 --- a/spec/models/namespace_onboarding_action_spec.rb +++ b/spec/models/namespace_onboarding_action_spec.rb @@ -5,7 +5,13 @@ require 'spec_helper' RSpec.describe NamespaceOnboardingAction do let(:namespace) { build(:namespace) } - it { is_expected.to belong_to :namespace } + describe 'associations' do + it { is_expected.to belong_to(:namespace).required } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:action) } + end describe '.completed?' do let(:action) { :subscription_created } diff --git a/spec/requests/jwt_controller_spec.rb b/spec/requests/jwt_controller_spec.rb index fe6c0f0a556..e154e691d5f 100644 --- a/spec/requests/jwt_controller_spec.rb +++ b/spec/requests/jwt_controller_spec.rb @@ -5,13 +5,13 @@ require 'spec_helper' RSpec.describe JwtController do include_context 'parsed logs' - let(:service) { double(execute: {}) } - let(:service_class) { double(new: service) } - let(:service_name) { 'test' } + let(:service) { double(execute: {} ) } + let(:service_class) { Auth::ContainerRegistryAuthenticationService } + let(:service_name) { 'container_registry' } let(:parameters) { { service: service_name } } before do - stub_const('JwtController::SERVICES', service_name => service_class) + allow(service_class).to receive(:new).and_return(service) end shared_examples 'user logging' do @@ -22,194 +22,266 @@ RSpec.describe JwtController do end end - context 'existing service' do - subject! { get '/jwt/auth', params: parameters } + context 'authenticating against container registry' do + context 'existing service' do + subject! { get '/jwt/auth', params: parameters } - it { expect(response).to have_gitlab_http_status(:ok) } + it { expect(response).to have_gitlab_http_status(:ok) } - context 'returning custom http code' do - let(:service) { double(execute: { http_status: 505 }) } + context 'returning custom http code' do + let(:service) { double(execute: { http_status: 505 }) } - it { expect(response).to have_gitlab_http_status(:http_version_not_supported) } + it { expect(response).to have_gitlab_http_status(:http_version_not_supported) } + end end - end - context 'when using authenticated request' do - shared_examples 'rejecting a blocked user' do - context 'with blocked user' do - let(:user) { create(:user, :blocked) } + context 'when using authenticated request' do + shared_examples 'rejecting a blocked user' do + context 'with blocked user' do + let(:user) { create(:user, :blocked) } - it 'rejects the request as unauthorized' do - expect(response).to have_gitlab_http_status(:unauthorized) - expect(response.body).to include('HTTP Basic: Access denied') + it 'rejects the request as unauthorized' do + expect(response).to have_gitlab_http_status(:unauthorized) + expect(response.body).to include('HTTP Basic: Access denied') + end end end - end - context 'using CI token' do - let(:user) { create(:user) } - let(:build) { create(:ci_build, :running, user: user) } - let(:project) { build.project } - let(:headers) { { authorization: credentials('gitlab-ci-token', build.token) } } + context 'using CI token' do + let(:user) { create(:user) } + let(:build) { create(:ci_build, :running, user: user) } + let(:project) { build.project } + let(:headers) { { authorization: credentials('gitlab-ci-token', build.token) } } - context 'project with enabled CI' do - subject! { get '/jwt/auth', params: parameters, headers: headers } - - it { expect(service_class).to have_received(:new).with(project, user, ActionController::Parameters.new(parameters).permit!) } + context 'project with enabled CI' do + subject! { get '/jwt/auth', params: parameters, headers: headers } - it_behaves_like 'user logging' - end + it { expect(service_class).to have_received(:new).with(project, user, ActionController::Parameters.new(parameters).permit!) } - context 'project with disabled CI' do - before do - project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) + it_behaves_like 'user logging' end - subject! { get '/jwt/auth', params: parameters, headers: headers } + context 'project with disabled CI' do + before do + project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) + end - it { expect(response).to have_gitlab_http_status(:unauthorized) } - end + subject! { get '/jwt/auth', params: parameters, headers: headers } - context 'using deploy tokens' do - let(:deploy_token) { create(:deploy_token, read_registry: true, projects: [project]) } - let(:headers) { { authorization: credentials(deploy_token.username, deploy_token.token) } } + it { expect(response).to have_gitlab_http_status(:unauthorized) } + end - subject! { get '/jwt/auth', params: parameters, headers: headers } + context 'using deploy tokens' do + let(:deploy_token) { create(:deploy_token, read_registry: true, projects: [project]) } + let(:headers) { { authorization: credentials(deploy_token.username, deploy_token.token) } } - it 'authenticates correctly' do - expect(response).to have_gitlab_http_status(:ok) - expect(service_class).to have_received(:new).with(nil, deploy_token, ActionController::Parameters.new(parameters).permit!) - end + subject! { get '/jwt/auth', params: parameters, headers: headers } - it 'does not log a user' do - expect(log_data.keys).not_to include(%w(username user_id)) + it 'authenticates correctly' do + expect(response).to have_gitlab_http_status(:ok) + expect(service_class).to have_received(:new).with(nil, deploy_token, ActionController::Parameters.new(parameters).permit!) + end + + it 'does not log a user' do + expect(log_data.keys).not_to include(%w(username user_id)) + end end - end - context 'using personal access tokens' do - let(:pat) { create(:personal_access_token, user: user, scopes: ['read_registry']) } - let(:headers) { { authorization: credentials('personal_access_token', pat.token) } } + context 'using personal access tokens' do + let(:pat) { create(:personal_access_token, user: user, scopes: ['read_registry']) } + let(:headers) { { authorization: credentials('personal_access_token', pat.token) } } - before do - stub_container_registry_config(enabled: true) + before do + stub_container_registry_config(enabled: true) + end + + subject! { get '/jwt/auth', params: parameters, headers: headers } + + it 'authenticates correctly' do + expect(response).to have_gitlab_http_status(:ok) + expect(service_class).to have_received(:new).with(nil, user, ActionController::Parameters.new(parameters).permit!) + end + + it_behaves_like 'rejecting a blocked user' + it_behaves_like 'user logging' end + end + + context 'using User login' do + let(:user) { create(:user) } + let(:headers) { { authorization: credentials(user.username, user.password) } } subject! { get '/jwt/auth', params: parameters, headers: headers } - it 'authenticates correctly' do - expect(response).to have_gitlab_http_status(:ok) - expect(service_class).to have_received(:new).with(nil, user, ActionController::Parameters.new(parameters).permit!) - end + it { expect(service_class).to have_received(:new).with(nil, user, ActionController::Parameters.new(parameters).permit!) } it_behaves_like 'rejecting a blocked user' - it_behaves_like 'user logging' - end - end - - context 'using User login' do - let(:user) { create(:user) } - let(:headers) { { authorization: credentials(user.username, user.password) } } - subject! { get '/jwt/auth', params: parameters, headers: headers } + context 'when passing a flat array of scopes' do + # We use this trick to make rails to generate a query_string: + # scope=scope1&scope=scope2 + # It works because :scope and 'scope' are the same as string, but different objects + let(:parameters) do + { + :service => service_name, + :scope => 'scope1', + 'scope' => 'scope2' + } + end - it { expect(service_class).to have_received(:new).with(nil, user, ActionController::Parameters.new(parameters).permit!) } + let(:service_parameters) do + ActionController::Parameters.new({ service: service_name, scopes: %w(scope1 scope2) }).permit! + end - it_behaves_like 'rejecting a blocked user' + it { expect(service_class).to have_received(:new).with(nil, user, service_parameters) } - context 'when passing a flat array of scopes' do - # We use this trick to make rails to generate a query_string: - # scope=scope1&scope=scope2 - # It works because :scope and 'scope' are the same as string, but different objects - let(:parameters) do - { - :service => service_name, - :scope => 'scope1', - 'scope' => 'scope2' - } + it_behaves_like 'user logging' end - let(:service_parameters) do - ActionController::Parameters.new({ service: service_name, scopes: %w(scope1 scope2) }).permit! + context 'when user has 2FA enabled' do + let(:user) { create(:user, :two_factor) } + + context 'without personal token' do + it 'rejects the authorization attempt' do + expect(response).to have_gitlab_http_status(:unauthorized) + expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP') + end + end + + context 'with personal token' do + let(:access_token) { create(:personal_access_token, user: user) } + let(:headers) { { authorization: credentials(user.username, access_token.token) } } + + it 'accepts the authorization attempt' do + expect(response).to have_gitlab_http_status(:ok) + end + end end - it { expect(service_class).to have_received(:new).with(nil, user, service_parameters) } + it 'does not cause session based checks to be activated' do + expect(Gitlab::Session).not_to receive(:with_session) + + get '/jwt/auth', params: parameters, headers: headers - it_behaves_like 'user logging' + expect(response).to have_gitlab_http_status(:ok) + end end - context 'when user has 2FA enabled' do - let(:user) { create(:user, :two_factor) } + context 'using invalid login' do + let(:headers) { { authorization: credentials('invalid', 'password') } } - context 'without personal token' do + context 'when internal auth is enabled' do it 'rejects the authorization attempt' do + get '/jwt/auth', params: parameters, headers: headers + expect(response).to have_gitlab_http_status(:unauthorized) - expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP') + expect(response.body).not_to include('You must use a personal access token with \'api\' scope for Git over HTTP') end end - context 'with personal token' do - let(:access_token) { create(:personal_access_token, user: user) } - let(:headers) { { authorization: credentials(user.username, access_token.token) } } + context 'when internal auth is disabled' do + it 'rejects the authorization attempt with personal access token message' do + allow_next_instance_of(ApplicationSetting) do |instance| + allow(instance).to receive(:password_authentication_enabled_for_git?) { false } + end + get '/jwt/auth', params: parameters, headers: headers - it 'accepts the authorization attempt' do - expect(response).to have_gitlab_http_status(:ok) + expect(response).to have_gitlab_http_status(:unauthorized) + expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP') end end end + end - it 'does not cause session based checks to be activated' do - expect(Gitlab::Session).not_to receive(:with_session) - - get '/jwt/auth', params: parameters, headers: headers + context 'when using unauthenticated request' do + it 'accepts the authorization attempt' do + get '/jwt/auth', params: parameters expect(response).to have_gitlab_http_status(:ok) end + + it 'allows read access' do + expect(service).to receive(:execute).with(authentication_abilities: Gitlab::Auth.read_only_authentication_abilities) + + get '/jwt/auth', params: parameters + end end - context 'using invalid login' do - let(:headers) { { authorization: credentials('invalid', 'password') } } + context 'unknown service' do + subject! { get '/jwt/auth', params: { service: 'unknown' } } - context 'when internal auth is enabled' do - it 'rejects the authorization attempt' do - get '/jwt/auth', params: parameters, headers: headers + it { expect(response).to have_gitlab_http_status(:not_found) } + end - expect(response).to have_gitlab_http_status(:unauthorized) - expect(response.body).not_to include('You must use a personal access token with \'api\' scope for Git over HTTP') - end - end + def credentials(login, password) + ActionController::HttpAuthentication::Basic.encode_credentials(login, password) + end + end - context 'when internal auth is disabled' do - it 'rejects the authorization attempt with personal access token message' do - allow_next_instance_of(ApplicationSetting) do |instance| - allow(instance).to receive(:password_authentication_enabled_for_git?) { false } - end - get '/jwt/auth', params: parameters, headers: headers + context 'authenticating against dependency proxy' do + let_it_be(:user) { create(:user) } + let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :private, group: group) } + let_it_be(:group_deploy_token) { create(:deploy_token, :group, groups: [group]) } + let_it_be(:project_deploy_token) { create(:deploy_token, :project, projects: [project]) } + let_it_be(:service_name) { 'dependency_proxy' } + let(:headers) { { authorization: credentials(credential_user, credential_password) } } + let(:params) { { account: credential_user, client_id: 'docker', offline_token: true, service: service_name } } + + before do + stub_config(dependency_proxy: { enabled: true }) + end - expect(response).to have_gitlab_http_status(:unauthorized) - expect(response.body).to include('You must use a personal access token with \'api\' scope for Git over HTTP') - end + subject { get '/jwt/auth', params: params, headers: headers } + + shared_examples 'with valid credentials' do + it 'returns token successfully' do + subject + + expect(response).to have_gitlab_http_status(:success) + expect(json_response['token']).to be_present end end - end - context 'when using unauthenticated request' do - it 'accepts the authorization attempt' do - get '/jwt/auth', params: parameters + context 'with personal access token' do + let(:credential_user) { nil } + let(:credential_password) { personal_access_token.token } - expect(response).to have_gitlab_http_status(:ok) + it_behaves_like 'with valid credentials' end - it 'allows read access' do - expect(service).to receive(:execute).with(authentication_abilities: Gitlab::Auth.read_only_authentication_abilities) + context 'with user credentials token' do + let(:credential_user) { user.username } + let(:credential_password) { user.password } - get '/jwt/auth', params: parameters + it_behaves_like 'with valid credentials' end - end - context 'unknown service' do - subject! { get '/jwt/auth', params: { service: 'unknown' } } + context 'with group deploy token' do + let(:credential_user) { group_deploy_token.username } + let(:credential_password) { group_deploy_token.token } - it { expect(response).to have_gitlab_http_status(:not_found) } + it_behaves_like 'with valid credentials' + end + + context 'with project deploy token' do + let(:credential_user) { project_deploy_token.username } + let(:credential_password) { project_deploy_token.token } + + it_behaves_like 'with valid credentials' + end + + context 'with invalid credentials' do + let(:credential_user) { 'foo' } + let(:credential_password) { 'bar' } + + it 'returns unauthorized' do + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end end def credentials(login, password) diff --git a/spec/routing/group_routing_spec.rb b/spec/routing/group_routing_spec.rb index f4d5ccc81b6..f171c2faf5e 100644 --- a/spec/routing/group_routing_spec.rb +++ b/spec/routing/group_routing_spec.rb @@ -81,6 +81,10 @@ RSpec.describe "Groups", "routing" do end describe 'dependency proxy for containers' do + it 'routes to #authenticate' do + expect(get('/v2')).to route_to('groups/dependency_proxy_auth#authenticate') + end + context 'image name without namespace' do it 'routes to #manifest' do expect(get('/v2/gitlabhq/dependency_proxy/containers/ruby/manifests/2.3.6')) diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb index 73d20cad4dd..0f931e7cc9e 100644 --- a/spec/routing/routing_spec.rb +++ b/spec/routing/routing_spec.rb @@ -179,6 +179,30 @@ RSpec.describe Profiles::KeysController, "routing" do end end +# keys GET /gpg_keys gpg_keys#index +# key POST /gpg_keys gpg_keys#create +# PUT /gpg_keys/:id gpg_keys#revoke +# DELETE /gpg_keys/:id gpg_keys#desroy +RSpec.describe Profiles::GpgKeysController, "routing" do + it "to #index" do + expect(get("/profile/gpg_keys")).to route_to('profiles/gpg_keys#index') + end + + it "to #create" do + expect(post("/profile/gpg_keys")).to route_to('profiles/gpg_keys#create') + end + + it "to #destroy" do + expect(delete("/profile/gpg_keys/1")).to route_to('profiles/gpg_keys#destroy', id: '1') + end + + it "to #get_keys" do + allow_any_instance_of(::Constraints::UserUrlConstrainer).to receive(:matches?).and_return(true) + + expect(get("/foo.gpg")).to route_to('profiles/gpg_keys#get_keys', username: 'foo') + end +end + # emails GET /emails(.:format) emails#index # POST /keys(.:format) emails#create # DELETE /keys/:id(.:format) keys#destroy diff --git a/spec/services/auth/dependency_proxy_authentication_service_spec.rb b/spec/services/auth/dependency_proxy_authentication_service_spec.rb new file mode 100644 index 00000000000..ba50149f53a --- /dev/null +++ b/spec/services/auth/dependency_proxy_authentication_service_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Auth::DependencyProxyAuthenticationService do + let_it_be(:user) { create(:user) } + let(:service) { Auth::DependencyProxyAuthenticationService.new(nil, user) } + + before do + stub_config(dependency_proxy: { enabled: true }) + end + + describe '#execute' do + subject { service.execute(authentication_abilities: nil) } + + context 'dependency proxy is not enabled' do + before do + stub_config(dependency_proxy: { enabled: false }) + end + + it 'returns not found' do + result = subject + + expect(result[:http_status]).to eq(404) + expect(result[:message]).to eq('dependency proxy not enabled') + end + end + + context 'without a user' do + let(:user) { nil } + + it 'returns forbidden' do + result = subject + + expect(result[:http_status]).to eq(403) + expect(result[:message]).to eq('access forbidden') + end + end + + context 'with a user' do + it 'returns a token' do + expect(subject[:token]).not_to be_nil + end + end + end +end diff --git a/spec/services/dependency_proxy/auth_token_service_spec.rb b/spec/services/dependency_proxy/auth_token_service_spec.rb new file mode 100644 index 00000000000..4b96f9d75a9 --- /dev/null +++ b/spec/services/dependency_proxy/auth_token_service_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe DependencyProxy::AuthTokenService do + include DependencyProxyHelpers + + describe '.decoded_token_payload' do + let_it_be(:user) { create(:user) } + let_it_be(:token) { build_jwt(user) } + + subject { described_class.decoded_token_payload(token.encoded) } + + it 'returns the user' do + result = subject + + expect(result['user_id']).to eq(user.id) + end + + it 'raises an error if the token is expired' do + travel_to(Time.zone.now + Auth::DependencyProxyAuthenticationService.token_expire_at + 1.minute) do + expect { subject }.to raise_error(JWT::ExpiredSignature) + end + end + + it 'raises an error if decoding fails' do + allow(JWT).to receive(:decode).and_raise(JWT::DecodeError) + + expect { subject }.to raise_error(JWT::DecodeError) + end + + it 'raises an error if signature is immature' do + allow(JWT).to receive(:decode).and_raise(JWT::ImmatureSignature) + + expect { subject }.to raise_error(JWT::ImmatureSignature) + end + end +end diff --git a/spec/services/onboarding_progress_service_spec.rb b/spec/services/onboarding_progress_service_spec.rb new file mode 100644 index 00000000000..edf40dfeed1 --- /dev/null +++ b/spec/services/onboarding_progress_service_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe OnboardingProgressService do + describe '#execute' do + let_it_be(:namespace) { build(:namespace) } + let(:action) { :namespace_action } + + subject(:execute_service) { described_class.new(namespace).execute(action: action) } + + it 'records a namespace onboarding progress action' do + expect(NamespaceOnboardingAction).to receive(:create_action) + .with(namespace, :namespace_action) + + subject + end + end +end diff --git a/spec/services/post_receive_service_spec.rb b/spec/services/post_receive_service_spec.rb index 7c4b7f51cc3..4e303bfc20a 100644 --- a/spec/services/post_receive_service_spec.rb +++ b/spec/services/post_receive_service_spec.rb @@ -45,6 +45,12 @@ RSpec.describe PostReceiveService do it 'does not return error' do expect(subject).to be_empty end + + it 'does not record a namespace onboarding progress action' do + expect(NamespaceOnboardingAction).not_to receive(:create_action) + + subject + end end context 'when repository is nil' do @@ -80,6 +86,13 @@ RSpec.describe PostReceiveService do expect(response.reference_counter_decreased).to be(true) end + + it 'records a namespace onboarding progress action' do + expect(NamespaceOnboardingAction).to receive(:create_action) + .with(project.namespace, :git_write) + + subject + end end context 'with Project' do diff --git a/spec/support/helpers/dependency_proxy_helpers.rb b/spec/support/helpers/dependency_proxy_helpers.rb index 545b9d1f4d0..0074cfb7931 100644 --- a/spec/support/helpers/dependency_proxy_helpers.rb +++ b/spec/support/helpers/dependency_proxy_helpers.rb @@ -25,6 +25,13 @@ module DependencyProxyHelpers .to_return(status: status, body: body) end + def build_jwt(user = nil, expire_time: nil) + JSONWebToken::HMACToken.new(::Auth::DependencyProxyAuthenticationService.secret).tap do |jwt| + jwt['user_id'] = user.id if user + jwt.expire_time = expire_time || jwt.issued_at + 1.minute + end + end + private def registry diff --git a/spec/support/helpers/gpg_helpers.rb b/spec/support/helpers/gpg_helpers.rb index f4df1cf601c..389e5818dbe 100644 --- a/spec/support/helpers/gpg_helpers.rb +++ b/spec/support/helpers/gpg_helpers.rb @@ -144,6 +144,145 @@ module GpgHelpers '5F7EA3981A5845B141ABD522CCFBE19F00AC8B1D' end + def secret_key2 + <<~KEY.strip + -----BEGIN PGP PRIVATE KEY BLOCK----- + + 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 + 4f6xMfFu2A== + =3YL6 + -----END PGP PRIVATE KEY BLOCK----- + KEY + end + + def public_key2 + <<~KEY.strip + -----BEGIN PGP PUBLIC KEY BLOCK----- + + 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 + =RAwd + -----END PGP PUBLIC KEY BLOCK----- + KEY + end + + def fingerprint2 + 'C447A6F6BFD9CEF8FB371785571625A930241179' + end + def names ['Nannie Bernhard'] end |