diff options
97 files changed, 1179 insertions, 634 deletions
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js index 7a6ad3dc771..dd300b8a307 100644 --- a/app/assets/javascripts/commons/polyfills.js +++ b/app/assets/javascripts/commons/polyfills.js @@ -4,6 +4,7 @@ import 'core-js/es/array/find'; import 'core-js/es/array/find-index'; import 'core-js/es/array/from'; import 'core-js/es/array/includes'; +import 'core-js/es/number/is-integer'; import 'core-js/es/object/assign'; import 'core-js/es/object/values'; import 'core-js/es/object/entries'; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue index 67ca419bf81..2f7fcfcb755 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue @@ -1,4 +1,5 @@ <script> +import $ from 'jquery'; import { GlIcon } from '@gitlab/ui'; import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; @@ -106,6 +107,7 @@ export default { data() { return { searchQuery: '', + focusOnSearch: false, }; }, computed: { @@ -141,6 +143,18 @@ export default { return itemsProp(this.selectedItems, this.valueProperty).join(', '); }, }, + mounted() { + $(this.$refs.dropdown) + .on('shown.bs.dropdown', () => { + this.focusOnSearch = true; + }) + .on('hidden.bs.dropdown', () => { + this.focusOnSearch = false; + }); + }, + beforeDestroy() { + $(this.$refs.dropdown).off(); + }, methods: { getItemsOrEmptyList() { return this.items || []; @@ -170,7 +184,7 @@ export default { <template> <div> - <div class="js-gcp-machine-type-dropdown dropdown"> + <div ref="dropdown" class="dropdown"> <dropdown-hidden-input :name="fieldName" :value="selectedItemsValues" /> <dropdown-button :class="{ 'border-danger': hasErrors }" @@ -179,7 +193,11 @@ export default { :toggle-text="toggleText" /> <div class="dropdown-menu dropdown-select"> - <dropdown-search-input v-model="searchQuery" :placeholder-text="searchFieldPlaceholder" /> + <dropdown-search-input + v-model="searchQuery" + :focused="focusOnSearch" + :placeholder-text="searchFieldPlaceholder" + /> <div class="dropdown-content"> <ul> <li v-if="!results.length"> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 06477477aad..1df57f1aa14 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -1,8 +1,8 @@ <script> -import { __, sprintf } from '~/locale'; import _ from 'underscore'; import { mapActions, mapState } from 'vuex'; import { GlLink, GlButton } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; import Icon from '~/vue_shared/components/icon.vue'; diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue index 922f64d93fe..5edb8ff555b 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/trigger_block.vue @@ -1,6 +1,6 @@ <script> -import { __ } from '~/locale'; import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; const HIDDEN_VALUE = '••••••'; diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index ed01d0ee553..0d442f14aea 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -1,9 +1,9 @@ <script> -import { s__, __ } from '~/locale'; import _ from 'underscore'; import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; +import { s__, __ } from '~/locale'; import { roundOffFloat } from '~/lib/utils/common_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue index 0388a6190d9..c3beae18726 100644 --- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue +++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue @@ -1,7 +1,7 @@ <script> import _ from 'underscore'; -import { s__, sprintf } from '~/locale'; import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; import { dateFormats } from '~/monitoring/constants'; const inputGroupText = { diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue index dae1fbad547..86f5559af8f 100644 --- a/app/assets/javascripts/monitoring/components/embed.vue +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -1,7 +1,7 @@ <script> import { mapActions, mapState, mapGetters } from 'vuex'; -import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; import GraphGroup from './graph_group.vue'; import { sidebarAnimationDuration } from '../constants'; import { getTimeDiff } from '../utils'; diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index ab8c9712ce4..728910dd633 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -1,6 +1,6 @@ <script> -import { __ } from '~/locale'; import { GlEmptyState } from '@gitlab/ui'; +import { __ } from '~/locale'; export default { components: { diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index 080fe6f2b4b..d0dce4f5116 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -1,7 +1,6 @@ <script> import { mapState } from 'vuex'; import _ from 'underscore'; -import { __ } from '~/locale'; import { GlDropdown, GlDropdownItem, @@ -9,6 +8,7 @@ import { GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; +import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorAnomalyChart from './charts/anomaly.vue'; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index a14145d480b..d296f5b7a66 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; +import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import { getParameterValues } from '~/lib/utils/url_utility'; -import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; import store from './stores'; Vue.use(GlToast); diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index defa278c089..fcd5b391b38 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -16,10 +16,10 @@ import Cookies from 'js-cookie'; import Autosize from 'autosize'; import 'jquery.caret'; // required by at.js import 'at.js'; -import AjaxCache from '~/lib/utils/ajax_cache'; import Vue from 'vue'; -import syntaxHighlight from '~/syntax_highlight'; import { GlSkeletonLoading } from '@gitlab/ui'; +import AjaxCache from '~/lib/utils/ajax_cache'; +import syntaxHighlight from '~/syntax_highlight'; import axios from './lib/utils/axios_utils'; import { getLocationHash } from './lib/utils/url_utility'; import Flash from './flash'; diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index df537ba1ed2..fe22737c7fc 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,10 +1,10 @@ <script> /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { mapState, mapActions } from 'vuex'; +import { GlSkeletonLoading } from '@gitlab/ui'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; -import { GlSkeletonLoading } from '@gitlab/ui'; import { getDiffMode } from '~/diffs/store/utils'; import { diffViewerModes } from '~/ide/constants'; diff --git a/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue index 07a5bda6bcb..f87ca097b40 100644 --- a/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue +++ b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue @@ -1,6 +1,6 @@ <script> -import icon from '~/vue_shared/components/icon.vue'; import { GlTooltipDirective } from '@gitlab/ui'; +import icon from '~/vue_shared/components/icon.vue'; export default { name: 'JumpToNextDiscussionButton', diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue index f03e6fd73d7..1d1529bfa5b 100644 --- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue +++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue @@ -1,6 +1,6 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import { GlTooltipDirective, GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; export default { name: 'ResolveWithIssueButton', diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 89d434a60ba..dc514f00801 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,8 +1,8 @@ <script> import { mapGetters } from 'vuex'; -import Icon from '~/vue_shared/components/icon.vue'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import resolvedStatusMixin from 'ee_else_ce/batch_comments/mixins/resolved_status'; +import Icon from '~/vue_shared/components/icon.vue'; import ReplyButton from './note_actions/reply_button.vue'; export default { diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 222badf70d1..b024884bea0 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,7 +1,7 @@ <script> -import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mapGetters, mapActions } from 'vuex'; import noteFormMixin from 'ee_else_ce/notes/mixins/note_form'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 62d401d4911..0151a3f10a5 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,10 +1,10 @@ <script> import { mapActions, mapGetters } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; +import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; import { s__, __ } from '~/locale'; import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import icon from '~/vue_shared/components/icon.vue'; -import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index fa8fc7d02e4..b3dae69d0bc 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -2,9 +2,9 @@ import $ from 'jquery'; import { mapGetters, mapActions } from 'vuex'; import { escape } from 'underscore'; +import draftMixin from 'ee_else_ce/notes/mixins/draft'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import draftMixin from 'ee_else_ce/notes/mixins/draft'; import { __, s__, sprintf } from '../../locale'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 7df99610132..be2adb07526 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,6 +1,5 @@ <script> import { mapGetters, mapActions } from 'vuex'; -import { __ } from '~/locale'; import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; import Flash from '../../flash'; import * as constants from '../constants'; @@ -14,6 +13,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note. import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; +import { __ } from '~/locale'; import initUserPopovers from '../../user_popovers'; export default { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 82c291379ec..97b0269509a 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import $ from 'jquery'; -import axios from '~/lib/utils/axios_utils'; import Visibility from 'visibilityjs'; +import axios from '~/lib/utils/axios_utils'; import TaskList from '../../task_list'; import Flash from '../../flash'; import Poll from '../../lib/utils/poll'; diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue index c01c7cc4ccc..610bce9a705 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue @@ -8,6 +8,11 @@ export default { required: true, default: __('Search'), }, + focused: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { searchQuery: this.value }; @@ -16,6 +21,11 @@ export default { searchQuery(query) { this.$emit('input', query); }, + focused(val) { + if (val) { + this.$refs.searchInput.focus(); + } + }, }, }; </script> @@ -23,6 +33,7 @@ export default { <template> <div class="dropdown-input"> <input + ref="searchInput" v-model="searchQuery" :placeholder="placeholderText" class="dropdown-input-field" diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue index e89638130f5..29a4a90a59f 100644 --- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue @@ -1,15 +1,18 @@ <script> +import { GlPagination } from '@gitlab/ui'; import { - PAGINATION_UI_BUTTON_LIMIT, - UI_LIMIT, - SPREAD, PREV, NEXT, - FIRST, - LAST, + LABEL_FIRST_PAGE, + LABEL_PREV_PAGE, + LABEL_NEXT_PAGE, + LABEL_LAST_PAGE, } from '~/vue_shared/components/pagination/constants'; export default { + components: { + GlPagination, + }, props: { /** This function will take the information given by the pagination component @@ -46,113 +49,34 @@ export default { }, }, computed: { - prev() { - return this.pageInfo.previousPage; - }, - next() { - return this.pageInfo.nextPage; - }, - getItems() { - const { totalPages, nextPage, previousPage, page } = this.pageInfo; - const items = []; - - if (page > 1) { - items.push({ title: FIRST, first: true }); - } - - if (previousPage) { - items.push({ title: PREV, prev: true }); - } else { - items.push({ title: PREV, disabled: true, prev: true }); - } - - if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true }); - - if (totalPages) { - const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1); - const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, totalPages); - - for (let i = start; i <= end; i += 1) { - const isActive = i === page; - items.push({ title: i, active: isActive, page: true }); - } - - if (totalPages - page > PAGINATION_UI_BUTTON_LIMIT) { - items.push({ title: SPREAD, separator: true, page: true }); - } - } - - if (nextPage) { - items.push({ title: NEXT, next: true }); - } else { - items.push({ title: NEXT, disabled: true, next: true }); - } - - if (totalPages && totalPages - page >= 1) { - items.push({ title: LAST, last: true }); - } - - return items; - }, showPagination() { return this.pageInfo.nextPage || this.pageInfo.previousPage; }, }, - methods: { - changePage(text, isDisabled) { - if (isDisabled) return; - - const { totalPages, nextPage, previousPage } = this.pageInfo; - - switch (text) { - case SPREAD: - break; - case LAST: - this.change(totalPages); - break; - case NEXT: - this.change(nextPage); - break; - case PREV: - this.change(previousPage); - break; - case FIRST: - this.change(1); - break; - default: - this.change(Number(text)); - break; - } - }, - hideOnSmallScreen(item) { - return !item.first && !item.last && !item.next && !item.prev && !item.active; - }, - }, + prevText: PREV, + nextText: NEXT, + labelFirstPage: LABEL_FIRST_PAGE, + labelPrevPage: LABEL_PREV_PAGE, + labelNextPage: LABEL_NEXT_PAGE, + labelLastPage: LABEL_LAST_PAGE, }; </script> <template> - <div v-if="showPagination" class="gl-pagination prepend-top-default"> - <ul class="pagination justify-content-center"> - <li - v-for="(item, index) in getItems" - :key="index" - :class="{ - page: item.page, - 'js-previous-button': item.prev, - 'js-next-button': item.next, - 'js-last-button': item.last, - 'js-first-button': item.first, - 'd-none d-md-block': hideOnSmallScreen(item), - separator: item.separator, - active: item.active, - disabled: item.disabled || item.separator, - }" - class="page-item" - > - <button type="button" class="page-link" @click="changePage(item.title, item.disabled)"> - {{ item.title }} - </button> - </li> - </ul> - </div> + <gl-pagination + v-if="showPagination" + class="justify-content-center prepend-top-default" + v-bind="$attrs" + :value="pageInfo.page" + :per-page="pageInfo.perPage" + :total-items="pageInfo.total" + :prev-page="pageInfo.previousPage" + :prev-text="$options.prevText" + :next-page="pageInfo.nextPage" + :next-text="$options.nextText" + :label-first-page="$options.labelFirstPage" + :label-prev-page="$options.labelPrevPage" + :label-next-page="$options.labelNextPage" + :label-last-page="$options.labelLastPage" + @input="change" + /> </template> diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb index 1f946e41995..f9587655a8d 100644 --- a/app/controllers/admin/sessions_controller.rb +++ b/app/controllers/admin/sessions_controller.rb @@ -6,17 +6,23 @@ class Admin::SessionsController < ApplicationController before_action :user_is_admin! def new - # Renders a form in which the admin can enter their password + if current_user_mode.admin_mode? + redirect_to redirect_path, notice: _('Admin mode already enabled') + else + current_user_mode.request_admin_mode! unless current_user_mode.admin_mode_requested? + store_location_for(:redirect, redirect_path) + end end def create if current_user_mode.enable_admin_mode!(password: params[:password]) - redirect_location = stored_location_for(:redirect) || admin_root_path - redirect_to safe_redirect_path(redirect_location) + redirect_to redirect_path, notice: _('Admin mode enabled') else - flash.now[:alert] = _('Invalid Login or password') + flash.now[:alert] = _('Invalid login or password') render :new end + rescue Gitlab::Auth::CurrentUserMode::NotRequestedError + redirect_to new_admin_session_path, alert: _('Re-authentication period expired or never requested. Please try again') end def destroy @@ -30,4 +36,19 @@ class Admin::SessionsController < ApplicationController def user_is_admin! render_404 unless current_user&.admin? end + + def redirect_path + redirect_to_path = safe_redirect_path(stored_location_for(:redirect)) || safe_redirect_path_for_url(request.referer) + + if redirect_to_path && + excluded_redirect_paths.none? { |excluded| redirect_to_path.include?(excluded) } + redirect_to_path + else + admin_root_path + end + end + + def excluded_redirect_paths + [new_admin_session_path, admin_session_path] + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ee2b3741ac9..33ae778769a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -16,6 +16,7 @@ class ApplicationController < ActionController::Base include ConfirmEmailWarning include Gitlab::Tracking::ControllerConcern include Gitlab::Experimentation::ControllerConcern + include InitializesCurrentUserMode before_action :authenticate_user!, except: [:route_not_found] before_action :enforce_terms!, if: :should_enforce_terms? @@ -41,7 +42,6 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception, prepend: true helper_method :can? - helper_method :current_user_mode helper_method :import_sources_enabled?, :github_import_enabled?, :gitea_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, @@ -546,10 +546,6 @@ class ApplicationController < ActionController::Base end end - def current_user_mode - @current_user_mode ||= Gitlab::Auth::CurrentUserMode.new(current_user) - end - # A user requires a role and have the setup_for_company attribute set when they are part of the experimental signup # flow (executed by the Growth team). Users are redirected to the welcome page when their role is required and the # experiment is enabled for the current user. diff --git a/app/controllers/concerns/enforces_admin_authentication.rb b/app/controllers/concerns/enforces_admin_authentication.rb index e731211f423..527759de0bb 100644 --- a/app/controllers/concerns/enforces_admin_authentication.rb +++ b/app/controllers/concerns/enforces_admin_authentication.rb @@ -18,6 +18,7 @@ module EnforcesAdminAuthentication return unless Feature.enabled?(:user_mode_in_session) unless current_user_mode.admin_mode? + current_user_mode.request_admin_mode! store_location_for(:redirect, request.fullpath) if storable_location? redirect_to(new_admin_session_path, notice: _('Re-authentication required')) end diff --git a/app/controllers/concerns/initializes_current_user_mode.rb b/app/controllers/concerns/initializes_current_user_mode.rb new file mode 100644 index 00000000000..df7cea5c754 --- /dev/null +++ b/app/controllers/concerns/initializes_current_user_mode.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module InitializesCurrentUserMode + extend ActiveSupport::Concern + + included do + helper_method :current_user_mode + end + + def current_user_mode + @current_user_mode ||= Gitlab::Auth::CurrentUserMode.new(current_user) + end +end diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb index f644923443b..d5c26fca957 100644 --- a/app/controllers/concerns/sessionless_authentication.rb +++ b/app/controllers/concerns/sessionless_authentication.rb @@ -33,6 +33,8 @@ module SessionlessAuthentication end def enable_admin_mode! - current_user_mode.enable_admin_mode!(skip_password_validation: true) if Feature.enabled?(:user_mode_in_session) + return unless Feature.enabled?(:user_mode_in_session) + + current_user_mode.enable_sessionless_admin_mode! end end diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index 8dd51ce1d64..bbf0bdd3662 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -6,6 +6,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController include PageLayoutHelper include OauthApplications include Gitlab::Experimentation::ControllerConcern + include InitializesCurrentUserMode before_action :verify_user_oauth_applications_enabled, except: :index before_action :authenticate_user! diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index e65726dffbf..2a4e659c5b9 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -2,6 +2,8 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController include Gitlab::Experimentation::ControllerConcern + include InitializesCurrentUserMode + layout 'profile' # Overridden from Doorkeeper::AuthorizationsController to diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index eca58748cc5..92f36c031f1 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -4,6 +4,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController include AuthenticatesWithTwoFactor include Devise::Controllers::Rememberable include AuthHelper + include InitializesCurrentUserMode protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true @@ -94,8 +95,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController return render_403 unless link_provider_allowed?(oauth['provider']) log_audit_event(current_user, with: oauth['provider']) - identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session) + if Feature.enabled?(:user_mode_in_session) + return admin_mode_flow if current_user_mode.admin_mode_requested? + end + + identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session) link_identity(identity_linker) if identity_linker.changed? @@ -239,6 +244,24 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController store_location_for(:user, uri.to_s) end end + + def admin_mode_flow + if omniauth_identity_matches_current_user? + current_user_mode.enable_admin_mode!(skip_password_validation: true) + + redirect_to stored_location_for(:redirect) || admin_root_path, notice: _('Admin mode enabled') + else + fail_admin_mode_invalid_credentials + end + end + + def omniauth_identity_matches_current_user? + current_user.matches_identity?(oauth['provider'], oauth['uid']) + end + + def fail_admin_mode_invalid_credentials + redirect_to new_admin_session_path, alert: _('Invalid login or password') + end end OmniauthCallbacksController.prepend_if_ee('EE::OmniauthCallbacksController') diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index a11676770a9..bd80ad7ff74 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -159,3 +159,5 @@ module Types resolver: Resolvers::Projects::SnippetsResolver end end + +Types::ProjectType.prepend_if_ee('::EE::Types::ProjectType') diff --git a/app/helpers/container_expiration_policies_helper.rb b/app/helpers/container_expiration_policies_helper.rb new file mode 100644 index 00000000000..17791e7b0ff --- /dev/null +++ b/app/helpers/container_expiration_policies_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ContainerExpirationPoliciesHelper + def cadence_options + ContainerExpirationPolicy.cadence_options.map do |key, val| + { key: key.to_s, label: val } + end + end + + def keep_n_options + ContainerExpirationPolicy.keep_n_options.map do |key, val| + { key: key, label: val } + end + end + + def older_than_options + ContainerExpirationPolicy.older_than_options.map do |key, val| + { key: key.to_s, label: val } + end + end +end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 2ce45cec878..6013475acb1 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -87,7 +87,7 @@ module NavHelper end if Feature.enabled?(:user_mode_in_session) - if current_user&.admin? && current_user_mode&.admin_mode? + if current_user_mode.admin_mode? links << :admin_mode end end diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb new file mode 100644 index 00000000000..f60a0179c83 --- /dev/null +++ b/app/models/container_expiration_policy.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class ContainerExpirationPolicy < ApplicationRecord + belongs_to :project, inverse_of: :container_expiration_policy + + validates :project, presence: true + validates :enabled, inclusion: { in: [true, false] } + validates :cadence, presence: true, inclusion: { in: ->(_) { self.cadence_options.stringify_keys } } + validates :older_than, inclusion: { in: ->(_) { self.older_than_options.stringify_keys } }, allow_nil: true + validates :keep_n, inclusion: { in: ->(_) { self.keep_n_options.keys } }, allow_nil: true + + def self.keep_n_options + { + 1 => _('%{tags} tag per image name') % { tags: 1 }, + 5 => _('%{tags} tags per image name') % { tags: 5 }, + 10 => _('%{tags} tags per image name') % { tags: 10 }, + 25 => _('%{tags} tags per image name') % { tags: 25 }, + 50 => _('%{tags} tags per image name') % { tags: 50 }, + 100 => _('%{tags} tags per image name') % { tags: 100 } + } + end + + def self.cadence_options + { + '1d': _('Every day'), + '7d': _('Every week'), + '14d': _('Every two weeks'), + '1month': _('Every month'), + '3month': _('Every three months') + } + end + + def self.older_than_options + { + '7d': _('%{days} days until tags are automatically removed') % { days: 7 }, + '14d': _('%{days} days until tags are automatically removed') % { days: 14 }, + '30d': _('%{days} days until tags are automatically removed') % { days: 30 }, + '90d': _('%{days} days until tags are automatically removed') % { days: 90 } + } + end +end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 5663ebf8ba1..d5a7c172fec 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -123,8 +123,10 @@ class Namespace < ApplicationRecord def find_by_pages_host(host) gitlab_host = "." + Settings.pages.host.downcase - name = host.downcase.delete_suffix(gitlab_host) + host = host.downcase + return unless host.ends_with?(gitlab_host) + name = host.delete_suffix(gitlab_host) Namespace.find_by_full_path(name) end end diff --git a/app/models/project.rb b/app/models/project.rb index 1cf69bf8403..84289e1d5ac 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -97,8 +97,11 @@ class Project < ApplicationRecord unless: :ci_cd_settings, if: proc { ProjectCiCdSetting.available? } + after_create :create_container_expiration_policy, + unless: :container_expiration_policy + after_create :create_pages_metadatum, - unless: :pages_metadatum + unless: :pages_metadatum after_create :set_timestamps_for_create after_update :update_forks_visibility_level @@ -248,6 +251,7 @@ class Project < ApplicationRecord # which is not managed by the DB. Hence we're still using dependent: :destroy # here. has_many :container_repositories, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_one :container_expiration_policy, inverse_of: :project has_many :commit_statuses # The relation :all_pipelines is intended to be used when we want to get the @@ -305,6 +309,7 @@ class Project < ApplicationRecord accepts_nested_attributes_for :import_data accepts_nested_attributes_for :auto_devops, update_only: true accepts_nested_attributes_for :ci_cd_settings, update_only: true + accepts_nested_attributes_for :container_expiration_policy, update_only: true accepts_nested_attributes_for :remote_mirrors, allow_destroy: true, diff --git a/app/models/user.rb b/app/models/user.rb index fd02db86582..6a29de20d86 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -996,6 +996,10 @@ class User < ApplicationRecord @ldap_identity ||= identities.find_by(["provider LIKE ?", "ldap%"]) end + def matches_identity?(provider, extern_uid) + identities.where(provider: provider, extern_uid: extern_uid).exists? + end + def project_deploy_keys DeployKey.in_projects(authorized_projects.select(:id)).distinct(:id) end diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml deleted file mode 100644 index 1d19915d3c5..00000000000 --- a/app/views/admin/sessions/_signin_box.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- if any_form_based_providers_enabled? - - - if password_authentication_enabled_for_web? - .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel' } - .login-body - = render 'admin/sessions/new_base' - -- elsif password_authentication_enabled_for_web? - .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' } - .login-body - = render 'admin/sessions/new_base' diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml index 73028e78ea5..a1d440f2cfd 100644 --- a/app/views/admin/sessions/new.html.haml +++ b/app/views/admin/sessions/new.html.haml @@ -7,9 +7,16 @@ #signin-container = render 'admin/sessions/tabs_normal' .tab-content - - if password_authentication_enabled_for_web? - = render 'admin/sessions/signin_box' - - else - -# Show a message if none of the mechanisms above are enabled + - if !current_user.require_password_creation_for_web? + .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' } + .login-body + = render 'admin/sessions/new_base' + + - if omniauth_enabled? && button_based_providers_enabled? + .clearfix + = render 'devise/shared/omniauth_box' + + -# Show a message if none of the mechanisms above are enabled + - if current_user.require_password_creation_for_web? && !omniauth_enabled? .prepend-top-default.center = _('No authentication methods configured.') diff --git a/changelogs/unreleased/15398-mvc-container-registry-tag-expiration-policies.yml b/changelogs/unreleased/15398-mvc-container-registry-tag-expiration-policies.yml new file mode 100644 index 00000000000..d71a5f171c7 --- /dev/null +++ b/changelogs/unreleased/15398-mvc-container-registry-tag-expiration-policies.yml @@ -0,0 +1,5 @@ +--- +title: Create container expiration policies for projects +merge_request: 20412 +author: +type: added diff --git a/changelogs/unreleased/22465-rack-attack-authenticate-runner-requests.yml b/changelogs/unreleased/22465-rack-attack-authenticate-runner-requests.yml new file mode 100644 index 00000000000..7d3cbcc2a1c --- /dev/null +++ b/changelogs/unreleased/22465-rack-attack-authenticate-runner-requests.yml @@ -0,0 +1,5 @@ +--- +title: Authenticate runner requests in Rack::Attack +merge_request: 21311 +author: +type: fixed diff --git a/changelogs/unreleased/34261-service-desk-to-graphql.yml b/changelogs/unreleased/34261-service-desk-to-graphql.yml new file mode 100644 index 00000000000..b055b443822 --- /dev/null +++ b/changelogs/unreleased/34261-service-desk-to-graphql.yml @@ -0,0 +1,5 @@ +--- +title: Add service desk information to project graphQL endpoint +merge_request: 20722 +author: +type: changed diff --git a/changelogs/unreleased/36326-auto-focus-search-bar.yml b/changelogs/unreleased/36326-auto-focus-search-bar.yml new file mode 100644 index 00000000000..5c5921140b4 --- /dev/null +++ b/changelogs/unreleased/36326-auto-focus-search-bar.yml @@ -0,0 +1,5 @@ +--- +title: Autofocus cluster dropdown search input +merge_request: 21440 +author: +type: changed diff --git a/changelogs/unreleased/feat-support-omniauth-for-admin-mode.yml b/changelogs/unreleased/feat-support-omniauth-for-admin-mode.yml new file mode 100644 index 00000000000..271499700e3 --- /dev/null +++ b/changelogs/unreleased/feat-support-omniauth-for-admin-mode.yml @@ -0,0 +1,5 @@ +--- +title: Add OmniAuth authentication support to admin mode feature +merge_request: 18214 +author: Diego Louzán +type: added diff --git a/config/initializers/rack_attack_new.rb b/config/initializers/rack_attack_new.rb index 6d29bb1cd8b..267d4c1eda9 100644 --- a/config/initializers/rack_attack_new.rb +++ b/config/initializers/rack_attack_new.rb @@ -113,11 +113,15 @@ class Rack::Attack class Request def unauthenticated? - !authenticated_user_id([:api, :rss, :ics]) + !(authenticated_user_id([:api, :rss, :ics]) || authenticated_runner_id) end def authenticated_user_id(request_formats) - Gitlab::Auth::RequestAuthenticator.new(self).user(request_formats)&.id + request_authenticator.user(request_formats)&.id + end + + def authenticated_runner_id + request_authenticator.runner&.id end def api_request? @@ -150,6 +154,10 @@ class Rack::Attack private + def request_authenticator + @request_authenticator ||= Gitlab::Auth::RequestAuthenticator.new(self) + end + def protected_paths Gitlab::CurrentSettings.current_application_settings.protected_paths end diff --git a/db/migrate/20191119231621_create_container_expiration_policies.rb b/db/migrate/20191119231621_create_container_expiration_policies.rb new file mode 100644 index 00000000000..d7108870cf1 --- /dev/null +++ b/db/migrate/20191119231621_create_container_expiration_policies.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateContainerExpirationPolicies < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + create_table :container_expiration_policies, id: false, primary_key: :project_id do |t| + t.timestamps_with_timezone null: false + t.datetime_with_timezone :next_run_at + t.references :project, primary_key: true, default: nil, index: false, foreign_key: { on_delete: :cascade } + t.string :name_regex, limit: 255 + t.string :cadence, null: false, limit: 12, default: '7d' + t.string :older_than, limit: 12 + t.integer :keep_n + t.boolean :enabled, null: false, default: false + end + + add_index :container_expiration_policies, [:next_run_at, :enabled], + name: 'index_container_expiration_policies_on_next_run_at_and_enabled' + end +end diff --git a/db/schema.rb b/db/schema.rb index da0cfd80ac2..deebbcb430d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1224,6 +1224,18 @@ ActiveRecord::Schema.define(version: 2019_12_06_122926) do t.index ["note_id"], name: "index_commit_user_mentions_on_note_id", unique: true end + create_table "container_expiration_policies", primary_key: "project_id", id: :bigint, default: nil, force: :cascade do |t| + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.datetime_with_timezone "next_run_at" + t.string "name_regex", limit: 255 + t.string "cadence", limit: 12, default: "7d", null: false + t.string "older_than", limit: 12 + t.integer "keep_n" + t.boolean "enabled", default: false, null: false + t.index ["next_run_at", "enabled"], name: "index_container_expiration_policies_on_next_run_at_and_enabled" + end + create_table "container_repositories", id: :serial, force: :cascade do |t| t.integer "project_id", null: false t.string "name", null: false @@ -4410,6 +4422,7 @@ ActiveRecord::Schema.define(version: 2019_12_06_122926) do add_foreign_key "clusters_kubernetes_namespaces", "environments", on_delete: :nullify add_foreign_key "clusters_kubernetes_namespaces", "projects", on_delete: :nullify add_foreign_key "commit_user_mentions", "notes", on_delete: :cascade + add_foreign_key "container_expiration_policies", "projects", on_delete: :cascade add_foreign_key "container_repositories", "projects" add_foreign_key "dependency_proxy_blobs", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "dependency_proxy_group_settings", "namespaces", column: "group_id", on_delete: :cascade diff --git a/doc/administration/geo/replication/database.md b/doc/administration/geo/replication/database.md index dd7cf9f5710..72c3692716b 100644 --- a/doc/administration/geo/replication/database.md +++ b/doc/administration/geo/replication/database.md @@ -155,8 +155,8 @@ There is an [issue where support is being discussed](https://gitlab.com/gitlab-o | `postgresql['md5_auth_cidr_addresses']` | **Secondary** node's public or VPC private addresses. | If you are using Google Cloud Platform, SoftLayer, or any other vendor that - provides a virtual private cloud (VPC) you can use the **secondary** node's private - address (corresponds to "internal address" for Google Cloud Platform) for + provides a virtual private cloud (VPC) you can use the **primary** and **secondary** nodes + private addresses (corresponds to "internal address" for Google Cloud Platform) for `postgresql['md5_auth_cidr_addresses']` and `postgresql['listen_address']`. The `listen_address` option opens PostgreSQL up to network connections with the interface diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 0d34e3136dc..7089ee8e51d 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -4552,6 +4552,16 @@ type Project { ): SentryDetailedError """ + E-mail address of the service desk. + """ + serviceDeskAddress: String + + """ + Indicates if the project has service desk enabled. + """ + serviceDeskEnabled: Boolean + + """ Indicates if shared runners are enabled on the project """ sharedRunnersEnabled: Boolean diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 833a230fd3f..aa7951e6bf7 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -1315,6 +1315,34 @@ "deprecationReason": null }, { + "name": "serviceDeskAddress", + "description": "E-mail address of the service desk.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "serviceDeskEnabled", + "description": "Indicates if the project has service desk enabled.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "sharedRunnersEnabled", "description": "Indicates if shared runners are enabled on the project", "args": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index b96666915f9..ec9b586d065 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -668,6 +668,8 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `mergeRequest` | MergeRequest | A single merge request of the project | | `issue` | Issue | A single issue of the project | | `sentryDetailedError` | SentryDetailedError | Detailed version of a Sentry error on the project | +| `serviceDeskEnabled` | Boolean | Indicates if the project has service desk enabled. | +| `serviceDeskAddress` | String | E-mail address of the service desk. | ### ProjectPermissions diff --git a/doc/api/jobs.md b/doc/api/jobs.md index bafcfd110d3..5f661ac4c76 100644 --- a/doc/api/jobs.md +++ b/doc/api/jobs.md @@ -2,7 +2,7 @@ ## List project jobs -Get a list of jobs in a project. +Get a list of jobs in a project. Jobs are sorted in descending order of their IDs. ``` GET /projects/:id/jobs @@ -33,13 +33,23 @@ Example of response }, "coverage": null, "allow_failure": false, - "created_at": "2015-12-24T15:51:21.727Z", - "started_at": "2015-12-24T17:54:24.729Z", - "finished_at": "2015-12-24T17:54:24.921Z", - "duration": 0.192, - "artifacts_expire_at": "2016-01-23T17:54:24.921Z", - "id": 6, - "name": "rspec:other", + "created_at": "2015-12-24T15:51:21.802Z", + "started_at": "2015-12-24T17:54:27.722Z", + "finished_at": "2015-12-24T17:54:27.895Z", + "duration": 0.173, + "artifacts_file": { + "filename": "artifacts.zip", + "size": 1000 + }, + "artifacts": [ + {"file_type": "archive", "size": 1000, "filename": "artifacts.zip", "file_format": "zip"}, + {"file_type": "metadata", "size": 186, "filename": "metadata.gz", "file_format": "gzip"}, + {"file_type": "trace", "size": 1500, "filename": "job.log", "file_format": "raw"}, + {"file_type": "junit", "size": 750, "filename": "junit.xml.gz", "file_format": "gzip"} + ], + "artifacts_expire_at": "2016-01-23T17:54:27.895Z", + "id": 7, + "name": "teaspoon", "pipeline": { "id": 6, "ref": "master", @@ -52,7 +62,7 @@ Example of response "stage": "test", "status": "failed", "tag": false, - "web_url": "https://example.com/foo/bar/-/jobs/6", + "web_url": "https://example.com/foo/bar/-/jobs/7", "user": { "id": 1, "name": "Administrator", @@ -83,23 +93,13 @@ Example of response }, "coverage": null, "allow_failure": false, - "created_at": "2015-12-24T15:51:21.802Z", - "started_at": "2015-12-24T17:54:27.722Z", - "finished_at": "2015-12-24T17:54:27.895Z", - "duration": 0.173, - "artifacts_file": { - "filename": "artifacts.zip", - "size": 1000 - }, - "artifacts": [ - {"file_type": "archive", "size": 1000, "filename": "artifacts.zip", "file_format": "zip"}, - {"file_type": "metadata", "size": 186, "filename": "metadata.gz", "file_format": "gzip"}, - {"file_type": "trace", "size": 1500, "filename": "job.log", "file_format": "raw"}, - {"file_type": "junit", "size": 750, "filename": "junit.xml.gz", "file_format": "gzip"} - ], - "artifacts_expire_at": "2016-01-23T17:54:27.895Z", - "id": 7, - "name": "teaspoon", + "created_at": "2015-12-24T15:51:21.727Z", + "started_at": "2015-12-24T17:54:24.729Z", + "finished_at": "2015-12-24T17:54:24.921Z", + "duration": 0.192, + "artifacts_expire_at": "2016-01-23T17:54:24.921Z", + "id": 6, + "name": "rspec:other", "pipeline": { "id": 6, "ref": "master", @@ -112,7 +112,7 @@ Example of response "stage": "test", "status": "failed", "tag": false, - "web_url": "https://example.com/foo/bar/-/jobs/7", + "web_url": "https://example.com/foo/bar/-/jobs/6", "user": { "id": 1, "name": "Administrator", diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb index d1535c43808..0769e464d26 100644 --- a/lib/api/api_guard.rb +++ b/lib/api/api_guard.rb @@ -44,7 +44,7 @@ module API # Helper Methods for Grape Endpoint module HelperMethods prepend_if_ee('EE::API::APIGuard::HelperMethods') # rubocop: disable Cop/InjectEnterpriseEditionModule - include Gitlab::Auth::UserAuthFinders + include Gitlab::Auth::AuthFinders def find_current_user! user = find_user_from_sources @@ -56,7 +56,9 @@ module API # Set admin mode for API requests (if admin) if Feature.enabled?(:user_mode_in_session) - Gitlab::Auth::CurrentUserMode.new(user).enable_admin_mode!(skip_password_validation: true) + current_user_mode = Gitlab::Auth::CurrentUserMode.new(user) + + current_user_mode.enable_sessionless_admin_mode! end user diff --git a/lib/gitlab/auth/user_auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 983682baab1..6210aca739a 100644 --- a/lib/gitlab/auth/user_auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -17,8 +17,8 @@ module Gitlab end end - module UserAuthFinders - prepend_if_ee('::EE::Gitlab::Auth::UserAuthFinders') # rubocop: disable Cop/InjectEnterpriseEditionModule + module AuthFinders + prepend_if_ee('::EE::Gitlab::Auth::AuthFinders') # rubocop: disable Cop/InjectEnterpriseEditionModule include Gitlab::Utils::StrongMemoize @@ -26,6 +26,7 @@ module Gitlab PRIVATE_TOKEN_PARAM = :private_token JOB_TOKEN_HEADER = "HTTP_JOB_TOKEN".freeze JOB_TOKEN_PARAM = :job_token + RUNNER_TOKEN_PARAM = :token # Check the Rails session for valid authentication details def find_user_from_warden @@ -85,6 +86,15 @@ module Gitlab access_token.user || raise(UnauthorizedError) end + def find_runner_from_token + return unless api_request? + + token = current_request.params[RUNNER_TOKEN_PARAM].presence + return unless token + + ::Ci::Runner.find_by_token(token) || raise(UnauthorizedError) + end + def validate_access_token!(scopes: []) return unless access_token @@ -201,7 +211,7 @@ module Gitlab end def api_request? - current_request.path.starts_with?("/api/") + current_request.path.starts_with?('/api/') end def archive_request? diff --git a/lib/gitlab/auth/current_user_mode.rb b/lib/gitlab/auth/current_user_mode.rb index df5039f50c1..cb39baaa6cc 100644 --- a/lib/gitlab/auth/current_user_mode.rb +++ b/lib/gitlab/auth/current_user_mode.rb @@ -8,9 +8,13 @@ module Gitlab # an administrator must have explicitly enabled admin-mode # e.g. on web access require re-authentication class CurrentUserMode + NotRequestedError = Class.new(StandardError) + SESSION_STORE_KEY = :current_user_mode ADMIN_MODE_START_TIME_KEY = 'admin_mode' + ADMIN_MODE_REQUESTED_TIME_KEY = 'admin_mode_requested' MAX_ADMIN_MODE_TIME = 6.hours + ADMIN_MODE_REQUESTED_GRACE_PERIOD = 5.minutes def initialize(user) @user = user @@ -19,8 +23,16 @@ module Gitlab def admin_mode? return false unless user - Gitlab::SafeRequestStore.fetch(request_store_key) do - user&.admin? && any_session_with_admin_mode? + Gitlab::SafeRequestStore.fetch(admin_mode_rs_key) do + user.admin? && any_session_with_admin_mode? + end + end + + def admin_mode_requested? + return false unless user + + Gitlab::SafeRequestStore.fetch(admin_mode_requested_rs_key) do + user.admin? && admin_mode_requested_in_grace_period? end end @@ -28,20 +40,45 @@ module Gitlab return unless user&.admin? return unless skip_password_validation || user&.valid_password?(password) + raise NotRequestedError unless admin_mode_requested? + + reset_request_store + + current_session_data[ADMIN_MODE_REQUESTED_TIME_KEY] = nil current_session_data[ADMIN_MODE_START_TIME_KEY] = Time.now end + def enable_sessionless_admin_mode! + request_admin_mode! && enable_admin_mode!(skip_password_validation: true) + end + def disable_admin_mode! + return unless user&.admin? + + reset_request_store + + current_session_data[ADMIN_MODE_REQUESTED_TIME_KEY] = nil current_session_data[ADMIN_MODE_START_TIME_KEY] = nil - Gitlab::SafeRequestStore.delete(request_store_key) + end + + def request_admin_mode! + return unless user&.admin? + + reset_request_store + + current_session_data[ADMIN_MODE_REQUESTED_TIME_KEY] = Time.now end private attr_reader :user - def request_store_key - @request_store_key ||= { res: :current_user_mode, user: user.id } + def admin_mode_rs_key + @admin_mode_rs_key ||= { res: :current_user_mode, user: user.id, method: :admin_mode? } + end + + def admin_mode_requested_rs_key + @admin_mode_requested_rs_key ||= { res: :current_user_mode, user: user.id, method: :admin_mode_requested? } end def current_session_data @@ -61,6 +98,15 @@ module Gitlab Gitlab::NamespacedSessionStore.new(SESSION_STORE_KEY, session.with_indifferent_access ) end end + + def admin_mode_requested_in_grace_period? + current_session_data[ADMIN_MODE_REQUESTED_TIME_KEY].to_i > ADMIN_MODE_REQUESTED_GRACE_PERIOD.ago.to_i + end + + def reset_request_store + Gitlab::SafeRequestStore.delete(admin_mode_rs_key) + Gitlab::SafeRequestStore.delete(admin_mode_requested_rs_key) + end end end end diff --git a/lib/gitlab/auth/request_authenticator.rb b/lib/gitlab/auth/request_authenticator.rb index aca8804b04c..9b1b7b8e879 100644 --- a/lib/gitlab/auth/request_authenticator.rb +++ b/lib/gitlab/auth/request_authenticator.rb @@ -5,7 +5,7 @@ module Gitlab module Auth class RequestAuthenticator - include UserAuthFinders + include AuthFinders attr_reader :request @@ -23,6 +23,12 @@ module Gitlab find_user_from_warden end + def runner + find_runner_from_token + rescue Gitlab::Auth::AuthenticationError + nil + end + def find_sessionless_user(request_format) find_user_from_web_access_token(request_format) || find_user_from_feed_token(request_format) || diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index b3a1d472e73..2fd4f18b756 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -73,6 +73,7 @@ tree: - :auto_devops - :triggers - :pipeline_schedules + - :container_expiration_policy - :services - protected_branches: - :merge_access_levels diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 82509e3c880..5c417aa1f29 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -234,6 +234,9 @@ msgstr[1] "" msgid "%{count} related %{pluralized_subject}: %{links}" msgstr "" +msgid "%{days} days until tags are automatically removed" +msgstr "" + msgid "%{description}- Sentry event: %{errorUrl}- First seen: %{firstSeen}- Last seen: %{lastSeen} %{countLabel}: %{count}%{userCountLabel}: %{userCount}" msgstr "" @@ -380,6 +383,12 @@ msgstr[1] "" msgid "%{tabname} changed" msgstr "" +msgid "%{tags} tag per image name" +msgstr "" + +msgid "%{tags} tags per image name" +msgstr "" + msgid "%{tag}-evidence.json" msgstr "" @@ -1134,9 +1143,15 @@ msgstr "" msgid "Admin Section" msgstr "" +msgid "Admin mode already enabled" +msgstr "" + msgid "Admin mode disabled" msgstr "" +msgid "Admin mode enabled" +msgstr "" + msgid "Admin notes" msgstr "" @@ -7050,12 +7065,27 @@ msgstr "" msgid "Every %{action} attempt has failed: %{job_error_message}. Please try again." msgstr "" +msgid "Every day" +msgstr "" + msgid "Every day (at 4:00am)" msgstr "" +msgid "Every month" +msgstr "" + msgid "Every month (on the 1st at 4:00am)" msgstr "" +msgid "Every three months" +msgstr "" + +msgid "Every two weeks" +msgstr "" + +msgid "Every week" +msgstr "" + msgid "Every week (Sundays at 4:00am)" msgstr "" @@ -9678,6 +9708,9 @@ msgstr "" msgid "Invalid input, please avoid emojis" msgstr "" +msgid "Invalid login or password" +msgstr "" + msgid "Invalid pin code" msgstr "" @@ -14397,6 +14430,9 @@ msgstr "" msgid "Raw blob request rate limit per minute" msgstr "" +msgid "Re-authentication period expired or never requested. Please try again" +msgstr "" + msgid "Re-authentication required" msgstr "" diff --git a/qa/.gitignore b/qa/.gitignore index b0ae074ac07..7bc4effd8a8 100644 --- a/qa/.gitignore +++ b/qa/.gitignore @@ -1,3 +1,4 @@ tmp/ .ruby-version +.ruby-gemset urls.yml @@ -35,6 +35,7 @@ module QA autoload :Logger, 'qa/runtime/logger' autoload :GPG, 'qa/runtime/gpg' autoload :MailHog, 'qa/runtime/mail_hog' + autoload :IPAddress, 'qa/runtime/ip_address' module API autoload :Client, 'qa/runtime/api/client' diff --git a/qa/qa/resource/api_fabricator.rb b/qa/qa/resource/api_fabricator.rb index 3862bd68c40..e6057433b55 100644 --- a/qa/qa/resource/api_fabricator.rb +++ b/qa/qa/resource/api_fabricator.rb @@ -100,7 +100,7 @@ module QA url = Runtime::API::Request.new(api_client, api_delete_path).url response = delete(url) - unless response.code == HTTP_STATUS_NO_CONTENT + unless [HTTP_STATUS_NO_CONTENT, HTTP_STATUS_ACCEPTED].include? response.code raise ResourceNotDeletedError, "Resource at #{url} could not be deleted (#{response.code}): `#{response}`." end diff --git a/qa/qa/resource/group.rb b/qa/qa/resource/group.rb index 7511396251d..c12e9dd146b 100644 --- a/qa/qa/resource/group.rb +++ b/qa/qa/resource/group.rb @@ -70,6 +70,10 @@ module QA } end + def api_delete_path + "/groups/#{id}" + end + def full_path sandbox.path + ' / ' + path end diff --git a/qa/qa/runtime/ip_address.rb b/qa/qa/runtime/ip_address.rb new file mode 100644 index 00000000000..f370882e5c7 --- /dev/null +++ b/qa/qa/runtime/ip_address.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true +require 'socket' + +module QA + module Runtime + module IPAddress + include Support::Api + HostUnreachableError = Class.new(StandardError) + + LOOPBACK_ADDRESS = '127.0.0.1' + PUBLIC_IP_ADDRESS_API = "https://api.ipify.org" + + def fetch_current_ip_address + # When running on CI against a live environment such as staging.gitlab.com, + # we use the public facing IP address + ip_address = if Env.running_in_ci? && !URI.parse(Scenario.gitlab_address).host.include?('test') + response = get(PUBLIC_IP_ADDRESS_API) + raise HostUnreachableError, "#{PUBLIC_IP_ADDRESS_API} is unreachable" unless response.code == Support::Api::HTTP_STATUS_OK + + response.body + elsif page.current_host.include?('localhost') + LOOPBACK_ADDRESS + else + Socket.ip_address_list.detect { |intf| intf.ipv4_private? }.ip_address + end + + QA::Runtime::Logger.info "Current IP address: #{ip_address}" + + ip_address + end + end + end +end diff --git a/qa/qa/support/api.rb b/qa/qa/support/api.rb index cd496efb4db..90924ffd40e 100644 --- a/qa/qa/support/api.rb +++ b/qa/qa/support/api.rb @@ -6,6 +6,7 @@ module QA HTTP_STATUS_OK = 200 HTTP_STATUS_CREATED = 201 HTTP_STATUS_NO_CONTENT = 204 + HTTP_STATUS_ACCEPTED = 202 def post(url, payload) RestClient::Request.execute( diff --git a/spec/controllers/admin/sessions_controller_spec.rb b/spec/controllers/admin/sessions_controller_spec.rb index c1cb57c0b9d..bd0bb0bd81f 100644 --- a/spec/controllers/admin/sessions_controller_spec.rb +++ b/spec/controllers/admin/sessions_controller_spec.rb @@ -17,7 +17,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do get :new expect(response).to have_gitlab_http_status(:not_found) - expect(controller.send(:current_user_mode).admin_mode?).to be(false) + expect(controller.current_user_mode.admin_mode?).to be(false) end end @@ -28,7 +28,21 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do get :new expect(response).to render_template :new - expect(controller.send(:current_user_mode).admin_mode?).to be(false) + expect(controller.current_user_mode.admin_mode?).to be(false) + end + + context 'already in admin mode' do + before do + controller.current_user_mode.request_admin_mode! + controller.current_user_mode.enable_admin_mode!(password: user.password) + end + + it 'redirects to original location' do + get :new + + expect(response).to redirect_to(admin_root_path) + expect(controller.current_user_mode.admin_mode?).to be(true) + end end end end @@ -39,7 +53,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do post :create expect(response).to have_gitlab_http_status(:not_found) - expect(controller.send(:current_user_mode).admin_mode?).to be(false) + expect(controller.current_user_mode.admin_mode?).to be(false) end end @@ -47,24 +61,60 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do let(:user) { create(:admin) } it 'sets admin mode with a valid password' do - expect(controller.send(:current_user_mode).admin_mode?).to be(false) + expect(controller.current_user_mode.admin_mode?).to be(false) controller.store_location_for(:redirect, admin_root_path) + + # triggering the auth form will request admin mode + get :new + post :create, params: { password: user.password } expect(response).to redirect_to admin_root_path - expect(controller.send(:current_user_mode).admin_mode?).to be(true) + expect(controller.current_user_mode.admin_mode?).to be(true) end it 'fails with an invalid password' do - expect(controller.send(:current_user_mode).admin_mode?).to be(false) + expect(controller.current_user_mode.admin_mode?).to be(false) controller.store_location_for(:redirect, admin_root_path) + # triggering the auth form will request admin mode + get :new + post :create, params: { password: '' } expect(response).to render_template :new - expect(controller.send(:current_user_mode).admin_mode?).to be(false) + expect(controller.current_user_mode.admin_mode?).to be(false) + end + + it 'fails if not requested first' do + expect(controller.current_user_mode.admin_mode?).to be(false) + + controller.store_location_for(:redirect, admin_root_path) + + # do not trigger the auth form + + post :create, params: { password: user.password } + + expect(response).to redirect_to(new_admin_session_path) + expect(controller.current_user_mode.admin_mode?).to be(false) + end + + it 'fails if request period expired' do + expect(controller.current_user_mode.admin_mode?).to be(false) + + controller.store_location_for(:redirect, admin_root_path) + + # triggering the auth form will request admin mode + get :new + + Timecop.freeze(Gitlab::Auth::CurrentUserMode::ADMIN_MODE_REQUESTED_GRACE_PERIOD.from_now) do + post :create, params: { password: user.password } + + expect(response).to redirect_to(new_admin_session_path) + expect(controller.current_user_mode.admin_mode?).to be(false) + end end end end @@ -75,7 +125,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do get :destroy expect(response).to have_gitlab_http_status(404) - expect(controller.send(:current_user_mode).admin_mode?).to be(false) + expect(controller.current_user_mode.admin_mode?).to be(false) end end @@ -83,15 +133,17 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do let(:user) { create(:admin) } it 'disables admin mode and redirects to main page' do - expect(controller.send(:current_user_mode).admin_mode?).to be(false) + expect(controller.current_user_mode.admin_mode?).to be(false) + + get :new post :create, params: { password: user.password } - expect(controller.send(:current_user_mode).admin_mode?).to be(true) + expect(controller.current_user_mode.admin_mode?).to be(true) get :destroy expect(response).to have_gitlab_http_status(:found) expect(response).to redirect_to(root_path) - expect(controller.send(:current_user_mode).admin_mode?).to be(false) + expect(controller.current_user_mode.admin_mode?).to be(false) end end end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 9c6ea13f948..e72ab16f62a 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -814,6 +814,7 @@ describe ApplicationController do context 'that re-authenticated' do before do + Gitlab::Auth::CurrentUserMode.new(user).request_admin_mode! Gitlab::Auth::CurrentUserMode.new(user).enable_admin_mode!(password: user.password) end diff --git a/spec/controllers/oauth/applications_controller_spec.rb b/spec/controllers/oauth/applications_controller_spec.rb index df836c2c3e3..270a2fcc1d6 100644 --- a/spec/controllers/oauth/applications_controller_spec.rb +++ b/spec/controllers/oauth/applications_controller_spec.rb @@ -62,6 +62,12 @@ describe Oauth::ApplicationsController do end end + context 'Helpers' do + it 'current_user_mode available' do + expect(subject.current_user_mode).not_to be_nil + end + end + def disable_user_oauth allow(Gitlab::CurrentSettings.current_application_settings).to receive(:user_oauth_applications?).and_return(false) end diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb index 521dbe7ee23..6c5f36804e8 100644 --- a/spec/controllers/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/omniauth_callbacks_controller_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe OmniauthCallbacksController, type: :controller do +describe OmniauthCallbacksController, type: :controller, do_not_mock_admin_mode: true do include LoginHelpers describe 'omniauth' do @@ -336,4 +336,109 @@ describe OmniauthCallbacksController, type: :controller do end end end + + describe 'enable admin mode' do + include_context 'custom session' + + let(:provider) { :auth0 } + let(:extern_uid) { 'my-uid' } + let(:user) { create(:omniauth_user, extern_uid: extern_uid, provider: provider) } + + def reauthenticate_and_check_admin_mode(expected_admin_mode:) + # Initially admin mode disabled + expect(subject.current_user_mode.admin_mode?).to be(false) + + # Trigger OmniAuth admin mode flow and expect admin mode status + post provider + + expect(request.env['warden']).to be_authenticated + expect(subject.current_user_mode.admin_mode?).to be(expected_admin_mode) + end + + context 'user and admin mode requested by the same user' do + before do + sign_in user + + mock_auth_hash(provider.to_s, extern_uid, user.email, additional_info: {}) + stub_omniauth_provider(provider, context: request) + end + + context 'with a regular user' do + it 'cannot be enabled' do + reauthenticate_and_check_admin_mode(expected_admin_mode: false) + + expect(response).to redirect_to(root_path) + end + end + + context 'with an admin user' do + let(:user) { create(:omniauth_user, extern_uid: extern_uid, provider: provider, access_level: :admin) } + + context 'when requested first' do + before do + subject.current_user_mode.request_admin_mode! + end + + it 'can be enabled' do + reauthenticate_and_check_admin_mode(expected_admin_mode: true) + + expect(response).to redirect_to(admin_root_path) + end + end + + context 'when not requested first' do + it 'cannot be enabled' do + reauthenticate_and_check_admin_mode(expected_admin_mode: false) + + expect(response).to redirect_to(root_path) + end + end + end + end + + context 'user and admin mode requested by different users' do + let(:reauth_extern_uid) { 'another_uid' } + let(:reauth_user) { create(:omniauth_user, extern_uid: reauth_extern_uid, provider: provider) } + + before do + sign_in user + + mock_auth_hash(provider.to_s, reauth_extern_uid, reauth_user.email, additional_info: {}) + stub_omniauth_provider(provider, context: request) + end + + context 'with a regular user' do + it 'cannot be enabled' do + reauthenticate_and_check_admin_mode(expected_admin_mode: false) + + expect(response).to redirect_to(profile_account_path) + end + end + + context 'with an admin user' do + let(:user) { create(:omniauth_user, extern_uid: extern_uid, provider: provider, access_level: :admin) } + let(:reauth_user) { create(:omniauth_user, extern_uid: reauth_extern_uid, provider: provider, access_level: :admin) } + + context 'when requested first' do + before do + subject.current_user_mode.request_admin_mode! + end + + it 'cannot be enabled' do + reauthenticate_and_check_admin_mode(expected_admin_mode: false) + + expect(response).to redirect_to(new_admin_session_path) + end + end + + context 'when not requested first' do + it 'cannot be enabled' do + reauthenticate_and_check_admin_mode(expected_admin_mode: false) + + expect(response).to redirect_to(profile_account_path) + end + end + end + end + end end diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb index ba7374d5040..741f46cef45 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe 'Gcp Cluster', :js do +describe 'Gcp Cluster', :js, :do_not_mock_admin_mode do include GoogleApi::CloudPlatformHelpers let(:project) { create(:project) } diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 382b5f3cac0..01687674309 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -49,11 +49,11 @@ describe 'Environments page', :js do it 'renders second page of pipelines' do visit_environments(project, scope: 'available') - find('.js-next-button').click + find('.page-link.next-page-item').click wait_for_requests - expect(page).to have_selector('.gl-pagination .page', count: 2) - expect(find('.gl-pagination .page-item.active .page-link').text).to eq("2") + expect(page).to have_selector('.gl-pagination .page-link', count: 4) + expect(find('.gl-pagination .page-link.active').text).to eq("2") end end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index f5558a1f2ec..b4c9eb7ebec 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -592,15 +592,15 @@ describe 'Pipelines', :js do visit project_pipelines_path(project, page: '2') wait_for_requests - expect(page).to have_selector('.gl-pagination .page', count: 2) + expect(page).to have_selector('.gl-pagination .page-link', count: 4) end it 'shows updated content' do visit project_pipelines_path(project) wait_for_requests - page.find('.js-next-button .page-link').click + page.find('.page-link.next-page-item').click - expect(page).to have_selector('.gl-pagination .page', count: 2) + expect(page).to have_selector('.gl-pagination .page-link', count: 4) end end end diff --git a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js b/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js index 7ba35358442..c9cdd728509 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import $ from 'jquery'; import { GlIcon } from '@gitlab/ui'; import ClusterFormDropdown from '~/create_cluster/eks_cluster/components/cluster_form_dropdown.vue'; @@ -169,4 +170,14 @@ describe('ClusterFormDropdown', () => { expect(vm.findAll('.js-dropdown-item').length).toEqual(1); expect(vm.find('.js-dropdown-item').text()).toEqual(secondItem.name); }); + + it('focuses dropdown search input when dropdown is displayed', () => { + const dropdownEl = vm.find('.dropdown').element; + + expect(vm.find(DropdownSearchInput).props('focused')).toBe(false); + + $(dropdownEl).trigger('shown.bs.dropdown'); + + expect(vm.find(DropdownSearchInput).props('focused')).toBe(true); + }); }); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js new file mode 100644 index 00000000000..0d0e4ae4349 --- /dev/null +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js @@ -0,0 +1,55 @@ +import { mount } from '@vue/test-utils'; +import DropdownSearchInputComponent from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; + +describe('DropdownSearchInputComponent', () => { + let wrapper; + + const defaultProps = { + placeholderText: 'Search something', + }; + const buildVM = (propsData = defaultProps) => { + wrapper = mount(DropdownSearchInputComponent, { + propsData, + }); + }; + const findInputEl = () => wrapper.find('.dropdown-input-field'); + + beforeEach(() => { + buildVM(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + it('renders input element with type `search`', () => { + expect(findInputEl().exists()).toBe(true); + expect(findInputEl().attributes('type')).toBe('search'); + }); + + it('renders search icon element', () => { + expect(wrapper.find('.fa-search.dropdown-input-search').exists()).toBe(true); + }); + + it('renders clear search icon element', () => { + expect(wrapper.find('.fa-times.dropdown-input-clear.js-dropdown-input-clear').exists()).toBe( + true, + ); + }); + + it('displays custom placeholder text', () => { + expect(findInputEl().attributes('placeholder')).toBe(defaultProps.placeholderText); + }); + + it('focuses input element when focused property equals true', () => { + const inputEl = findInputEl().element; + + jest.spyOn(inputEl, 'focus'); + + wrapper.setProps({ focused: true }); + + expect(inputEl.focus).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/table_pagination_spec.js b/spec/frontend/vue_shared/components/table_pagination_spec.js index 0a9ff36b2fb..8105d1fcef3 100644 --- a/spec/frontend/vue_shared/components/table_pagination_spec.js +++ b/spec/frontend/vue_shared/components/table_pagination_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import { GlPagination } from '@gitlab/ui'; describe('Pagination component', () => { let wrapper; @@ -12,15 +13,6 @@ describe('Pagination component', () => { }); }; - const findFirstButtonLink = () => wrapper.find('.js-first-button .page-link'); - const findPreviousButton = () => wrapper.find('.js-previous-button'); - const findPreviousButtonLink = () => wrapper.find('.js-previous-button .page-link'); - const findNextButton = () => wrapper.find('.js-next-button'); - const findNextButtonLink = () => wrapper.find('.js-next-button .page-link'); - const findLastButtonLink = () => wrapper.find('.js-last-button .page-link'); - const findPages = () => wrapper.findAll('.page'); - const findSeparator = () => wrapper.find('.separator'); - beforeEach(() => { spy = jest.fn(); }); @@ -46,290 +38,54 @@ describe('Pagination component', () => { expect(wrapper.isEmpty()).toBe(true); }); - describe('prev button', () => { - it('should be disabled and non clickable', () => { - mountComponent({ - pageInfo: { - nextPage: 2, - page: 1, - perPage: 20, - previousPage: NaN, - total: 84, - totalPages: 5, - }, - change: spy, - }); - - expect(findPreviousButton().classes()).toContain('disabled'); - findPreviousButtonLink().trigger('click'); - expect(spy).not.toHaveBeenCalled(); - }); - - it('should be disabled and non clickable when total and totalPages are NaN', () => { - mountComponent({ - pageInfo: { - nextPage: 2, - page: 1, - perPage: 20, - previousPage: NaN, - total: NaN, - totalPages: NaN, - }, - change: spy, - }); - expect(findPreviousButton().classes()).toContain('disabled'); - findPreviousButtonLink().trigger('click'); - expect(spy).not.toHaveBeenCalled(); - }); - - it('should be enabled and clickable', () => { - mountComponent({ - pageInfo: { - nextPage: 3, - page: 2, - perPage: 20, - previousPage: 1, - total: 84, - totalPages: 5, - }, - change: spy, - }); - findPreviousButtonLink().trigger('click'); - expect(spy).toHaveBeenCalledWith(1); - }); - - it('should be enabled and clickable when total and totalPages are NaN', () => { - mountComponent({ - pageInfo: { - nextPage: 3, - page: 2, - perPage: 20, - previousPage: 1, - total: NaN, - totalPages: NaN, - }, - change: spy, - }); - findPreviousButtonLink().trigger('click'); - expect(spy).toHaveBeenCalledWith(1); - }); - }); - - describe('first button', () => { - it('should call the change callback with the first page', () => { - mountComponent({ - pageInfo: { - nextPage: 3, - page: 2, - perPage: 20, - previousPage: 1, - total: 84, - totalPages: 5, - }, - change: spy, - }); - const button = findFirstButtonLink(); - expect(button.text().trim()).toEqual('« First'); - button.trigger('click'); - expect(spy).toHaveBeenCalledWith(1); - }); - - it('should call the change callback with the first page when total and totalPages are NaN', () => { - mountComponent({ - pageInfo: { - nextPage: 3, - page: 2, - perPage: 20, - previousPage: 1, - total: NaN, - totalPages: NaN, - }, - change: spy, - }); - const button = findFirstButtonLink(); - expect(button.text().trim()).toEqual('« First'); - button.trigger('click'); - expect(spy).toHaveBeenCalledWith(1); - }); - }); - - describe('last button', () => { - it('should call the change callback with the last page', () => { - mountComponent({ - pageInfo: { - nextPage: 3, - page: 2, - perPage: 20, - previousPage: 1, - total: 84, - totalPages: 5, - }, - change: spy, - }); - const button = findLastButtonLink(); - expect(button.text().trim()).toEqual('Last »'); - button.trigger('click'); - expect(spy).toHaveBeenCalledWith(5); - }); - - it('should not render', () => { - mountComponent({ - pageInfo: { - nextPage: 3, - page: 2, - perPage: 20, - previousPage: 1, - total: NaN, - totalPages: NaN, - }, - change: spy, - }); - expect(findLastButtonLink().exists()).toBe(false); - }); - }); - - describe('next button', () => { - it('should be disabled and non clickable', () => { - mountComponent({ - pageInfo: { - nextPage: NaN, - page: 5, - perPage: 20, - previousPage: 4, - total: 84, - totalPages: 5, - }, - change: spy, - }); - expect( - findNextButton() - .text() - .trim(), - ).toEqual('Next ›'); - findNextButtonLink().trigger('click'); - expect(spy).not.toHaveBeenCalled(); - }); - - it('should be disabled and non clickable when total and totalPages are NaN', () => { - mountComponent({ - pageInfo: { - nextPage: NaN, - page: 5, - perPage: 20, - previousPage: 4, - total: NaN, - totalPages: NaN, - }, - change: spy, - }); - expect( - findNextButton() - .text() - .trim(), - ).toEqual('Next ›'); - findNextButtonLink().trigger('click'); - expect(spy).not.toHaveBeenCalled(); - }); - - it('should be enabled and clickable', () => { - mountComponent({ - pageInfo: { - nextPage: 4, - page: 3, - perPage: 20, - previousPage: 2, - total: 84, - totalPages: 5, - }, - change: spy, - }); - findNextButtonLink().trigger('click'); - expect(spy).toHaveBeenCalledWith(4); + it('renders if there is a next page', () => { + mountComponent({ + pageInfo: { + nextPage: 2, + page: 1, + perPage: 20, + previousPage: NaN, + total: 15, + totalPages: 1, + }, + change: spy, }); - it('should be enabled and clickable when total and totalPages are NaN', () => { - mountComponent({ - pageInfo: { - nextPage: 4, - page: 3, - perPage: 20, - previousPage: 2, - total: NaN, - totalPages: NaN, - }, - change: spy, - }); - findNextButtonLink().trigger('click'); - expect(spy).toHaveBeenCalledWith(4); - }); + expect(wrapper.isEmpty()).toBe(false); }); - describe('numbered buttons', () => { - it('should render 5 pages', () => { - mountComponent({ - pageInfo: { - nextPage: 4, - page: 3, - perPage: 20, - previousPage: 2, - total: 84, - totalPages: 5, - }, - change: spy, - }); - expect(findPages().length).toEqual(5); + it('renders if there is a prev page', () => { + mountComponent({ + pageInfo: { + nextPage: NaN, + page: 2, + perPage: 20, + previousPage: 1, + total: 15, + totalPages: 1, + }, + change: spy, }); - it('should not render any page', () => { - mountComponent({ - pageInfo: { - nextPage: 4, - page: 3, - perPage: 20, - previousPage: 2, - total: NaN, - totalPages: NaN, - }, - change: spy, - }); - expect(findPages().length).toEqual(0); - }); + expect(wrapper.isEmpty()).toBe(false); }); + }); - describe('spread operator', () => { - it('should render', () => { - mountComponent({ - pageInfo: { - nextPage: 4, - page: 3, - perPage: 20, - previousPage: 2, - total: 84, - totalPages: 10, - }, - change: spy, - }); - expect( - findSeparator() - .text() - .trim(), - ).toEqual('...'); - }); - - it('should not render', () => { - mountComponent({ - pageInfo: { - nextPage: 4, - page: 3, - perPage: 20, - previousPage: 2, - total: NaN, - totalPages: NaN, - }, - change: spy, - }); - expect(findSeparator().exists()).toBe(false); + describe('events', () => { + it('calls change method when page changes', () => { + mountComponent({ + pageInfo: { + nextPage: NaN, + page: 2, + perPage: 20, + previousPage: 1, + total: 15, + totalPages: 1, + }, + change: spy, }); + wrapper.find(GlPagination).vm.$emit('input', 3); + expect(spy).toHaveBeenCalledWith(3); }); }); }); diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index 5d1a5fe1987..a3c51f24307 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -25,7 +25,7 @@ describe GitlabSchema.types['Project'] do issue pipelines removeSourceBranchAfterMerge sentryDetailedError snippets ] - is_expected.to have_graphql_fields(*expected_fields) + is_expected.to include_graphql_fields(*expected_fields) end describe 'issue field' do diff --git a/spec/helpers/container_expiration_policies_helper_spec.rb b/spec/helpers/container_expiration_policies_helper_spec.rb new file mode 100644 index 00000000000..3eb1234d82b --- /dev/null +++ b/spec/helpers/container_expiration_policies_helper_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ContainerExpirationPoliciesHelper do + describe '#keep_n_options' do + it 'returns keep_n options formatted for dropdown usage' do + expected_result = [ + { key: 1, label: '1 tag per image name' }, + { key: 5, label: '5 tags per image name' }, + { key: 10, label: '10 tags per image name' }, + { key: 25, label: '25 tags per image name' }, + { key: 50, label: '50 tags per image name' }, + { key: 100, label: '100 tags per image name' } + ] + + expect(helper.keep_n_options).to eq(expected_result) + end + end + + describe '#cadence_options' do + it 'returns cadence options formatted for dropdown usage' do + expected_result = [ + { key: '1d', label: 'Every day' }, + { key: '7d', label: 'Every week' }, + { key: '14d', label: 'Every two weeks' }, + { key: '1month', label: 'Every month' }, + { key: '3month', label: 'Every three months' } + ] + + expect(helper.cadence_options).to eq(expected_result) + end + end + + describe '#older_than_options' do + it 'returns older_than options formatted for dropdown usage' do + expected_result = [ + { key: '7d', label: '7 days until tags are automatically removed' }, + { key: '14d', label: '14 days until tags are automatically removed' }, + { key: '30d', label: '30 days until tags are automatically removed' }, + { key: '90d', label: '90 days until tags are automatically removed' } + ] + + expect(helper.older_than_options).to eq(expected_result) + end + end +end diff --git a/spec/helpers/nav_helper_spec.rb b/spec/helpers/nav_helper_spec.rb index 882a125a0da..8d7572c5b5f 100644 --- a/spec/helpers/nav_helper_spec.rb +++ b/spec/helpers/nav_helper_spec.rb @@ -42,6 +42,7 @@ describe NavHelper, :do_not_mock_admin_mode do context 'with admin mode enabled' do before do + current_user_mode.request_admin_mode! current_user_mode.enable_admin_mode!(password: user.password) end @@ -62,6 +63,7 @@ describe NavHelper, :do_not_mock_admin_mode do context 'with admin mode enabled' do before do + current_user_mode.request_admin_mode! current_user_mode.enable_admin_mode!(password: user.password) end @@ -89,11 +91,18 @@ describe NavHelper, :do_not_mock_admin_mode do end end - it 'returns only the sign in and search when the user is not logged in' do - allow(helper).to receive(:current_user).and_return(nil) - allow(helper).to receive(:can?).with(nil, :read_cross_project) { true } + context 'when the user is not logged in' do + let(:current_user_mode) { Gitlab::Auth::CurrentUserMode.new(nil) } - expect(helper.header_links).to contain_exactly(:sign_in, :search) + before do + allow(helper).to receive(:current_user).and_return(nil) + allow(helper).to receive(:current_user_mode).and_return(current_user_mode) + allow(helper).to receive(:can?).with(nil, :read_cross_project) { true } + end + + it 'returns only the sign in and search when the user is not logged in' do + expect(helper.header_links).to contain_exactly(:sign_in, :search) + end end end diff --git a/spec/javascripts/commit/pipelines/pipelines_spec.js b/spec/javascripts/commit/pipelines/pipelines_spec.js index e2bbada3a49..29bdf05b8cf 100644 --- a/spec/javascripts/commit/pipelines/pipelines_spec.js +++ b/spec/javascripts/commit/pipelines/pipelines_spec.js @@ -83,7 +83,7 @@ describe('Pipelines table in Commits and Merge requests', function() { }; vm.$nextTick(() => { - vm.$el.querySelector('.js-next-button .page-link').click(); + vm.$el.querySelector('.next-page-item').click(); expect(vm.updateContent).toHaveBeenCalledWith({ page: '2' }); done(); diff --git a/spec/javascripts/environments/environments_app_spec.js b/spec/javascripts/environments/environments_app_spec.js index 9c8da4970f4..75526c2ba74 100644 --- a/spec/javascripts/environments/environments_app_spec.js +++ b/spec/javascripts/environments/environments_app_spec.js @@ -92,13 +92,13 @@ describe('Environment', () => { describe('pagination', () => { it('should render pagination', () => { - expect(component.$el.querySelectorAll('.gl-pagination li').length).toEqual(5); + expect(component.$el.querySelectorAll('.gl-pagination li').length).toEqual(9); }); it('should make an API request when page is clicked', done => { spyOn(component, 'updateContent'); setTimeout(() => { - component.$el.querySelector('.gl-pagination li:nth-child(5) .page-link').click(); + component.$el.querySelector('.gl-pagination li:nth-child(3) .page-link').click(); expect(component.updateContent).toHaveBeenCalledWith({ scope: 'available', page: '2' }); done(); diff --git a/spec/javascripts/environments/folder/environments_folder_view_spec.js b/spec/javascripts/environments/folder/environments_folder_view_spec.js index d217bbb3078..6530201240f 100644 --- a/spec/javascripts/environments/folder/environments_folder_view_spec.js +++ b/spec/javascripts/environments/folder/environments_folder_view_spec.js @@ -115,7 +115,9 @@ describe('Environments Folder View', () => { it('should make an API request when changing page', done => { spyOn(component, 'updateContent'); setTimeout(() => { - component.$el.querySelector('.gl-pagination .js-last-button .page-link').click(); + component.$el + .querySelector('.gl-pagination .page-item:nth-last-of-type(2) .page-link') + .click(); expect(component.updateContent).toHaveBeenCalledWith({ scope: component.scope, diff --git a/spec/javascripts/pipelines/pipelines_spec.js b/spec/javascripts/pipelines/pipelines_spec.js index e1123cc7248..5cd91413c5f 100644 --- a/spec/javascripts/pipelines/pipelines_spec.js +++ b/spec/javascripts/pipelines/pipelines_spec.js @@ -446,7 +446,7 @@ describe('Pipelines', () => { }; vm.$nextTick(() => { - vm.$el.querySelector('.js-next-button .page-link').click(); + vm.$el.querySelector('.next-page-item').click(); expect(vm.updateContent).toHaveBeenCalledWith({ scope: 'all', page: '2' }); diff --git a/spec/javascripts/vue_shared/components/dropdown/dropdown_search_input_spec.js b/spec/javascripts/vue_shared/components/dropdown/dropdown_search_input_spec.js deleted file mode 100644 index 456f310d10c..00000000000 --- a/spec/javascripts/vue_shared/components/dropdown/dropdown_search_input_spec.js +++ /dev/null @@ -1,51 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import dropdownSearchInputComponent from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; - -const componentConfig = { - placeholderText: 'Search something', -}; - -const createComponent = (config = componentConfig) => { - const Component = Vue.extend(dropdownSearchInputComponent); - - return mountComponent(Component, config); -}; - -describe('DropdownSearchInputComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('template', () => { - it('renders input element with type `search`', () => { - const inputEl = vm.$el.querySelector('input.dropdown-input-field'); - - expect(inputEl).not.toBeNull(); - expect(inputEl.getAttribute('type')).toBe('search'); - }); - - it('renders search icon element', () => { - expect(vm.$el.querySelector('.fa-search.dropdown-input-search')).not.toBeNull(); - }); - - it('renders clear search icon element', () => { - expect( - vm.$el.querySelector('.fa-times.dropdown-input-clear.js-dropdown-input-clear'), - ).not.toBeNull(); - }); - - it('displays custom placeholder text', () => { - const inputEl = vm.$el.querySelector('input.dropdown-input-field'); - - expect(inputEl.getAttribute('placeholder')).toBe(componentConfig.placeholderText); - }); - }); -}); diff --git a/spec/lib/gitlab/auth/user_auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb index 125039edcf8..3d10f411310 100644 --- a/spec/lib/gitlab/auth/user_auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/auth_finders_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe Gitlab::Auth::UserAuthFinders do +describe Gitlab::Auth::AuthFinders do include described_class let(:user) { create(:user) } @@ -196,13 +196,13 @@ describe Gitlab::Auth::UserAuthFinders do context 'when validate_access_token! returns valid' do it 'returns user' do - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[described_class::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect(find_user_from_access_token).to eq user end it 'returns exception if token has no user' do - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[described_class::PRIVATE_TOKEN_HEADER] = personal_access_token.token allow_any_instance_of(PersonalAccessToken).to receive(:user).and_return(nil) expect { find_user_from_access_token }.to raise_error(Gitlab::Auth::UnauthorizedError) @@ -228,7 +228,7 @@ describe Gitlab::Auth::UserAuthFinders do let(:personal_access_token) { create(:personal_access_token, user: user) } before do - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[described_class::PRIVATE_TOKEN_HEADER] = personal_access_token.token end it 'returns exception if token has no user' do @@ -279,7 +279,7 @@ describe Gitlab::Auth::UserAuthFinders do context 'passed as header' do it 'returns token if valid personal_access_token' do - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[described_class::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect(find_personal_access_token).to eq personal_access_token end @@ -287,7 +287,7 @@ describe Gitlab::Auth::UserAuthFinders do context 'passed as param' do it 'returns token if valid personal_access_token' do - set_param(Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_PARAM, personal_access_token.token) + set_param(described_class::PRIVATE_TOKEN_PARAM, personal_access_token.token) expect(find_personal_access_token).to eq personal_access_token end @@ -298,7 +298,7 @@ describe Gitlab::Auth::UserAuthFinders do end it 'returns exception if invalid personal_access_token' do - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = 'invalid_token' + env[described_class::PRIVATE_TOKEN_HEADER] = 'invalid_token' expect { find_personal_access_token }.to raise_error(Gitlab::Auth::UnauthorizedError) end @@ -379,4 +379,58 @@ describe Gitlab::Auth::UserAuthFinders do end end end + + describe '#find_runner_from_token' do + let(:runner) { create(:ci_runner) } + + context 'with API requests' do + before do + env['SCRIPT_NAME'] = '/api/endpoint' + end + + it 'returns the runner if token is valid' do + set_param(:token, runner.token) + + expect(find_runner_from_token).to eq(runner) + end + + it 'returns nil if token is not present' do + expect(find_runner_from_token).to be_nil + end + + it 'returns nil if token is blank' do + set_param(:token, '') + + expect(find_runner_from_token).to be_nil + end + + it 'returns exception if invalid token' do + set_param(:token, 'invalid_token') + + expect { find_runner_from_token }.to raise_error(Gitlab::Auth::UnauthorizedError) + end + end + + context 'without API requests' do + before do + env['SCRIPT_NAME'] = 'url.ics' + end + + it 'returns nil if token is valid' do + set_param(:token, runner.token) + + expect(find_runner_from_token).to be_nil + end + + it 'returns nil if token is blank' do + expect(find_runner_from_token).to be_nil + end + + it 'returns nil if invalid token' do + set_param(:token, 'invalid_token') + + expect(find_runner_from_token).to be_nil + end + end + end end diff --git a/spec/lib/gitlab/auth/current_user_mode_spec.rb b/spec/lib/gitlab/auth/current_user_mode_spec.rb index b93d460cf48..3b3db0f7315 100644 --- a/spec/lib/gitlab/auth/current_user_mode_spec.rb +++ b/spec/lib/gitlab/auth/current_user_mode_spec.rb @@ -62,69 +62,90 @@ describe Gitlab::Auth::CurrentUserMode, :do_not_mock_admin_mode do context 'when the user is an admin' do let(:user) { build(:user, :admin) } - it 'is false by default' do - expect(subject.admin_mode?).to be(false) - end - - it 'cannot be enabled with an invalid password' do - subject.enable_admin_mode!(password: nil) - - expect(subject.admin_mode?).to be(false) - end + context 'when admin mode not requested' do + it 'is false by default' do + expect(subject.admin_mode?).to be(false) + end - it 'can be enabled with a valid password' do - subject.enable_admin_mode!(password: user.password) + it 'raises exception if we try to enable it' do + expect do + subject.enable_admin_mode!(password: user.password) + end.to raise_error(::Gitlab::Auth::CurrentUserMode::NotRequestedError) - expect(subject.admin_mode?).to be(true) + expect(subject.admin_mode?).to be(false) + end end - it 'can be disabled' do - subject.enable_admin_mode!(password: user.password) - subject.disable_admin_mode! - - expect(subject.admin_mode?).to be(false) - end + context 'when admin mode requested first' do + before do + subject.request_admin_mode! + end - it 'will expire in the future' do - subject.enable_admin_mode!(password: user.password) - expect(subject.admin_mode?).to be(true), 'admin mode is not active in the present' + it 'is false by default' do + expect(subject.admin_mode?).to be(false) + end - Timecop.freeze(Gitlab::Auth::CurrentUserMode::MAX_ADMIN_MODE_TIME.from_now) do - # in the future this will be a new request, simulate by clearing the RequestStore - Gitlab::SafeRequestStore.clear! + it 'cannot be enabled with an invalid password' do + subject.enable_admin_mode!(password: nil) - expect(subject.admin_mode?).to be(false), 'admin mode did not expire in the future' + expect(subject.admin_mode?).to be(false) end - end - context 'skipping password validation' do it 'can be enabled with a valid password' do - subject.enable_admin_mode!(password: user.password, skip_password_validation: true) + subject.enable_admin_mode!(password: user.password) expect(subject.admin_mode?).to be(true) end - it 'can be enabled with an invalid password' do - subject.enable_admin_mode!(skip_password_validation: true) + it 'can be disabled' do + subject.enable_admin_mode!(password: user.password) + subject.disable_admin_mode! - expect(subject.admin_mode?).to be(true) + expect(subject.admin_mode?).to be(false) end - end - context 'with two independent sessions' do - let(:another_session) { {} } - let(:another_subject) { described_class.new(user) } + it 'will expire in the future' do + subject.enable_admin_mode!(password: user.password) + expect(subject.admin_mode?).to be(true), 'admin mode is not active in the present' - before do - allow(ActiveSession).to receive(:list_sessions).with(user).and_return([session, another_session]) + Timecop.freeze(Gitlab::Auth::CurrentUserMode::MAX_ADMIN_MODE_TIME.from_now) do + # in the future this will be a new request, simulate by clearing the RequestStore + Gitlab::SafeRequestStore.clear! + + expect(subject.admin_mode?).to be(false), 'admin mode did not expire in the future' + end end - it 'can be enabled in one and seen in the other' do - Gitlab::Session.with_session(another_session) do - another_subject.enable_admin_mode!(password: user.password) + context 'skipping password validation' do + it 'can be enabled with a valid password' do + subject.enable_admin_mode!(password: user.password, skip_password_validation: true) + + expect(subject.admin_mode?).to be(true) end - expect(subject.admin_mode?).to be(true) + it 'can be enabled with an invalid password' do + subject.enable_admin_mode!(skip_password_validation: true) + + expect(subject.admin_mode?).to be(true) + end + end + + context 'with two independent sessions' do + let(:another_session) { {} } + let(:another_subject) { described_class.new(user) } + + before do + allow(ActiveSession).to receive(:list_sessions).with(user).and_return([session, another_session]) + end + + it 'can be enabled in one and seen in the other' do + Gitlab::Session.with_session(another_session) do + another_subject.request_admin_mode! + another_subject.enable_admin_mode!(password: user.password) + end + + expect(subject.admin_mode?).to be(true) + end end end end @@ -134,16 +155,28 @@ describe Gitlab::Auth::CurrentUserMode, :do_not_mock_admin_mode do let(:user) { build(:user, :admin) } it 'creates a timestamp in the session' do + subject.request_admin_mode! subject.enable_admin_mode!(password: user.password) expect(session).to include(expected_session_entry(be_within(1.second).of Time.now)) end end + describe '#enable_sessionless_admin_mode!' do + let(:user) { build(:user, :admin) } + + it 'enabled admin mode without password' do + subject.enable_sessionless_admin_mode! + + expect(subject.admin_mode?).to be(true) + end + end + describe '#disable_admin_mode!' do let(:user) { build(:user, :admin) } it 'sets the session timestamp to nil' do + subject.request_admin_mode! subject.disable_admin_mode! expect(session).to include(expected_session_entry(be_nil)) diff --git a/spec/lib/gitlab/auth/request_authenticator_spec.rb b/spec/lib/gitlab/auth/request_authenticator_spec.rb index f7fff389d88..4dbcd0df302 100644 --- a/spec/lib/gitlab/auth/request_authenticator_spec.rb +++ b/spec/lib/gitlab/auth/request_authenticator_spec.rb @@ -66,4 +66,28 @@ describe Gitlab::Auth::RequestAuthenticator do expect(subject.find_sessionless_user([:api])).to be_blank end end + + describe '#runner' do + let!(:runner) { build(:ci_runner) } + + it 'returns the runner using #find_runner_from_token' do + expect_any_instance_of(described_class) + .to receive(:find_runner_from_token) + .and_return(runner) + + expect(subject.runner).to eq runner + end + + it 'returns nil if no runner is found' do + expect(subject.runner).to be_blank + end + + it 'rescue Gitlab::Auth::AuthenticationError exceptions' do + expect_any_instance_of(described_class) + .to receive(:find_runner_from_token) + .and_raise(Gitlab::Auth::UnauthorizedError) + + expect(subject.runner).to be_blank + end + end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 26793f28bd8..8d436fb28e0 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -443,6 +443,7 @@ project: - downstream_project_subscriptions - service_desk_setting - import_failures +- container_expiration_policy award_emoji: - awardable - user diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index fa6bf14bf64..bf8c079f027 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -773,3 +773,13 @@ ZoomMeeting: ServiceDeskSetting: - project_id - issue_template_key +ContainerExpirationPolicy: +- created_at +- updated_at +- next_run_at +- project_id +- name_regex +- cadence +- older_than +- keep_n +- enabled diff --git a/spec/models/container_expiration_policy_spec.rb b/spec/models/container_expiration_policy_spec.rb new file mode 100644 index 00000000000..1ce76490448 --- /dev/null +++ b/spec/models/container_expiration_policy_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ContainerExpirationPolicy, type: :model do + describe 'relationships' do + it { is_expected.to belong_to(:project) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:project) } + + describe '#enabled' do + it { is_expected.to allow_value(true).for(:enabled) } + it { is_expected.to allow_value(false).for(:enabled) } + it { is_expected.not_to allow_value(nil).for(:enabled) } + end + + describe '#cadence' do + it { is_expected.to validate_presence_of(:cadence) } + + it { is_expected.to allow_value('1d').for(:cadence) } + it { is_expected.to allow_value('1month').for(:cadence) } + it { is_expected.not_to allow_value('123asdf').for(:cadence) } + it { is_expected.not_to allow_value(nil).for(:cadence) } + end + + describe '#older_than' do + it { is_expected.to allow_value('7d').for(:older_than) } + it { is_expected.to allow_value('14d').for(:older_than) } + it { is_expected.to allow_value(nil).for(:older_than) } + it { is_expected.not_to allow_value('123asdf').for(:older_than) } + end + + describe '#keep_n' do + it { is_expected.to allow_value(10).for(:keep_n) } + it { is_expected.to allow_value(nil).for(:keep_n) } + it { is_expected.not_to allow_value('foo').for(:keep_n) } + end + end +end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index c93e6aafd75..3089afd8d8a 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -199,6 +199,13 @@ describe Namespace do expect(described_class.find_by_pages_host(host)).to eq(namespace) end + + it "returns no result if the provided host is not subdomain of the Pages host" do + create(:namespace, name: 'namespace.io') + host = "namespace.io" + + expect(described_class.find_by_pages_host(host)).to eq(nil) + end end describe '#ancestors_upto' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index e7abdf847e1..feb06f4ffc9 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -62,6 +62,7 @@ describe Project do it { is_expected.to have_one(:external_wiki_service) } it { is_expected.to have_one(:project_feature) } it { is_expected.to have_one(:project_repository) } + it { is_expected.to have_one(:container_expiration_policy) } it { is_expected.to have_one(:statistics).class_name('ProjectStatistics') } it { is_expected.to have_one(:import_data).class_name('ProjectImportData') } it { is_expected.to have_one(:last_event).class_name('Event') } @@ -137,6 +138,13 @@ describe Project do expect(project.ci_cd_settings).to be_persisted end + it 'automatically creates a container expiration policy row' do + project = create(:project) + + expect(project.container_expiration_policy).to be_an_instance_of(ContainerExpirationPolicy) + expect(project.container_expiration_policy).to be_persisted + end + it 'automatically creates a Pages metadata row' do project = create(:project) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 4f9dfbd9103..9e8aa6a95e8 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2839,6 +2839,7 @@ describe User, :do_not_mock_admin_mode do context 'when admin mode is enabled' do before do + Gitlab::Auth::CurrentUserMode.new(user).request_admin_mode! Gitlab::Auth::CurrentUserMode.new(user).enable_admin_mode!(password: user.password) end diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index bbfe40041a1..0c53c04ba40 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -146,13 +146,13 @@ describe API::Helpers do let(:personal_access_token) { create(:personal_access_token, user: user) } it "returns a 401 response for an invalid token" do - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = 'invalid token' + env[Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER] = 'invalid token' expect { current_user }.to raise_error /401/ end it "returns a 403 response for a user without access" do - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) expect { current_user }.to raise_error /403/ @@ -160,7 +160,7 @@ describe API::Helpers do it 'returns a 403 response for a user who is blocked' do user.block! - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect { current_user }.to raise_error /403/ end @@ -168,7 +168,7 @@ describe API::Helpers do context 'when terms are enforced' do before do enforce_terms - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token end it 'returns a 403 when a user has not accepted the terms' do @@ -183,27 +183,27 @@ describe API::Helpers do end it "sets current_user" do - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect(current_user).to eq(user) end it "does not allow tokens without the appropriate scope" do personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user']) - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect { current_user }.to raise_error Gitlab::Auth::InsufficientScopeError end it 'does not allow revoked tokens' do personal_access_token.revoke! - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect { current_user }.to raise_error Gitlab::Auth::RevokedError end it 'does not allow expired tokens' do personal_access_token.update!(expires_at: 1.day.ago) - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect { current_user }.to raise_error Gitlab::Auth::ExpiredError end @@ -213,7 +213,7 @@ describe API::Helpers do before do stub_config_setting(impersonation_enabled: false) - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER] = personal_access_token.token end it 'does not allow impersonation tokens' do @@ -478,7 +478,7 @@ describe API::Helpers do context 'passed as param' do before do - set_param(Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_PARAM, token.token) + set_param(Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_PARAM, token.token) end it_behaves_like 'sudo' @@ -486,7 +486,7 @@ describe API::Helpers do context 'passed as header' do before do - env[Gitlab::Auth::UserAuthFinders::PRIVATE_TOKEN_HEADER] = token.token + env[Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER] = token.token end it_behaves_like 'sudo' diff --git a/spec/requests/rack_attack_global_spec.rb b/spec/requests/rack_attack_global_spec.rb index 59113687d1c..9e62d84d313 100644 --- a/spec/requests/rack_attack_global_spec.rb +++ b/spec/requests/rack_attack_global_spec.rb @@ -100,6 +100,18 @@ describe 'Rack Attack global throttles' do end end + context 'when the request is authenticated by a runner token' do + let(:request_jobs_url) { '/api/v4/jobs/request' } + let(:runner) { create(:ci_runner) } + + it 'does not cont as unauthenticated' do + (1 + requests_per_period).times do + post request_jobs_url, params: { token: runner.token } + expect(response).to have_http_status 204 + end + end + end + it 'logs RackAttack info into structured logs' do requests_per_period.times do get url_that_does_not_require_authentication diff --git a/spec/support/helpers/admin_mode_helpers.rb b/spec/support/helpers/admin_mode_helpers.rb index de8ffe40536..e995a7d4f5e 100644 --- a/spec/support/helpers/admin_mode_helpers.rb +++ b/spec/support/helpers/admin_mode_helpers.rb @@ -3,7 +3,7 @@ # Helper for enabling admin mode in tests module AdminModeHelper - # Users are logged in by default in user mode and have to switch to admin + # Administrators are logged in by default in user mode and have to switch to admin # mode for accessing any administrative functionality. This helper lets a user # be in admin mode without requiring a second authentication step (provided # the user is an admin) diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb index ebba5d8a73c..dbf457a9200 100644 --- a/spec/support/matchers/graphql_matchers.rb +++ b/spec/support/matchers/graphql_matchers.rb @@ -28,6 +28,19 @@ RSpec::Matchers.define :have_graphql_fields do |*expected| end end +RSpec::Matchers.define :include_graphql_fields do |*expected| + expected_field_names = expected.map { |name| GraphqlHelpers.fieldnamerize(name) } + + match do |kls| + expect(kls.fields.keys).to include(*expected_field_names) + end + + failure_message do |kls| + missing = expected_field_names - kls.fields.keys + "is missing fields: <#{missing.inspect}>" if missing.any? + end +end + RSpec::Matchers.define :have_graphql_field do |field_name, args = {}| match do |kls| field = kls.fields[GraphqlHelpers.fieldnamerize(field_name)] diff --git a/spec/views/admin/sessions/new.html.haml_spec.rb b/spec/views/admin/sessions/new.html.haml_spec.rb index 57255748988..b3208296c80 100644 --- a/spec/views/admin/sessions/new.html.haml_spec.rb +++ b/spec/views/admin/sessions/new.html.haml_spec.rb @@ -3,29 +3,44 @@ require 'spec_helper' describe 'admin/sessions/new.html.haml' do - context 'admin has password set' do - before do - allow(view).to receive(:password_authentication_enabled_for_web?).and_return(true) - end + let(:user) { create(:admin) } + + before do + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:omniauth_enabled?).and_return(false) + end - it "shows enter password form" do + context 'internal admin user' do + it 'shows enter password form' do render expect(rendered).to have_css('#login-pane.active') expect(rendered).to have_selector('input[name="password"]') end + + it 'warns authentication not possible if password not set' do + allow(user).to receive(:require_password_creation_for_web?).and_return(true) + + render + + expect(rendered).not_to have_css('#login-pane') + expect(rendered).to have_content _('No authentication methods configured.') + end end - context 'admin has no password set' do + context 'omniauth authentication enabled' do before do - allow(view).to receive(:password_authentication_enabled_for_web?).and_return(false) + allow(view).to receive(:omniauth_enabled?).and_return(true) + allow(view).to receive(:button_based_providers_enabled?).and_return(true) end - it "warns authentication not possible" do + it 'shows omniauth form' do render - expect(rendered).not_to have_css('#login-pane') - expect(rendered).to have_content 'No authentication methods configured' + expect(rendered).to have_css('.omniauth-container') + expect(rendered).to have_content _('Sign in with') + + expect(rendered).not_to have_content _('No authentication methods configured.') end end end diff --git a/spec/views/layouts/application.html.haml_spec.rb b/spec/views/layouts/application.html.haml_spec.rb index bdd4a97a1f5..4270bbf1924 100644 --- a/spec/views/layouts/application.html.haml_spec.rb +++ b/spec/views/layouts/application.html.haml_spec.rb @@ -11,6 +11,7 @@ describe 'layouts/application' do allow(view).to receive(:session).and_return({}) allow(view).to receive(:user_signed_in?).and_return(true) allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:current_user_mode).and_return(Gitlab::Auth::CurrentUserMode.new(user)) end context 'body data elements for pageview context' do |