diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-02-06 12:10:29 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-02-06 12:10:29 +0000 |
commit | 5564275a0b378298dc6281599cbfe71a937109ff (patch) | |
tree | a468e1e60046356410219c35c23a8a428c5e2c5e | |
parent | d87918510a866a5fcbbc2f899ad65c6938ebf5f5 (diff) | |
download | gitlab-ce-5564275a0b378298dc6281599cbfe71a937109ff.tar.gz |
Add latest changes from gitlab-org/gitlab@master
96 files changed, 1938 insertions, 95 deletions
diff --git a/.gitlab/ci/pages.gitlab-ci.yml b/.gitlab/ci/pages.gitlab-ci.yml index 9d80f4cba94..6c52afb068f 100644 --- a/.gitlab/ci/pages.gitlab-ci.yml +++ b/.gitlab/ci/pages.gitlab-ci.yml @@ -2,6 +2,32 @@ .if-canonical-dot-com-gitlab-org-group-master-refs: &if-canonical-dot-com-gitlab-org-group-master-refs if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE == "gitlab-org" && $CI_COMMIT_REF_NAME == "master"' +# Make sure to update all the similar patterns in other CI config files if you modify these patterns +.code-backstage-qa-patterns: &code-backstage-qa-patterns + - ".gitlab/ci/**/*" + - ".{eslintignore,gitattributes,nvmrc,prettierrc,stylelintrc,yamllint}" + - ".{codeclimate,eslintrc,gitlab-ci,haml-lint,haml-lint_todo,rubocop,rubocop_todo,scss-lint}.yml" + - ".csscomb.json" + - "Dockerfile.assets" + - "*_VERSION" + - "Gemfile{,.lock}" + - "Rakefile" + - "{babel.config,jest.config}.js" + - "config.ru" + - "{package.json,yarn.lock}" + - "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*" + - "doc/api/graphql/reference/*" # Files in this folder are auto-generated + # Backstage changes + - "Dangerfile" + - "danger/**/*" + - "{,ee/}fixtures/**/*" + - "{,ee/}rubocop/**/*" + - "{,ee/}spec/**/*" + - "doc/README.md" # Some RSpec test rely on this file + # QA changes + - ".dockerignore" + - "qa/**/*" + pages: extends: - .default-tags @@ -9,6 +35,7 @@ pages: - .default-cache rules: - <<: *if-canonical-dot-com-gitlab-org-group-master-refs + changes: *code-backstage-qa-patterns when: on_success stage: pages dependencies: ["coverage", "karma", "gitlab:assets:compile pull-cache"] diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml index 6b0a7f31f1a..3fe8411ccad 100644 --- a/.gitlab/ci/rails.gitlab-ci.yml +++ b/.gitlab/ci/rails.gitlab-ci.yml @@ -68,6 +68,7 @@ setup-test-env: - rspec_profiling/ - tmp/capybara/ - tmp/memory_test/ + - junit_rspec.xml reports: junit: junit_rspec.xml @@ -488,3 +488,8 @@ gem 'liquid', '~> 4.0' gem 'lru_redux' gem 'erubi', '~> 1.9.0' + +# Locked as long as quoted-printable encoding issues are not resolved +# Monkey-patched in `config/initializers/mail_encoding_patch.rb` +# See https://gitlab.com/gitlab-org/gitlab/issues/197386 +gem 'mail', '= 2.7.1' diff --git a/Gemfile.lock b/Gemfile.lock index c8912d70810..64e701d22de 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1283,6 +1283,7 @@ DEPENDENCIES lograge (~> 0.5) loofah (~> 2.2) lru_redux + mail (= 2.7.1) mail_room (~> 0.10.0) marginalia (~> 1.8.0) memory_profiler (~> 0.9) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 6de9ab9efb3..76f3020c5c2 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -45,6 +45,7 @@ const Api = { mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines', adminStatisticsPath: '/api/:version/application/statistics', pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id', + lsifPath: '/api/:version/projects/:id/commits/:commit_id/lsif/info', group(groupId, callback) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -457,6 +458,14 @@ const Api = { return axios.get(url); }, + lsifData(projectPath, commitId, path) { + const url = Api.buildUrl(this.lsifPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':commit_id', commitId); + + return axios.get(url, { params: { path } }); + }, + buildUrl(url) { return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); }, diff --git a/app/assets/javascripts/boards/components/issue_count.vue b/app/assets/javascripts/boards/components/issue_count.vue index c50a3c1c0d3..d55f7151d7e 100644 --- a/app/assets/javascripts/boards/components/issue_count.vue +++ b/app/assets/javascripts/boards/components/issue_count.vue @@ -25,7 +25,7 @@ export default { </script> <template> - <div class="issue-count"> + <div class="issue-count text-nowrap"> <span class="js-issue-size" :class="{ 'text-danger': issuesExceedMax }"> {{ issuesSize }} </span> diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue new file mode 100644 index 00000000000..0e5f1f0485d --- /dev/null +++ b/app/assets/javascripts/code_navigation/components/app.vue @@ -0,0 +1,43 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import Popover from './popover.vue'; + +export default { + components: { + Popover, + }, + computed: { + ...mapState(['currentDefinition', 'currentDefinitionPosition']), + }, + mounted() { + this.blobViewer = document.querySelector('.blob-viewer'); + + this.addGlobalEventListeners(); + this.fetchData(); + }, + beforeDestroy() { + this.removeGlobalEventListeners(); + }, + methods: { + ...mapActions(['fetchData', 'showDefinition']), + addGlobalEventListeners() { + if (this.blobViewer) { + this.blobViewer.addEventListener('click', this.showDefinition); + } + }, + removeGlobalEventListeners() { + if (this.blobViewer) { + this.blobViewer.removeEventListener('click', this.showDefinition); + } + }, + }, +}; +</script> + +<template> + <popover + v-if="currentDefinition" + :position="currentDefinitionPosition" + :data="currentDefinition" + /> +</template> diff --git a/app/assets/javascripts/code_navigation/components/popover.vue b/app/assets/javascripts/code_navigation/components/popover.vue new file mode 100644 index 00000000000..d5bbe430fcd --- /dev/null +++ b/app/assets/javascripts/code_navigation/components/popover.vue @@ -0,0 +1,76 @@ +<script> +import { GlButton } from '@gitlab/ui'; + +export default { + components: { + GlButton, + }, + props: { + position: { + type: Object, + required: true, + }, + data: { + type: Object, + required: true, + }, + }, + data() { + return { + offsetLeft: 0, + }; + }, + computed: { + positionStyles() { + return { + left: `${this.position.x - this.offsetLeft}px`, + top: `${this.position.y + this.position.height}px`, + }; + }, + }, + watch: { + position: { + handler() { + this.$nextTick(() => this.updateOffsetLeft()); + }, + deep: true, + immediate: true, + }, + }, + methods: { + updateOffsetLeft() { + this.offsetLeft = Math.max( + 0, + this.$el.offsetLeft + this.$el.offsetWidth - window.innerWidth + 20, + ); + }, + }, + colorScheme: gon?.user_color_scheme, +}; +</script> + +<template> + <div + :style="positionStyles" + class="popover code-navigation-popover popover-font-size-normal gl-popover bs-popover-bottom show" + > + <div :style="{ left: `${offsetLeft}px` }" class="arrow"></div> + <div v-for="(hover, index) in data.hover" :key="index" class="border-bottom"> + <pre + v-if="hover.language" + ref="code-output" + :class="$options.colorScheme" + class="border-0 bg-transparent m-0 code highlight" + v-html="hover.value" + ></pre> + <p v-else ref="doc-output" class="p-3 m-0"> + {{ hover.value }} + </p> + </div> + <div v-if="data.definition_url" class="popover-body"> + <gl-button :href="data.definition_url" target="_blank" class="w-100" variant="default"> + {{ __('Go to definition') }} + </gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/code_navigation/index.js b/app/assets/javascripts/code_navigation/index.js new file mode 100644 index 00000000000..2222c986dfe --- /dev/null +++ b/app/assets/javascripts/code_navigation/index.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import store from './store'; +import App from './components/app.vue'; + +Vue.use(Vuex); + +export default () => { + const el = document.getElementById('js-code-navigation'); + + store.dispatch('setInitialData', el.dataset); + + return new Vue({ + el, + store, + render(h) { + return h(App); + }, + }); +}; diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js new file mode 100644 index 00000000000..10483abfb23 --- /dev/null +++ b/app/assets/javascripts/code_navigation/store/actions.js @@ -0,0 +1,62 @@ +import api from '~/api'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import * as types from './mutation_types'; +import { getCurrentHoverElement, setCurrentHoverElement, addInteractionClass } from '../utils'; + +export default { + setInitialData({ commit }, data) { + commit(types.SET_INITIAL_DATA, data); + }, + requestDataError({ commit }) { + commit(types.REQUEST_DATA_ERROR); + createFlash(__('An error occurred loading code navigation')); + }, + fetchData({ commit, dispatch, state }) { + commit(types.REQUEST_DATA); + + api + .lsifData(state.projectPath, state.commitId, state.path) + .then(({ data }) => { + const normalizedData = data.reduce((acc, d) => { + if (d.hover) { + acc[`${d.start_line}:${d.start_char}`] = d; + addInteractionClass(d); + } + return acc; + }, {}); + + commit(types.REQUEST_DATA_SUCCESS, normalizedData); + }) + .catch(() => dispatch('requestDataError')); + }, + showDefinition({ commit, state }, { target: el }) { + let definition; + let position; + + if (!state.data) return; + + const isCurrentElementPopoverOpen = el.classList.contains('hll'); + + if (getCurrentHoverElement()) { + getCurrentHoverElement().classList.remove('hll'); + } + + if (el.classList.contains('js-code-navigation') && !isCurrentElementPopoverOpen) { + const { lineIndex, charIndex } = el.dataset; + + position = { + x: el.offsetLeft, + y: el.offsetTop, + height: el.offsetHeight, + }; + definition = state.data[`${lineIndex}:${charIndex}`]; + + el.classList.add('hll'); + + setCurrentHoverElement(el); + } + + commit(types.SET_CURRENT_DEFINITION, { definition, position }); + }, +}; diff --git a/app/assets/javascripts/code_navigation/store/index.js b/app/assets/javascripts/code_navigation/store/index.js new file mode 100644 index 00000000000..fe48f3ac7f5 --- /dev/null +++ b/app/assets/javascripts/code_navigation/store/index.js @@ -0,0 +1,10 @@ +import Vuex from 'vuex'; +import createState from './state'; +import actions from './actions'; +import mutations from './mutations'; + +export default new Vuex.Store({ + actions, + mutations, + state: createState(), +}); diff --git a/app/assets/javascripts/code_navigation/store/mutation_types.js b/app/assets/javascripts/code_navigation/store/mutation_types.js new file mode 100644 index 00000000000..29a2897a6fd --- /dev/null +++ b/app/assets/javascripts/code_navigation/store/mutation_types.js @@ -0,0 +1,5 @@ +export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; +export const REQUEST_DATA = 'REQUEST_DATA'; +export const REQUEST_DATA_SUCCESS = 'REQUEST_DATA_SUCCESS'; +export const REQUEST_DATA_ERROR = 'REQUEST_DATA_ERROR'; +export const SET_CURRENT_DEFINITION = 'SET_CURRENT_DEFINITION'; diff --git a/app/assets/javascripts/code_navigation/store/mutations.js b/app/assets/javascripts/code_navigation/store/mutations.js new file mode 100644 index 00000000000..bb833a5adbc --- /dev/null +++ b/app/assets/javascripts/code_navigation/store/mutations.js @@ -0,0 +1,23 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_DATA](state, { projectPath, commitId, blobPath }) { + state.projectPath = projectPath; + state.commitId = commitId; + state.blobPath = blobPath; + }, + [types.REQUEST_DATA](state) { + state.loading = true; + }, + [types.REQUEST_DATA_SUCCESS](state, data) { + state.loading = false; + state.data = data; + }, + [types.REQUEST_DATA_ERROR](state) { + state.loading = false; + }, + [types.SET_CURRENT_DEFINITION](state, { definition, position }) { + state.currentDefinition = definition; + state.currentDefinitionPosition = position; + }, +}; diff --git a/app/assets/javascripts/code_navigation/store/state.js b/app/assets/javascripts/code_navigation/store/state.js new file mode 100644 index 00000000000..a7b3b289db4 --- /dev/null +++ b/app/assets/javascripts/code_navigation/store/state.js @@ -0,0 +1,9 @@ +export default () => ({ + projectPath: null, + commitId: null, + blobPath: null, + loading: false, + data: null, + currentDefinition: null, + currentDefinitionPosition: null, +}); diff --git a/app/assets/javascripts/code_navigation/utils/index.js b/app/assets/javascripts/code_navigation/utils/index.js new file mode 100644 index 00000000000..2dee0de6501 --- /dev/null +++ b/app/assets/javascripts/code_navigation/utils/index.js @@ -0,0 +1,20 @@ +export const cachedData = new Map(); + +export const getCurrentHoverElement = () => cachedData.get('current'); +export const setCurrentHoverElement = el => cachedData.set('current', el); + +export const addInteractionClass = d => { + let charCount = 0; + const line = document.getElementById(`LC${d.start_line + 1}`); + const el = [...line.childNodes].find(({ textContent }) => { + if (charCount === d.start_char) return true; + charCount += textContent.length; + return false; + }); + + if (el) { + el.setAttribute('data-char-index', d.start_char); + el.setAttribute('data-line-index', d.start_line); + el.classList.add('cursor-pointer', 'code-navigation', 'js-code-navigation'); + } +}; diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index aee67899ca2..caf9a8c0b64 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -30,4 +30,9 @@ document.addEventListener('DOMContentLoaded', () => { } GpgBadges.fetch(); + + if (gon.features?.codeNavigation) { + // eslint-disable-next-line promise/catch-or-return + import('~/code_navigation').then(m => m.default()); + } }); diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue index 40ce200befb..78c355ecb76 100644 --- a/app/assets/javascripts/reports/components/modal.vue +++ b/app/assets/javascripts/reports/components/modal.vue @@ -46,8 +46,8 @@ export default { </a> </template> - <template v-else-if="field.type === $options.fieldTypes.miliseconds">{{ - sprintf(__('%{value} ms'), { value: field.value }) + <template v-else-if="field.type === $options.fieldTypes.seconds">{{ + sprintf(__('%{value} s'), { value: field.value }) }}</template> <template v-else-if="field.type === $options.fieldTypes.text"> diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index 66ac1af062b..1845b51e6b2 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -1,7 +1,7 @@ export const fieldTypes = { codeBock: 'codeBlock', link: 'link', - miliseconds: 'miliseconds', + seconds: 'seconds', text: 'text', }; diff --git a/app/assets/javascripts/reports/store/state.js b/app/assets/javascripts/reports/store/state.js index 25f9f70d095..d0b2d0a37f5 100644 --- a/app/assets/javascripts/reports/store/state.js +++ b/app/assets/javascripts/reports/store/state.js @@ -48,7 +48,7 @@ export default () => ({ execution_time: { value: null, text: s__('Reports|Execution time'), - type: fieldTypes.miliseconds, + type: fieldTypes.seconds, }, failure: { value: null, diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index 5b9e3817f3a..67e5f175039 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -54,11 +54,17 @@ const populateUserInfo = user => { ); }; +const initializedPopovers = new Map(); + export default (elements = document.querySelectorAll('.js-user-link')) => { const userLinks = Array.from(elements); + const UserPopoverComponent = Vue.extend(UserPopover); return userLinks.map(el => { - const UserPopoverComponent = Vue.extend(UserPopover); + if (initializedPopovers.has(el)) { + return initializedPopovers.get(el); + } + const user = { location: null, bio: null, @@ -73,6 +79,8 @@ export default (elements = document.querySelectorAll('.js-user-link')) => { }, }); + initializedPopovers.set(el, renderedPopover); + renderedPopover.$mount(); el.addEventListener('mouseenter', ({ target }) => { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 1a017f03ebb..bb1c304b9fe 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -499,3 +499,15 @@ span.idiff { background-color: transparent; border: transparent; } + +.code-navigation { + border-bottom: 1px $gray-darkest dashed; + + &:hover { + border-bottom-color: $almost-black; + } +} + +.code-navigation-popover { + max-width: 450px; +} diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index f3e6927767c..0fd6aafef0d 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -28,6 +28,13 @@ } } +@for $i from 1 through 12 { + #{'.tab-width-#{$i}'} { + -moz-tab-size: $i; + tab-size: $i; + } +} + .border-width-1px { border-width: 1px; } .border-bottom-width-1px { border-bottom-width: 1px; } .border-style-dashed { border-style: dashed; } diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 2166dd7dad7..1477d79c911 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -48,6 +48,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController :time_display_relative, :time_format_in_24h, :show_whitespace_in_diffs, + :tab_width, :sourcegraph_enabled, :render_whitespace_in_code ] diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 3cd14cf845f..01e5103198b 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -29,6 +29,10 @@ class Projects::BlobController < Projects::ApplicationController before_action :validate_diff_params, only: :diff before_action :set_last_commit_sha, only: [:edit, :update] + before_action only: :show do + push_frontend_feature_flag(:code_navigation, @project) + end + def new commit unless @repository.empty? end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 6a271e93cd9..8a79217c929 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -63,6 +63,10 @@ module PreferencesHelper Gitlab::ColorSchemes.for_user(current_user).css_class end + def user_tab_width + Gitlab::TabWidth.css_class_for_user(current_user) + end + def language_choices Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] } end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 011871f373f..93c38d2f933 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -706,6 +706,10 @@ module ProjectsHelper Feature.enabled?(:vue_file_list, @project) end + def native_code_navigation_enabled?(project) + Feature.enabled?(:code_navigation, project) + end + def show_visibility_confirm_modal?(project) project.unlink_forks_upon_visibility_decrease_enabled? && project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && project.forks_count > 0 end diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 3d098406ab1..31c813edb67 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -15,6 +15,11 @@ class DeployToken < ApplicationRecord has_many :project_deploy_tokens, inverse_of: :deploy_token has_many :projects, through: :project_deploy_tokens + has_many :group_deploy_tokens, inverse_of: :deploy_token + has_many :groups, through: :group_deploy_tokens + + validate :no_groups, unless: :group_type? + validate :no_projects, unless: :project_type? validate :ensure_at_least_one_scope validates :username, length: { maximum: 255 }, @@ -24,6 +29,7 @@ class DeployToken < ApplicationRecord message: "can contain only letters, digits, '_', '-', '+', and '.'" } + validates :deploy_token_type, presence: true enum deploy_token_type: { group_type: 1, project_type: 2 @@ -56,18 +62,31 @@ class DeployToken < ApplicationRecord end def has_access_to?(requested_project) - active? && project == requested_project + return false unless active? + return false unless holder + + holder.has_access_to?(requested_project) end # This is temporal. Currently we limit DeployToken - # to a single project, later we're going to extend - # that to be for multiple projects and namespaces. + # to a single project or group, later we're going to + # extend that to be for multiple projects and namespaces. def project strong_memoize(:project) do projects.first end end + def holder + strong_memoize(:holder) do + if project_type? + project_deploy_tokens.first + elsif group_type? + group_deploy_tokens.first + end + end + end + def expires_at expires_at = read_attribute(:expires_at) expires_at != Forever.date ? expires_at : nil @@ -92,4 +111,12 @@ class DeployToken < ApplicationRecord def default_username "gitlab+deploy-token-#{id}" if persisted? end + + def no_groups + errors.add(:deploy_token, 'cannot have groups assigned') if group_deploy_tokens.any? + end + + def no_projects + errors.add(:deploy_token, 'cannot have projects assigned') if project_deploy_tokens.any? + end end diff --git a/app/models/group_deploy_token.rb b/app/models/group_deploy_token.rb new file mode 100644 index 00000000000..221a7d768ae --- /dev/null +++ b/app/models/group_deploy_token.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class GroupDeployToken < ApplicationRecord + belongs_to :group, class_name: '::Group' + belongs_to :deploy_token, inverse_of: :group_deploy_tokens + + validates :deploy_token, presence: true + validates :group, presence: true + validates :deploy_token_id, uniqueness: { scope: [:group_id] } + + def has_access_to?(requested_project) + return false unless Feature.enabled?(:allow_group_deploy_token, default: true) + + requested_project_group = requested_project&.group + return false unless requested_project_group + return true if requested_project_group.id == group_id + + requested_project_group + .ancestors + .where(id: group_id) + .exists? + end +end diff --git a/app/models/project_deploy_token.rb b/app/models/project_deploy_token.rb index a55667496fb..0bce1c745f7 100644 --- a/app/models/project_deploy_token.rb +++ b/app/models/project_deploy_token.rb @@ -7,4 +7,8 @@ class ProjectDeployToken < ApplicationRecord validates :deploy_token, presence: true validates :project, presence: true validates :deploy_token_id, uniqueness: { scope: [:project_id] } + + def has_access_to?(requested_project) + requested_project == project + end end diff --git a/app/models/user.rb b/app/models/user.rb index a5ef03215d3..3512e663f4a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -59,6 +59,8 @@ class User < ApplicationRecord MINIMUM_INACTIVE_DAYS = 180 + enum bot_type: ::UserBotTypeEnums.bots + # Override Devise::Models::Trackable#update_tracked_fields! # to limit database writes to at most once every hour # rubocop: disable CodeReuse/ServiceClass @@ -246,6 +248,7 @@ class User < ApplicationRecord delegate :time_display_relative, :time_display_relative=, to: :user_preference delegate :time_format_in_24h, :time_format_in_24h=, to: :user_preference delegate :show_whitespace_in_diffs, :show_whitespace_in_diffs=, to: :user_preference + delegate :tab_width, :tab_width=, to: :user_preference delegate :sourcegraph_enabled, :sourcegraph_enabled=, to: :user_preference delegate :setup_for_company, :setup_for_company=, to: :user_preference delegate :render_whitespace_in_code, :render_whitespace_in_code=, to: :user_preference @@ -322,6 +325,8 @@ class User < ApplicationRecord scope :with_emails, -> { preload(:emails) } scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) } scope :with_public_profile, -> { where(private_profile: false) } + scope :bots, -> { where.not(bot_type: nil) } + scope :humans, -> { where(bot_type: nil) } scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do where('EXISTS (?)', @@ -598,6 +603,15 @@ class User < ApplicationRecord end end + def alert_bot + email_pattern = "alert%s@#{Settings.gitlab.host}" + + unique_internal(where(bot_type: :alert_bot), 'alert-bot', email_pattern) do |u| + u.bio = 'The GitLab alert bot' + u.name = 'GitLab Alert Bot' + end + end + # Return true if there is only single non-internal user in the deployment, # ghost user is ignored. def single_user? @@ -613,16 +627,20 @@ class User < ApplicationRecord username end + def bot? + bot_type.present? + end + def internal? - ghost? + ghost? || bot? end def self.internal - where(ghost: true) + where(ghost: true).or(bots) end def self.non_internal - without_ghosts + without_ghosts.humans end # diff --git a/app/models/user_bot_type_enums.rb b/app/models/user_bot_type_enums.rb new file mode 100644 index 00000000000..b6b08ce650b --- /dev/null +++ b/app/models/user_bot_type_enums.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module UserBotTypeEnums + def self.bots + # When adding a new key, please ensure you are not conflicting with EE-only keys in app/models/user_bot_types_enums.rb + { + alert_bot: 2 + } + end +end + +UserBotTypeEnums.prepend_if_ee('EE::UserBotTypeEnums') diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 713b0598029..48a56cded0e 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -9,7 +9,13 @@ class UserPreference < ApplicationRecord belongs_to :user validates :issue_notes_filter, :merge_request_notes_filter, inclusion: { in: NOTES_FILTERS.values }, presence: true + validates :tab_width, numericality: { + only_integer: true, + greater_than_or_equal_to: Gitlab::TabWidth::MIN, + less_than_or_equal_to: Gitlab::TabWidth::MAX + } + default_value_for :tab_width, value: Gitlab::TabWidth::DEFAULT, allows_nil: false default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false default_value_for :time_display_relative, value: true, allows_nil: false default_value_for :time_format_in_24h, value: false, allows_nil: false diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index c93a19bdc3d..ce3e5b0195c 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -44,6 +44,9 @@ class BasePolicy < DeclarativePolicy::Base ::Gitlab::ExternalAuthorization.perform_check? end + with_options scope: :user, score: 0 + condition(:alert_bot) { @user&.alert_bot? } + rule { external_authorization_enabled & ~can?(:read_all_resources) }.policy do prevent :read_cross_project end diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb index b963a64b429..406677d7b56 100644 --- a/app/policies/concerns/policy_actor.rb +++ b/app/policies/concerns/policy_actor.rb @@ -33,6 +33,10 @@ module PolicyActor def can_create_group false end + + def alert_bot? + false + end end PolicyActor.prepend_if_ee('EE::PolicyActor') diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index bbcb3c637a9..ee22a2d84e7 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -515,6 +515,8 @@ class ProjectPolicy < BasePolicy end def lookup_access_level! + return ::Gitlab::Access::REPORTER if alert_bot? + # NOTE: max_member_access has its own cache project.team.max_member_access(@user.id) end diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 7af190f5a0b..eb58115451d 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -4,7 +4,7 @@ !!! 5 %html{ lang: I18n.locale, class: page_classes } = render "layouts/head" - %body{ class: "#{user_application_theme} #{@body_class} #{client_class_list}", data: body_data } + %body{ class: "#{user_application_theme} #{user_tab_width} #{@body_class} #{client_class_list}", data: body_data } = render "layouts/init_auto_complete" if @gfm_form = render "layouts/init_client_detection_flags" = render 'peek/bar' diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml index 91a7777514c..8d0775f6f27 100644 --- a/app/views/layouts/fullscreen.html.haml +++ b/app/views/layouts/fullscreen.html.haml @@ -1,7 +1,7 @@ !!! 5 %html{ lang: I18n.locale, class: page_class } = render "layouts/head" - %body{ class: "#{user_application_theme} #{@body_class} fullscreen-layout", data: { page: body_data_page } } + %body{ class: "#{user_application_theme} #{user_tab_width} #{@body_class} fullscreen-layout", data: { page: body_data_page } } = render 'peek/bar' = header_message = render partial: "layouts/header/default", locals: { project: @project, group: @group } diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 93acd6f550b..12d42ce9892 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -69,6 +69,15 @@ = f.check_box :show_whitespace_in_diffs, class: 'form-check-input' = f.label :show_whitespace_in_diffs, class: 'form-check-label' do = s_('Preferences|Show whitespace changes in diffs') + .form-group + = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold' + = f.number_field :tab_width, + class: 'form-control', + min: Gitlab::TabWidth::MIN, + max: Gitlab::TabWidth::MAX, + required: true + .form-text.text-muted + = s_('Preferences|Must be a number between %{min} and %{max}') % { min: Gitlab::TabWidth::MIN, max: Gitlab::TabWidth::MAX } .col-sm-12 %hr diff --git a/app/views/profiles/preferences/update.js.erb b/app/views/profiles/preferences/update.js.erb index 8966dd3fd86..8397acbf1b3 100644 --- a/app/views/profiles/preferences/update.js.erb +++ b/app/views/profiles/preferences/update.js.erb @@ -12,5 +12,9 @@ if ('<%= current_user.layout %>' === 'fluid') { // Re-enable the "Save" button $('input[type=submit]').enable() -// Show the notice flash message -new Flash('<%= flash.discard(:notice) %>', 'notice') +// Show flash messages +<% if flash.notice %> + new Flash('<%= flash.discard(:notice) %>', 'notice') +<% elsif flash.alert %> + new Flash('<%= flash.discard(:alert) %>', 'alert') +<% end %> diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index cf273aab108..9803d65c4fb 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -9,6 +9,8 @@ = render "projects/blob/auxiliary_viewer", blob: blob #blob-content-holder.blob-content-holder + - if native_code_navigation_enabled?(@project) + #js-code-navigation{ data: { commit_id: blob.commit_id, path: blob.path, project_path: @project.full_path } } %article.file-holder = render 'projects/blob/header', blob: blob = render 'projects/blob/content', blob: blob diff --git a/changelogs/unreleased/13904-tab-width-option.yml b/changelogs/unreleased/13904-tab-width-option.yml new file mode 100644 index 00000000000..eaa3dae7deb --- /dev/null +++ b/changelogs/unreleased/13904-tab-width-option.yml @@ -0,0 +1,5 @@ +--- +title: Add tab width option to user preferences +merge_request: 22063 +author: Alexander Oleynikov +type: added diff --git a/changelogs/unreleased/197311-board-list-wip-limit-distorted-when-board-list-is-scoped-to-an-ass.yml b/changelogs/unreleased/197311-board-list-wip-limit-distorted-when-board-list-is-scoped-to-an-ass.yml new file mode 100644 index 00000000000..426830ae083 --- /dev/null +++ b/changelogs/unreleased/197311-board-list-wip-limit-distorted-when-board-list-is-scoped-to-an-ass.yml @@ -0,0 +1,5 @@ +--- +title: Fix issue count wrapping on board list +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/21765-group-token-architecture.yml b/changelogs/unreleased/21765-group-token-architecture.yml new file mode 100644 index 00000000000..8db4724e3b6 --- /dev/null +++ b/changelogs/unreleased/21765-group-token-architecture.yml @@ -0,0 +1,5 @@ +--- +title: Update deploy token architecture to introduce group-level deploy tokens. +merge_request: 23460 +author: +type: added diff --git a/changelogs/unreleased/26247-junit-xml-tests-mis-present-the-test-execution-time-from-test-cases.yml b/changelogs/unreleased/26247-junit-xml-tests-mis-present-the-test-execution-time-from-test-cases.yml new file mode 100644 index 00000000000..4b3d514e764 --- /dev/null +++ b/changelogs/unreleased/26247-junit-xml-tests-mis-present-the-test-execution-time-from-test-cases.yml @@ -0,0 +1,5 @@ +--- +title: Label MR test modal execution time as seconds +merge_request: 24019 +author: +type: fixed diff --git a/changelogs/unreleased/37012-jira-dvcs-error.yml b/changelogs/unreleased/37012-jira-dvcs-error.yml new file mode 100644 index 00000000000..a00ce632020 --- /dev/null +++ b/changelogs/unreleased/37012-jira-dvcs-error.yml @@ -0,0 +1,5 @@ +--- +title: Fix JIRA DVCS retrieving repositories +merge_request: 23180 +author: +type: fixed diff --git a/changelogs/unreleased/39474-unable-to-view-project-audit-events-statement-timeouts.yml b/changelogs/unreleased/39474-unable-to-view-project-audit-events-statement-timeouts.yml new file mode 100644 index 00000000000..f128eb4e417 --- /dev/null +++ b/changelogs/unreleased/39474-unable-to-view-project-audit-events-statement-timeouts.yml @@ -0,0 +1,5 @@ +--- +title: Add index to audit_events (entity_id, entity_type, id) +merge_request: 23998 +author: +type: performance diff --git a/changelogs/unreleased/fix-duplicated-user-popover.yml b/changelogs/unreleased/fix-duplicated-user-popover.yml new file mode 100644 index 00000000000..fafce90124f --- /dev/null +++ b/changelogs/unreleased/fix-duplicated-user-popover.yml @@ -0,0 +1,5 @@ +--- +title: Fix duplicated user popovers +merge_request: 24405 +author: +type: fixed diff --git a/changelogs/unreleased/fix-quoted-printable-unicode-in-mails.yml b/changelogs/unreleased/fix-quoted-printable-unicode-in-mails.yml new file mode 100644 index 00000000000..50010b0b4ac --- /dev/null +++ b/changelogs/unreleased/fix-quoted-printable-unicode-in-mails.yml @@ -0,0 +1,5 @@ +--- +title: Fix quoted-printable encoding for unicode and newlines in mails +merge_request: 24153 +author: Diego Louzán +type: fixed diff --git a/config/initializers/mail_encoding_patch.rb b/config/initializers/mail_encoding_patch.rb new file mode 100644 index 00000000000..d53b058ba75 --- /dev/null +++ b/config/initializers/mail_encoding_patch.rb @@ -0,0 +1,82 @@ +# Monkey patch mail 2.7.1 to fix quoted-printable issues with newlines +# The issues upstream invalidate SMIME signatures under some conditions +# This was working properly in 2.6.6 +# +# See https://gitlab.com/gitlab-org/gitlab/issues/197386 +# See https://github.com/mikel/mail/issues/1190 + +module Mail + module Encodings + # PATCH + # This reverts https://github.com/mikel/mail/pull/1113, which solves some + # encoding issues with binary attachments encoded in quoted-printable, but + # unfortunately breaks re-encoding of messages + class QuotedPrintable < SevenBit + def self.decode(str) + ::Mail::Utilities.to_lf str.gsub(/(?:=0D=0A|=0D|=0A)\r\n/, "\r\n").unpack1("M*") + end + + def self.encode(str) + ::Mail::Utilities.to_crlf([::Mail::Utilities.to_lf(str)].pack("M")) + end + end + end + + class Body + def encoded(transfer_encoding = nil, charset = nil) + # PATCH + # Use provided parameter charset (from parent Message) if not nil, + # otherwise use own self.charset + # Required because the Message potentially has on its headers the charset + # that needs to be used (e.g. 'Content-Type: text/plain; charset=UTF-8') + charset = self.charset if charset.nil? + + if multipart? + self.sort_parts! + encoded_parts = parts.map { |p| p.encoded } + ([preamble] + encoded_parts).join(crlf_boundary) + end_boundary + epilogue.to_s + else + dec = Mail::Encodings.get_encoding(encoding) + enc = if Utilities.blank?(transfer_encoding) + dec + else + negotiate_best_encoding(transfer_encoding) + end + + if dec.nil? + # Cannot decode, so skip normalization + raw_source + else + # Decode then encode to normalize and allow transforming + # from base64 to Q-P and vice versa + decoded = dec.decode(raw_source) + + if defined?(Encoding) && charset && charset != "US-ASCII" + # PATCH + # We need to force the encoding: in the case of quoted-printable + # this will throw an exception otherwise, because `decoded` will have + # an encoding of BINARY (or its equivalent ASCII-8BIT), + # coming from QuotedPrintable#decode, and inside it from String#unpack1 + decoded = decoded.force_encoding(charset) + decoded.force_encoding('BINARY') unless Encoding.find(charset).ascii_compatible? + end + + enc.encode(decoded) + end + end + end + end + + class Message + def encoded + ready_to_send! + buffer = header.encoded + buffer << "\r\n" + # PATCH + # Pass the Message charset down to the contained Body, the headers + # potentially contain the charset needed to be applied + buffer << body.encoded(content_transfer_encoding, charset) + buffer + end + end +end diff --git a/db/migrate/20191217165641_add_saml_provider_prohibited_outer_forks.rb b/db/migrate/20191217165641_add_saml_provider_prohibited_outer_forks.rb new file mode 100644 index 00000000000..6cd32cdcfe9 --- /dev/null +++ b/db/migrate/20191217165641_add_saml_provider_prohibited_outer_forks.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddSamlProviderProhibitedOuterForks < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default :saml_providers, :prohibited_outer_forks, :boolean, default: false, allow_null: true + end + + def down + remove_column :saml_providers, :prohibited_outer_forks + end +end diff --git a/db/migrate/20191218190253_add_tab_width_to_user_preferences.rb b/db/migrate/20191218190253_add_tab_width_to_user_preferences.rb new file mode 100644 index 00000000000..b03dd8f76b9 --- /dev/null +++ b/db/migrate/20191218190253_add_tab_width_to_user_preferences.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddTabWidthToUserPreferences < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column(:user_preferences, :tab_width, :integer, limit: 2) + end +end diff --git a/db/migrate/20200121200203_create_group_deploy_tokens.rb b/db/migrate/20200121200203_create_group_deploy_tokens.rb new file mode 100644 index 00000000000..55b30745fcf --- /dev/null +++ b/db/migrate/20200121200203_create_group_deploy_tokens.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateGroupDeployTokens < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + create_table :group_deploy_tokens do |t| + t.timestamps_with_timezone null: false + + t.references :group, index: false, null: false, foreign_key: { to_table: :namespaces, on_delete: :cascade } + t.references :deploy_token, null: false, foreign_key: { on_delete: :cascade } + + t.index [:group_id, :deploy_token_id], unique: true, name: 'index_group_deploy_tokens_on_group_and_deploy_token_ids' + end + end +end diff --git a/db/migrate/20200129172428_add_index_on_audit_events_id_desc.rb b/db/migrate/20200129172428_add_index_on_audit_events_id_desc.rb new file mode 100644 index 00000000000..b9182c99ebf --- /dev/null +++ b/db/migrate/20200129172428_add_index_on_audit_events_id_desc.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AddIndexOnAuditEventsIdDesc < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + OLD_INDEX_NAME = 'index_audit_events_on_entity_id_and_entity_type' + NEW_INDEX_NAME = 'index_audit_events_on_entity_id_and_entity_type_and_id_desc' + + disable_ddl_transaction! + + def up + add_concurrent_index :audit_events, [:entity_id, :entity_type, :id], name: NEW_INDEX_NAME, + order: { entity_id: :asc, entity_type: :asc, id: :desc } + + remove_concurrent_index_by_name :audit_events, OLD_INDEX_NAME + end + + def down + add_concurrent_index :audit_events, [:entity_id, :entity_type], name: OLD_INDEX_NAME + + remove_concurrent_index_by_name :audit_events, NEW_INDEX_NAME + end +end diff --git a/db/schema.rb b/db/schema.rb index 1c96e4eefcf..1050b265acb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -465,7 +465,7 @@ ActiveRecord::Schema.define(version: 2020_02_04_131054) do t.datetime "created_at" t.datetime "updated_at" t.index ["created_at", "author_id"], name: "analytics_index_audit_events_on_created_at_and_author_id" - t.index ["entity_id", "entity_type"], name: "index_audit_events_on_entity_id_and_entity_type" + t.index ["entity_id", "entity_type", "id"], name: "index_audit_events_on_entity_id_and_entity_type_and_id_desc", order: { id: :desc } end create_table "award_emoji", id: :serial, force: :cascade do |t| @@ -1979,6 +1979,15 @@ ActiveRecord::Schema.define(version: 2020_02_04_131054) do t.index ["user_id"], name: "index_group_deletion_schedules_on_user_id" end + create_table "group_deploy_tokens", force: :cascade do |t| + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.bigint "group_id", null: false + t.bigint "deploy_token_id", null: false + t.index ["deploy_token_id"], name: "index_group_deploy_tokens_on_deploy_token_id" + t.index ["group_id", "deploy_token_id"], name: "index_group_deploy_tokens_on_group_and_deploy_token_ids", unique: true + end + create_table "group_group_links", force: :cascade do |t| t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "updated_at", null: false @@ -3735,6 +3744,7 @@ ActiveRecord::Schema.define(version: 2020_02_04_131054) do t.string "sso_url", null: false t.boolean "enforced_sso", default: false, null: false t.boolean "enforced_group_managed_accounts", default: false, null: false + t.boolean "prohibited_outer_forks", default: false, null: false t.index ["group_id"], name: "index_saml_providers_on_group_id" end @@ -4133,6 +4143,7 @@ ActiveRecord::Schema.define(version: 2020_02_04_131054) do t.boolean "sourcegraph_enabled" t.boolean "setup_for_company" t.boolean "render_whitespace_in_code" + t.integer "tab_width", limit: 2 t.index ["user_id"], name: "index_user_preferences_on_user_id", unique: true end @@ -4691,6 +4702,8 @@ ActiveRecord::Schema.define(version: 2020_02_04_131054) do add_foreign_key "group_custom_attributes", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "group_deletion_schedules", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "group_deletion_schedules", "users", name: "fk_11e3ebfcdd", on_delete: :cascade + add_foreign_key "group_deploy_tokens", "deploy_tokens", on_delete: :cascade + add_foreign_key "group_deploy_tokens", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "group_group_links", "namespaces", column: "shared_group_id", on_delete: :cascade add_foreign_key "group_group_links", "namespaces", column: "shared_with_group_id", on_delete: :cascade add_foreign_key "identities", "saml_providers", name: "fk_aade90f0fc", on_delete: :cascade diff --git a/doc/api/groups.md b/doc/api/groups.md index ea2493111df..25a61632bd3 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -487,7 +487,7 @@ Parameters: | `two_factor_grace_period` | integer | no | Time before Two-factor authentication is enforced (in hours). | | `project_creation_level` | string | no | Determine if developers can create projects in the group. Can be `noone` (No one), `maintainer` (Maintainers), or `developer` (Developers + Maintainers). | | `auto_devops_enabled` | boolean | no | Default to Auto DevOps pipeline for all projects within this group. | -| `subgroup_creation_level` | integer | no | Allowed to create subgroups. Can be `owner` (Owners), or `maintainer` (Maintainers). | +| `subgroup_creation_level` | string | no | Allowed to create subgroups. Can be `owner` (Owners), or `maintainer` (Maintainers). | | `emails_disabled` | boolean | no | Disable email notifications | | `mentions_disabled` | boolean | no | Disable the capability of a group from getting mentioned | | `lfs_enabled` | boolean | no | Enable/disable Large File Storage (LFS) for the projects in this group. | @@ -533,7 +533,7 @@ PUT /groups/:id | `two_factor_grace_period` | integer | no | Time before Two-factor authentication is enforced (in hours). | | `project_creation_level` | string | no | Determine if developers can create projects in the group. Can be `noone` (No one), `maintainer` (Maintainers), or `developer` (Developers + Maintainers). | | `auto_devops_enabled` | boolean | no | Default to Auto DevOps pipeline for all projects within this group. | -| `subgroup_creation_level` | integer | no | Allowed to create subgroups. Can be `owner` (Owners), or `maintainer` (Maintainers). | +| `subgroup_creation_level` | string | no | Allowed to create subgroups. Can be `owner` (Owners), or `maintainer` (Maintainers). | | `emails_disabled` | boolean | no | Disable email notifications | | `mentions_disabled` | boolean | no | Disable the capability of a group from getting mentioned | | `lfs_enabled` (optional) | boolean | no | Enable/disable Large File Storage (LFS) for the projects in this group. | diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 6b1a0e4ffe6..7e67ddc9021 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -1970,7 +1970,7 @@ job: > Introduced in GitLab 8.9 and GitLab Runner v1.3.0. `expire_in` allows you to specify how long artifacts should live before they -expire and therefore deleted, counting from the time they are uploaded and +expire and are therefore deleted, counting from the time they are uploaded and stored on GitLab. If the expiry time is not defined, it defaults to the [instance wide setting](../../user/admin_area/settings/continuous_integration.md#default-artifacts-expiration-core-only) (30 days by default, forever on GitLab.com). diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 4268e386425..cc9ef3ab5c5 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -762,6 +762,39 @@ networkPolicy: app.gitlab.com/managed_by: gitlab ``` +#### Web Application Firewall (ModSecurity) customization + +> [Introduced](https://gitlab.com/gitlab-org/charts/auto-deploy-app/-/merge_requests/44) in GitLab 12.8. + +Customization on an [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) or on a deployment base is available for clusters with [ModSecurity installed](../../user/clusters/applications.md#web-application-firewall-modsecurity). + +To enable ModSecurity with Auto Deploy, you need to create a `.gitlab/auto-deploy-values.yaml` file in your project with the following attributes. + +|Attribute | Description | Default | +-----------|-------------|---------| +|`enabled` | Enables custom configuration for modsecurity, defaulting to the [Core Rule Set](https://coreruleset.org/) | `false` | +|`secRuleEngine` | Configures the [rules engine](https://github.com/SpiderLabs/ModSecurity/wiki/Reference-Manual-(v2.x)#secruleengine) | `DetectionOnly` | +|`secRules` | Creates one or more additional [rule](https://github.com/SpiderLabs/ModSecurity/wiki/Reference-Manual-(v2.x)#SecRule) | `nil` | + +In the following `auto-deploy-values.yaml` example, some custom settings +are enabled for ModSecurity. Those include setting its engine to +process rules instead of only logging them, while adding two specific +rules which are header-based: + +```yaml +ingress: + modSecurity: + enabled: true + secRuleEngine: "On" + secRules: + - variable: "REQUEST_HEADERS:User-Agent" + operator: "printer" + action: "log,deny,id:'2010',status:403,msg:'printer is an invalid agent'" + - variable: "REQUEST_HEADERS:Content-Type" + operator: "text/plain" + action: "log,deny,id:'2011',status:403,msg:'Text is not supported as content type'" +``` + #### Running commands in the container Applications built with [Auto Build](#auto-build) using Herokuish, the default diff --git a/doc/user/application_security/dependency_list/index.md b/doc/user/application_security/dependency_list/index.md index 2828d487153..992f4137bb8 100644 --- a/doc/user/application_security/dependency_list/index.md +++ b/doc/user/application_security/dependency_list/index.md @@ -5,7 +5,7 @@ The Dependency list allows you to see your project's dependencies, and key details about them, including their known vulnerabilities. To see it, navigate to **Security & Compliance > Dependency List** in your project's -sidebar. +sidebar. This information is sometimes referred to as a Software Bill of Materials or SBoM / BOM. ## Requirements diff --git a/doc/user/application_security/sast/index.md b/doc/user/application_security/sast/index.md index ea9c0b85bea..fad6d33dc7f 100644 --- a/doc/user/application_security/sast/index.md +++ b/doc/user/application_security/sast/index.md @@ -454,6 +454,12 @@ CI/CD configuration file to turn it on. Results are available in the SAST report GitLab currently includes [Gitleaks](https://github.com/zricethezav/gitleaks) and [TruffleHog](https://github.com/dxa4481/truffleHog) checks. +NOTE: **Note:** +The secrets analyzer will ignore "Password in URL" vulnerabilities if the password begins +with a dollar sign (`$`) as this likely indicates the password being used is an environment +variable. For example, `https://username:$password@example.com/path/to/repo` will not be +detected, whereas `https://username:password@example.com/path/to/repo` would be detected. + ## Security Dashboard The Security Dashboard is a good place to get an overview of all the security diff --git a/doc/user/profile/preferences.md b/doc/user/profile/preferences.md index b299c74c8f4..cd195e6e7a1 100644 --- a/doc/user/profile/preferences.md +++ b/doc/user/profile/preferences.md @@ -108,6 +108,15 @@ You can choose between 3 options: - Readme - Activity +### Tab width + +You can set the displayed width of tab characters across various parts of +GitLab, for example, blobs, diffs, and snippets. + +NOTE: **Note:** +Some parts of GitLab do not respect this setting, including the WebIDE, file +editor and Markdown editor. + ## Localization ### Language diff --git a/doc/user/project/operations/error_tracking.md b/doc/user/project/operations/error_tracking.md index 685fdefe0c6..e87b5d03438 100644 --- a/doc/user/project/operations/error_tracking.md +++ b/doc/user/project/operations/error_tracking.md @@ -25,7 +25,7 @@ GitLab provides an easy way to connect Sentry to your project: Make sure to give the token at least the following scopes: `event:read` and `project:read`. 1. Navigate to your project’s **Settings > Operations**. 1. Ensure that the **Active** checkbox is set. -1. In the **Sentry API URL** field, enter your Sentry hostname. For example, `https://sentry.example.com`. +1. In the **Sentry API URL** field, enter your Sentry hostname. For example, enter `https://sentry.example.com` if this is the address at which your Sentry instance is available. For the SaaS version of Sentry, the hostname will be `https://sentry.io`. 1. In the **Auth Token** field, enter the token you previously generated. 1. Click the **Connect** button to test the connection to Sentry and populate the **Project** dropdown. 1. From the **Project** dropdown, choose a Sentry project to link to your GitLab project. diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 821c68dbedc..1329357d0b8 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -49,7 +49,7 @@ module Gitlab lfs_token_check(login, password, project) || oauth_access_token_check(login, password) || personal_access_token_check(password) || - deploy_token_check(login, password) || + deploy_token_check(login, password, project) || user_with_password_for_git(login, password) || Gitlab::Auth::Result.new @@ -208,7 +208,7 @@ module Gitlab end.uniq end - def deploy_token_check(login, password) + def deploy_token_check(login, password, project) return unless password.present? token = DeployToken.active.find_by_token(password) @@ -219,7 +219,7 @@ module Gitlab scopes = abilities_for_scopes(token.scopes) if valid_scoped_token?(token, all_available_scopes) - Gitlab::Auth::Result.new(token, token.project, :deploy_token, scopes) + Gitlab::Auth::Result.new(token, project, :deploy_token, scopes) end end diff --git a/lib/gitlab/email/hook/smime_signature_interceptor.rb b/lib/gitlab/email/hook/smime_signature_interceptor.rb index e48041d9218..61c9c984f8e 100644 --- a/lib/gitlab/email/hook/smime_signature_interceptor.rb +++ b/lib/gitlab/email/hook/smime_signature_interceptor.rb @@ -11,6 +11,7 @@ module Gitlab cert: certificate.cert, key: certificate.key, data: message.encoded) + signed_email = Mail.new(signed_message) overwrite_body(message, signed_email) diff --git a/lib/gitlab/tab_width.rb b/lib/gitlab/tab_width.rb new file mode 100644 index 00000000000..d33723a2106 --- /dev/null +++ b/lib/gitlab/tab_width.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Gitlab + module TabWidth + extend self + + MIN = 1 + MAX = 12 + DEFAULT = 8 + + def css_class_for_user(user) + return css_class_for_value(DEFAULT) unless user + + css_class_for_value(user.tab_width) + end + + private + + def css_class_for_value(value) + raise ArgumentError unless in_range?(value) + + "tab-width-#{value}" + end + + def in_range?(value) + (MIN..MAX).cover?(value) + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9062f1fc5ac..23978006af0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -484,7 +484,7 @@ msgstr "" msgid "%{username}'s avatar" msgstr "" -msgid "%{value} ms" +msgid "%{value} s" msgstr "" msgid "%{verb} %{time_spent_value} spent time." @@ -1670,6 +1670,9 @@ msgstr "" msgid "An error occurred fetching the dropdown data." msgstr "" +msgid "An error occurred loading code navigation" +msgstr "" + msgid "An error occurred previewing the blob" msgstr "" @@ -9297,6 +9300,9 @@ msgstr "" msgid "Go to commits" msgstr "" +msgid "Go to definition" +msgstr "" + msgid "Go to environments" msgstr "" @@ -13963,6 +13969,9 @@ msgstr "" msgid "Preferences|Layout width" msgstr "" +msgid "Preferences|Must be a number between %{min} and %{max}" +msgstr "" + msgid "Preferences|Navigation theme" msgstr "" @@ -13981,6 +13990,9 @@ msgstr "" msgid "Preferences|Syntax highlighting theme" msgstr "" +msgid "Preferences|Tab width" +msgstr "" + msgid "Preferences|These settings will update how dates and times are displayed for you." msgstr "" @@ -19227,6 +19239,9 @@ msgstr "" msgid "This GitLab instance is licensed at the %{insufficient_license} tier. Geo is only available for users who have at least a Premium license." msgstr "" +msgid "This Project is currently archived and read-only. Please unarchive the project first if you want to resume Pull mirroring" +msgstr "" + msgid "This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention." msgstr "" diff --git a/qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb b/qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb index 3e000c6381e..887625c4aa8 100644 --- a/qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb +++ b/qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb @@ -4,6 +4,7 @@ module QA context 'Verify', :docker do describe 'Pipeline creation and processing' do let(:executor) { "qa-runner-#{Time.now.to_i}" } + let(:max_wait) { 30 } let(:project) do Resource::Project.fabricate_via_api! do |project| @@ -68,11 +69,11 @@ module QA Page::Project::Pipeline::Index.perform(&:click_on_latest_pipeline) Page::Project::Pipeline::Show.perform do |pipeline| - expect(pipeline).to be_running(wait: 30) - expect(pipeline).to have_build('test-success', status: :success) - expect(pipeline).to have_build('test-failure', status: :failed) - expect(pipeline).to have_build('test-tags', status: :pending) - expect(pipeline).to have_build('test-artifacts', status: :success) + expect(pipeline).to be_running(wait: max_wait) + expect(pipeline).to have_build('test-success', status: :success, wait: max_wait) + expect(pipeline).to have_build('test-failure', status: :failed, wait: max_wait) + expect(pipeline).to have_build('test-tags', status: :pending, wait: max_wait) + expect(pipeline).to have_build('test-artifacts', status: :success, wait: max_wait) end end end diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb index 77e7b32af25..98a9c3eaec6 100644 --- a/spec/controllers/profiles/preferences_controller_spec.rb +++ b/spec/controllers/profiles/preferences_controller_spec.rb @@ -47,6 +47,7 @@ describe Profiles::PreferencesController do theme_id: '2', first_day_of_week: '1', preferred_language: 'jp', + tab_width: '5', render_whitespace_in_code: 'true' }.with_indifferent_access diff --git a/spec/factories/deploy_tokens.rb b/spec/factories/deploy_tokens.rb index 42ed66ac191..e86d4ab8812 100644 --- a/spec/factories/deploy_tokens.rb +++ b/spec/factories/deploy_tokens.rb @@ -9,6 +9,7 @@ FactoryBot.define do read_registry { true } revoked { false } expires_at { 5.days.from_now } + deploy_token_type { DeployToken.deploy_token_types[:project_type] } trait :revoked do revoked { true } @@ -21,5 +22,13 @@ FactoryBot.define do trait :expired do expires_at { Date.today - 1.month } end + + trait :group do + deploy_token_type { DeployToken.deploy_token_types[:group_type] } + end + + trait :project do + deploy_token_type { DeployToken.deploy_token_types[:project_type] } + end end end diff --git a/spec/factories/group_deploy_tokens.rb b/spec/factories/group_deploy_tokens.rb new file mode 100644 index 00000000000..9ec7d0701be --- /dev/null +++ b/spec/factories/group_deploy_tokens.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :group_deploy_token do + group + deploy_token + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index f83c137b758..34f6da682b6 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -23,6 +23,10 @@ FactoryBot.define do after(:build) { |user, _| user.block! } end + trait :bot do + bot_type { User.bot_types[:alert_bot] } + end + trait :external do external { true } end diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb index 5662465d431..8c16dcec42f 100644 --- a/spec/features/groups/navbar_spec.rb +++ b/spec/features/groups/navbar_spec.rb @@ -3,53 +3,53 @@ require 'spec_helper' describe 'Group navbar' do - it_behaves_like 'verified navigation bar' do - let(:user) { create(:user) } - let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group) { create(:group) } + + let(:analytics_nav_item) do + { + nav_item: _('Analytics'), + nav_sub_items: [ + _('Contribution Analytics') + ] + } + end - let(:analytics_nav_item) do + let(:structure) do + [ + { + nav_item: _('Group overview'), + nav_sub_items: [ + _('Details'), + _('Activity') + ] + }, { - nav_item: _('Analytics'), + nav_item: _('Issues'), nav_sub_items: [ - _('Contribution Analytics') + _('List'), + _('Board'), + _('Labels'), + _('Milestones') ] + }, + { + nav_item: _('Merge Requests'), + nav_sub_items: [] + }, + { + nav_item: _('Kubernetes'), + nav_sub_items: [] + }, + (analytics_nav_item if Gitlab.ee?), + { + nav_item: _('Members'), + nav_sub_items: [] } - end - - let(:structure) do - [ - { - nav_item: _('Group overview'), - nav_sub_items: [ - _('Details'), - _('Activity') - ] - }, - { - nav_item: _('Issues'), - nav_sub_items: [ - _('List'), - _('Board'), - _('Labels'), - _('Milestones') - ] - }, - { - nav_item: _('Merge Requests'), - nav_sub_items: [] - }, - { - nav_item: _('Kubernetes'), - nav_sub_items: [] - }, - (analytics_nav_item if Gitlab.ee?), - { - nav_item: _('Members'), - nav_sub_items: [] - } - ] - end + ] + end + it_behaves_like 'verified navigation bar' do before do group.add_maintainer(user) sign_in(user) @@ -57,4 +57,21 @@ describe 'Group navbar' do visit group_path(group) end end + + if Gitlab.ee? + context 'when productivity analytics is available' do + before do + stub_licensed_features(productivity_analytics: true) + + analytics_nav_item[:nav_sub_items] << _('Productivity Analytics') + + group.add_maintainer(user) + sign_in(user) + + visit group_path(group) + end + + it_behaves_like 'verified navigation bar' + end + end end diff --git a/spec/features/merge_request/maintainer_edits_fork_spec.rb b/spec/features/merge_request/maintainer_edits_fork_spec.rb index 4f2c5fc73d8..17ff494a6fa 100644 --- a/spec/features/merge_request/maintainer_edits_fork_spec.rb +++ b/spec/features/merge_request/maintainer_edits_fork_spec.rb @@ -20,7 +20,7 @@ describe 'a maintainer edits files on a source-branch of an MR from a fork', :js end before do - stub_feature_flags(web_ide_default: false, single_mr_diff_view: false) + stub_feature_flags(web_ide_default: false, single_mr_diff_view: false, code_navigation: false) target_project.add_maintainer(user) sign_in(user) diff --git a/spec/features/profiles/user_edit_preferences_spec.rb b/spec/features/profiles/user_edit_preferences_spec.rb index 2d2da222998..6e61536d5ff 100644 --- a/spec/features/profiles/user_edit_preferences_spec.rb +++ b/spec/features/profiles/user_edit_preferences_spec.rb @@ -29,4 +29,31 @@ describe 'User edit preferences profile' do expect(field).not_to be_checked end + + describe 'User changes tab width to acceptable value' do + it 'shows success message' do + fill_in 'Tab width', with: 9 + click_button 'Save changes' + + expect(page).to have_content('Preferences saved.') + end + + it 'saves the value' do + tab_width_field = page.find_field('Tab width') + + expect do + tab_width_field.fill_in with: 6 + click_button 'Save changes' + end.to change { tab_width_field.value } + end + end + + describe 'User changes tab width to unacceptable value' do + it 'shows error message' do + fill_in 'Tab width', with: -1 + click_button 'Save changes' + + expect(page).to have_content('Failed to save preferences') + end + end end diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index 5d86e4125df..e714d0f7cad 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -13,6 +13,10 @@ describe 'File blob', :js do wait_for_requests end + before do + stub_feature_flags(code_navigation: false) + end + context 'Ruby file' do before do visit_blob('files/ruby/popen.rb') diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb index a1d6a8896c7..5d62b2f87bb 100644 --- a/spec/features/projects/blobs/edit_spec.rb +++ b/spec/features/projects/blobs/edit_spec.rb @@ -69,6 +69,8 @@ describe 'Editing file blob', :js do context 'from blob file path' do before do + stub_feature_flags(code_navigation: false) + visit project_blob_path(project, tree_join(branch, file_path)) end diff --git a/spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb b/spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb index b90129d6176..30878b7fb64 100644 --- a/spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb +++ b/spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb @@ -8,6 +8,7 @@ describe 'User creates blob in new project', :js do shared_examples 'creating a file' do before do + stub_feature_flags(code_navigation: false) sign_in(user) visit project_path(project) end diff --git a/spec/features/projects/files/user_creates_files_spec.rb b/spec/features/projects/files/user_creates_files_spec.rb index eb9a4d8cb09..2d4f22e299e 100644 --- a/spec/features/projects/files/user_creates_files_spec.rb +++ b/spec/features/projects/files/user_creates_files_spec.rb @@ -14,7 +14,7 @@ describe 'Projects > Files > User creates files', :js do let(:user) { create(:user) } before do - stub_feature_flags(web_ide_default: false) + stub_feature_flags(web_ide_default: false, code_navigation: false) project.add_maintainer(user) sign_in(user) diff --git a/spec/features/projects/files/user_deletes_files_spec.rb b/spec/features/projects/files/user_deletes_files_spec.rb index 0f543e47631..5e36407d9cb 100644 --- a/spec/features/projects/files/user_deletes_files_spec.rb +++ b/spec/features/projects/files/user_deletes_files_spec.rb @@ -14,6 +14,8 @@ describe 'Projects > Files > User deletes files', :js do let(:user) { create(:user) } before do + stub_feature_flags(code_navigation: false) + sign_in(user) end diff --git a/spec/features/projects/files/user_replaces_files_spec.rb b/spec/features/projects/files/user_replaces_files_spec.rb index 4c54bbdcd67..e1eefdcc40f 100644 --- a/spec/features/projects/files/user_replaces_files_spec.rb +++ b/spec/features/projects/files/user_replaces_files_spec.rb @@ -16,6 +16,8 @@ describe 'Projects > Files > User replaces files', :js do let(:user) { create(:user) } before do + stub_feature_flags(code_navigation: false) + sign_in(user) end diff --git a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap new file mode 100644 index 00000000000..dda6d68018e --- /dev/null +++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Code navigation popover component renders popover 1`] = ` +<div + class="popover code-navigation-popover popover-font-size-normal gl-popover bs-popover-bottom show" + style="left: 0px; top: 0px;" +> + <div + class="arrow" + style="left: 0px;" + /> + + <div + class="border-bottom" + > + <pre + class="border-0 bg-transparent m-0 code highlight" + > + console.log + </pre> + </div> + + <div + class="popover-body" + > + <gl-button-stub + class="w-100" + href="http://test.com" + size="md" + target="_blank" + variant="default" + > + + Go to definition + + </gl-button-stub> + </div> +</div> +`; diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js new file mode 100644 index 00000000000..cfdc0dcc6cc --- /dev/null +++ b/spec/frontend/code_navigation/components/app_spec.js @@ -0,0 +1,64 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import createState from '~/code_navigation/store/state'; +import App from '~/code_navigation/components/app.vue'; +import Popover from '~/code_navigation/components/popover.vue'; + +const localVue = createLocalVue(); +const fetchData = jest.fn(); +const showDefinition = jest.fn(); +let wrapper; + +localVue.use(Vuex); + +function factory(initialState = {}) { + const store = new Vuex.Store({ + state: { + ...createState(), + ...initialState, + }, + actions: { + fetchData, + showDefinition, + }, + }); + + wrapper = shallowMount(App, { store, localVue }); +} + +describe('Code navigation app component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('fetches data on mount', () => { + factory(); + + expect(fetchData).toHaveBeenCalled(); + }); + + it('hides popover when no definition set', () => { + factory(); + + expect(wrapper.find(Popover).exists()).toBe(false); + }); + + it('renders popover when definition set', () => { + factory({ + currentDefinition: { hover: 'console' }, + currentDefinitionPosition: { x: 0 }, + }); + + expect(wrapper.find(Popover).exists()).toBe(true); + }); + + it('calls showDefinition when clicking blob viewer', () => { + setFixtures('<div class="blob-viewer"></div>'); + + factory(); + + document.querySelector('.blob-viewer').click(); + + expect(showDefinition).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/code_navigation/components/popover_spec.js b/spec/frontend/code_navigation/components/popover_spec.js new file mode 100644 index 00000000000..ad05504a224 --- /dev/null +++ b/spec/frontend/code_navigation/components/popover_spec.js @@ -0,0 +1,58 @@ +import { shallowMount } from '@vue/test-utils'; +import Popover from '~/code_navigation/components/popover.vue'; + +const MOCK_CODE_DATA = Object.freeze({ + hover: [ + { + language: 'javascript', + value: 'console.log', + }, + ], + definition_url: 'http://test.com', +}); + +const MOCK_DOCS_DATA = Object.freeze({ + hover: [ + { + language: null, + value: 'console.log', + }, + ], + definition_url: 'http://test.com', +}); + +let wrapper; + +function factory(position, data) { + wrapper = shallowMount(Popover, { propsData: { position, data } }); +} + +describe('Code navigation popover component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('renders popover', () => { + factory({ x: 0, y: 0, height: 0 }, MOCK_CODE_DATA); + + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('code output', () => { + it('renders code output', () => { + factory({ x: 0, y: 0, height: 0 }, MOCK_CODE_DATA); + + expect(wrapper.find({ ref: 'code-output' }).exists()).toBe(true); + expect(wrapper.find({ ref: 'doc-output' }).exists()).toBe(false); + }); + }); + + describe('documentation output', () => { + it('renders code output', () => { + factory({ x: 0, y: 0, height: 0 }, MOCK_DOCS_DATA); + + expect(wrapper.find({ ref: 'code-output' }).exists()).toBe(false); + expect(wrapper.find({ ref: 'doc-output' }).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js new file mode 100644 index 00000000000..5e29a76f804 --- /dev/null +++ b/spec/frontend/code_navigation/store/actions_spec.js @@ -0,0 +1,221 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import actions from '~/code_navigation/store/actions'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { setCurrentHoverElement, addInteractionClass } from '~/code_navigation/utils'; + +jest.mock('~/flash'); +jest.mock('~/code_navigation/utils'); + +describe('Code navigation actions', () => { + describe('setInitialData', () => { + it('commits SET_INITIAL_DATA', done => { + testAction( + actions.setInitialData, + { projectPath: 'test' }, + {}, + [{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test' } }], + [], + done, + ); + }); + }); + + describe('requestDataError', () => { + it('commits REQUEST_DATA_ERROR', () => + testAction(actions.requestDataError, null, {}, [{ type: 'REQUEST_DATA_ERROR' }], [])); + + it('creates a flash message', () => + testAction(actions.requestDataError, null, {}, [{ type: 'REQUEST_DATA_ERROR' }], []).then( + () => { + expect(createFlash).toHaveBeenCalled(); + }, + )); + }); + + describe('fetchData', () => { + let mock; + const state = { + projectPath: 'gitlab-org/gitlab', + commitId: '123', + blobPath: 'index', + }; + const apiUrl = '/api/1/projects/gitlab-org%2Fgitlab/commits/123/lsif/info'; + + beforeEach(() => { + window.gon = { api_version: '1' }; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + beforeEach(() => { + mock.onGet(apiUrl).replyOnce(200, [ + { + start_line: 0, + start_char: 0, + hover: { value: '123' }, + }, + { + start_line: 1, + start_char: 0, + hover: null, + }, + ]); + }); + + it('commits REQUEST_DATA_SUCCESS with normalized data', done => { + testAction( + actions.fetchData, + null, + state, + [ + { type: 'REQUEST_DATA' }, + { + type: 'REQUEST_DATA_SUCCESS', + payload: { '0:0': { start_line: 0, start_char: 0, hover: { value: '123' } } }, + }, + ], + [], + done, + ); + }); + + it('calls addInteractionClass with data', done => { + testAction( + actions.fetchData, + null, + state, + [ + { type: 'REQUEST_DATA' }, + { + type: 'REQUEST_DATA_SUCCESS', + payload: { '0:0': { start_line: 0, start_char: 0, hover: { value: '123' } } }, + }, + ], + [], + ) + .then(() => { + expect(addInteractionClass).toHaveBeenCalledWith({ + start_line: 0, + start_char: 0, + hover: { value: '123' }, + }); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(apiUrl).replyOnce(500); + }); + + it('dispatches requestDataError', done => { + testAction( + actions.fetchData, + null, + state, + [{ type: 'REQUEST_DATA' }], + [{ type: 'requestDataError' }], + done, + ); + }); + }); + }); + + describe('showDefinition', () => { + let target; + + beforeEach(() => { + target = document.createElement('div'); + }); + + it('returns early when no data exists', done => { + testAction(actions.showDefinition, { target }, {}, [], [], done); + }); + + it('commits SET_CURRENT_DEFINITION when target is not code navitation element', done => { + testAction( + actions.showDefinition, + { target }, + { data: {} }, + [ + { + type: 'SET_CURRENT_DEFINITION', + payload: { definition: undefined, position: undefined }, + }, + ], + [], + done, + ); + }); + + it('commits SET_CURRENT_DEFINITION with LSIF data', done => { + target.classList.add('js-code-navigation'); + target.setAttribute('data-line-index', '0'); + target.setAttribute('data-char-index', '0'); + + testAction( + actions.showDefinition, + { target }, + { data: { '0:0': { hover: 'test' } } }, + [ + { + type: 'SET_CURRENT_DEFINITION', + payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } }, + }, + ], + [], + done, + ); + }); + + it('adds hll class to target element', () => { + target.classList.add('js-code-navigation'); + target.setAttribute('data-line-index', '0'); + target.setAttribute('data-char-index', '0'); + + return testAction( + actions.showDefinition, + { target }, + { data: { '0:0': { hover: 'test' } } }, + [ + { + type: 'SET_CURRENT_DEFINITION', + payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } }, + }, + ], + [], + ).then(() => { + expect(target.classList).toContain('hll'); + }); + }); + + it('caches current target element', () => { + target.classList.add('js-code-navigation'); + target.setAttribute('data-line-index', '0'); + target.setAttribute('data-char-index', '0'); + + return testAction( + actions.showDefinition, + { target }, + { data: { '0:0': { hover: 'test' } } }, + [ + { + type: 'SET_CURRENT_DEFINITION', + payload: { definition: { hover: 'test' }, position: { height: 0, x: 0, y: 0 } }, + }, + ], + [], + ).then(() => { + expect(setCurrentHoverElement).toHaveBeenCalledWith(target); + }); + }); + }); +}); diff --git a/spec/frontend/code_navigation/store/mutations_spec.js b/spec/frontend/code_navigation/store/mutations_spec.js new file mode 100644 index 00000000000..117a2ed2f14 --- /dev/null +++ b/spec/frontend/code_navigation/store/mutations_spec.js @@ -0,0 +1,63 @@ +import mutations from '~/code_navigation/store/mutations'; +import createState from '~/code_navigation/store/state'; + +let state; + +describe('Code navigation mutations', () => { + beforeEach(() => { + state = createState(); + }); + + describe('SET_INITIAL_DATA', () => { + it('sets initial data', () => { + mutations.SET_INITIAL_DATA(state, { + projectPath: 'test', + commitId: '123', + blobPath: 'index.js', + }); + + expect(state.projectPath).toBe('test'); + expect(state.commitId).toBe('123'); + expect(state.blobPath).toBe('index.js'); + }); + }); + + describe('REQUEST_DATA', () => { + it('sets loading true', () => { + mutations.REQUEST_DATA(state); + + expect(state.loading).toBe(true); + }); + }); + + describe('REQUEST_DATA_SUCCESS', () => { + it('sets loading false', () => { + mutations.REQUEST_DATA_SUCCESS(state, ['test']); + + expect(state.loading).toBe(false); + }); + + it('sets data', () => { + mutations.REQUEST_DATA_SUCCESS(state, ['test']); + + expect(state.data).toEqual(['test']); + }); + }); + + describe('REQUEST_DATA_ERROR', () => { + it('sets loading false', () => { + mutations.REQUEST_DATA_ERROR(state); + + expect(state.loading).toBe(false); + }); + }); + + describe('SET_CURRENT_DEFINITION', () => { + it('sets current definition and position', () => { + mutations.SET_CURRENT_DEFINITION(state, { definition: 'test', position: { x: 0 } }); + + expect(state.currentDefinition).toBe('test'); + expect(state.currentDefinitionPosition).toEqual({ x: 0 }); + }); + }); +}); diff --git a/spec/frontend/code_navigation/utils/index_spec.js b/spec/frontend/code_navigation/utils/index_spec.js new file mode 100644 index 00000000000..458cc536635 --- /dev/null +++ b/spec/frontend/code_navigation/utils/index_spec.js @@ -0,0 +1,58 @@ +import { + cachedData, + getCurrentHoverElement, + setCurrentHoverElement, + addInteractionClass, +} from '~/code_navigation/utils'; + +afterEach(() => { + if (cachedData.has('current')) { + cachedData.delete('current'); + } +}); + +describe('getCurrentHoverElement', () => { + it.each` + value + ${'test'} + ${undefined} + `('it returns cached current key', ({ value }) => { + if (value) { + cachedData.set('current', value); + } + + expect(getCurrentHoverElement()).toEqual(value); + }); +}); + +describe('setCurrentHoverElement', () => { + it('sets cached current key', () => { + setCurrentHoverElement('test'); + + expect(getCurrentHoverElement()).toEqual('test'); + }); +}); + +describe('addInteractionClass', () => { + beforeEach(() => { + setFixtures( + '<div id="LC1"><span>console</span><span>.</span><span>log</span></div><div id="LC2"><span>function</span></div>', + ); + }); + + it.each` + line | char | index + ${0} | ${0} | ${0} + ${0} | ${8} | ${2} + ${1} | ${0} | ${0} + `( + 'it sets code navigation attributes for line $line and character $char', + ({ line, char, index }) => { + addInteractionClass({ start_line: line, start_char: char }); + + expect(document.querySelectorAll(`#LC${line + 1} span`)[index].classList).toContain( + 'js-code-navigation', + ); + }, + ); +}); diff --git a/spec/initializers/mail_encoding_patch_spec.rb b/spec/initializers/mail_encoding_patch_spec.rb new file mode 100644 index 00000000000..41074af3503 --- /dev/null +++ b/spec/initializers/mail_encoding_patch_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +require 'mail' +require_relative '../../config/initializers/mail_encoding_patch.rb' + +describe 'Mail quoted-printable transfer encoding patch and Unicode characters' do + shared_examples 'email encoding' do |email| + it 'enclosing in a new object does not change the encoded original' do + new_email = Mail.new(email) + + expect(new_email.subject).to eq(email.subject) + expect(new_email.from).to eq(email.from) + expect(new_email.to).to eq(email.to) + expect(new_email.content_type).to eq(email.content_type) + expect(new_email.content_transfer_encoding).to eq(email.content_transfer_encoding) + + expect(new_email.encoded).to eq(email.encoded) + end + end + + context 'with a text email' do + context 'with a body that encodes to exactly 74 characters (final newline)' do + email = Mail.new do + to 'jane.doe@example.com' + from 'John Dóe <john.doe@example.com>' + subject 'Encoding tést' + content_type 'text/plain; charset=UTF-8' + content_transfer_encoding 'quoted-printable' + body "-123456789-123456789-123456789-123456789-123456789-123456789-123456789-1\n" + end + + it_behaves_like 'email encoding', email + end + + context 'with a body that encodes to exactly 74 characters (no final newline)' do + email = Mail.new do + to 'jane.doe@example.com' + from 'John Dóe <john.doe@example.com>' + subject 'Encoding tést' + content_type 'text/plain; charset=UTF-8' + content_transfer_encoding 'quoted-printable' + body "-123456789-123456789-123456789-123456789-123456789-123456789-123456789-12" + end + + it_behaves_like 'email encoding', email + end + + context 'with a body that encodes to exactly 75 characters' do + email = Mail.new do + to 'jane.doe@example.com' + from 'John Dóe <john.doe@example.com>' + subject 'Encoding tést' + content_type 'text/plain; charset=UTF-8' + content_transfer_encoding 'quoted-printable' + body "-123456789-123456789-123456789-123456789-123456789-123456789-123456789-12\n" + end + + it_behaves_like 'email encoding', email + end + end + + context 'with an html email' do + context 'with a body that encodes to exactly 74 characters (final newline)' do + email = Mail.new do + to 'jane.doe@example.com' + from 'John Dóe <john.doe@example.com>' + subject 'Encoding tést' + content_type 'text/html; charset=UTF-8' + content_transfer_encoding 'quoted-printable' + body "<p>-123456789-123456789-123456789-123456789-123456789-123456789-1234</p>\n" + end + + it_behaves_like 'email encoding', email + end + + context 'with a body that encodes to exactly 74 characters (no final newline)' do + email = Mail.new do + to 'jane.doe@example.com' + from 'John Dóe <john.doe@example.com>' + subject 'Encoding tést' + content_type 'text/html; charset=UTF-8' + content_transfer_encoding 'quoted-printable' + body "<p>-123456789-123456789-123456789-123456789-123456789-123456789-12345</p>" + end + + it_behaves_like 'email encoding', email + end + + context 'with a body that encodes to exactly 75 characters' do + email = Mail.new do + to 'jane.doe@example.com' + from 'John Dóe <john.doe@example.com>' + subject 'Encoding tést' + content_type 'text/html; charset=UTF-8' + content_transfer_encoding 'quoted-printable' + body "<p>-123456789-123456789-123456789-123456789-123456789-123456789-12345</p>\n" + end + + it_behaves_like 'email encoding', email + end + end + + context 'a multipart email' do + email = Mail.new do + to 'jane.doe@example.com' + from 'John Dóe <john.doe@example.com>' + subject 'Encoding tést' + end + + text_part = Mail::Part.new do + content_type 'text/plain; charset=UTF-8' + content_transfer_encoding 'quoted-printable' + body "\r\n\r\n@john.doe, now known as John Dóe has accepted your invitation to join the Administrator / htmltest project.\r\n\r\nhttp://169.254.169.254:3000/root/htmltest\r\n\r\n-- \r\nYou're receiving this email because of your account on 169.254.169.254.\r\n\r\n\r\n\r\n" + end + + html_part = Mail::Part.new do + content_type 'text/html; charset=UTF-8' + content_transfer_encoding 'quoted-printable' + body "\r\n\r\n@john.doe, now known as John Dóe has accepted your invitation to join the Administrator / htmltest project.\r\n\r\nhttp://169.254.169.254:3000/root/htmltest\r\n\r\n-- \r\nYou're receiving this email because of your account on 169.254.169.254.\r\n\r\n\r\n\r\n" + end + + email.text_part = text_part + email.html_part = html_part + + it_behaves_like 'email encoding', email + end + + context 'with non UTF-8 charset' do + email = Mail.new do + to 'jane.doe@example.com' + from 'John Dóe <john.doe@example.com>' + subject 'Encoding tést' + content_type 'text/plain; charset=windows-1251' + content_transfer_encoding 'quoted-printable' + body "This line is very long and will be put in multiple quoted-printable lines. Some Russian character: Д\n\n\n".encode('windows-1251') + end + + it_behaves_like 'email encoding', email + + it 'can be decoded back' do + expect(Mail.new(email).body.decoded.dup.force_encoding('windows-1251').encode('utf-8')).to include('Some Russian character: Д') + end + end + + context 'with binary content' do + context 'can be encoded with \'base64\' content-transfer-encoding' do + image = File.binread('spec/fixtures/rails_sample.jpg') + + email = Mail.new do + to 'jane.doe@example.com' + from 'John Dóe <john.doe@example.com>' + subject 'Encoding tést' + end + + part = Mail::Part.new + part.body = [image].pack('m') + part.content_type = 'image/jpg' + part.content_transfer_encoding = 'base64' + + email.parts << part + + it_behaves_like 'email encoding', email + + it 'binary contents are not modified' do + expect(email.parts.first.decoded).to eq(image) + + # Enclosing in a new Mail object does not corrupt encoded data + expect(Mail.new(email).parts.first.decoded).to eq(image) + end + end + + context 'encoding fails with \'quoted-printable\' content-transfer-encoding' do + image = File.binread('spec/fixtures/rails_sample.jpg') + + email = Mail.new do + to 'jane.doe@example.com' + from 'John Dóe <john.doe@example.com>' + subject 'Encoding tést' + end + + part = Mail::Part.new + part.body = [image].pack('M*') + part.content_type = 'image/jpg' + part.content_transfer_encoding = 'quoted-printable' + + email.parts << part + + # The Mail patch in `config/initializers/mail_encoding_patch.rb` fixes + # encoding of non-binary content. The failure below is expected since we + # reverted some upstream changes in order to properly support SMIME signatures + # See https://gitlab.com/gitlab-org/gitlab/issues/197386 + it 'content cannot be decoded back' do + # Headers are ok + expect(email.subject).to eq(email.subject) + expect(email.from).to eq(email.from) + expect(email.to).to eq(email.to) + expect(email.content_type).to eq(email.content_type) + expect(email.content_transfer_encoding).to eq(email.content_transfer_encoding) + + # Content cannot be recovered + expect(email.parts.first.decoded).not_to eq(image) + end + end + end +end diff --git a/spec/javascripts/reports/components/modal_spec.js b/spec/javascripts/reports/components/modal_spec.js index d42c509e5b5..ff046e64b6e 100644 --- a/spec/javascripts/reports/components/modal_spec.js +++ b/spec/javascripts/reports/components/modal_spec.js @@ -42,8 +42,8 @@ describe('Grouped Test Reports Modal', () => { ); }); - it('renders miliseconds', () => { - expect(vm.$el.textContent).toContain(`${modalDataStructure.execution_time.value} ms`); + it('renders seconds', () => { + expect(vm.$el.textContent).toContain(`${modalDataStructure.execution_time.value} s`); }); it('render title', () => { diff --git a/spec/javascripts/user_popovers_spec.js b/spec/javascripts/user_popovers_spec.js index 3962f837a00..b3def474957 100644 --- a/spec/javascripts/user_popovers_spec.js +++ b/spec/javascripts/user_popovers_spec.js @@ -38,6 +38,13 @@ describe('User Popovers', () => { expect(document.querySelectorAll(selector).length).toBe(popovers.length); }); + it('does not initialize the user popovers twice for the same element', () => { + const newPopovers = initUserPopovers(document.querySelectorAll(selector)); + const samePopovers = popovers.every((popover, index) => newPopovers[index] === popover); + + expect(samePopovers).toBe(true); + }); + describe('when user link emits mouseenter event', () => { let userLink; diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index 1f943bebbec..ed763f63756 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -460,6 +460,20 @@ describe Gitlab::Auth, :use_clean_rails_memory_store_caching do end end + context 'when the deploy token is of group type' do + let(:project_with_group) { create(:project, group: create(:group)) } + let(:deploy_token) { create(:deploy_token, :group, read_repository: true, groups: [project_with_group.group]) } + let(:login) { deploy_token.username } + + subject { gl_auth.find_for_git_client(login, deploy_token.token, project: project_with_group, ip: 'ip') } + + it 'succeeds when login and a group deploy token are valid' do + auth_success = Gitlab::Auth::Result.new(deploy_token, project_with_group, :deploy_token, [:download_code, :read_container_image]) + + expect(subject).to eq(auth_success) + end + end + context 'when the deploy token has read_registry as a scope' do let(:deploy_token) { create(:deploy_token, read_repository: false, projects: [project]) } let(:login) { deploy_token.username } @@ -469,10 +483,10 @@ describe Gitlab::Auth, :use_clean_rails_memory_store_caching do stub_container_registry_config(enabled: true) end - it 'succeeds when login and token are valid' do + it 'succeeds when login and a project token are valid' do auth_success = Gitlab::Auth::Result.new(deploy_token, project, :deploy_token, [:read_container_image]) - expect(gl_auth.find_for_git_client(login, deploy_token.token, project: nil, ip: 'ip')) + expect(gl_auth.find_for_git_client(login, deploy_token.token, project: project, ip: 'ip')) .to eq(auth_success) end diff --git a/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb b/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb index a65214fab61..36954252b6b 100644 --- a/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb +++ b/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb @@ -20,8 +20,14 @@ describe Gitlab::Email::Hook::SmimeSignatureInterceptor do Gitlab::Email::Smime::Certificate.new(@cert[:key], @cert[:cert]) end + let(:mail_body) { "signed hello with Unicode €áø and\r\n newlines\r\n" } + let(:mail) do - ActionMailer::Base.mail(to: 'test@example.com', from: 'info@example.com', body: 'signed hello') + ActionMailer::Base.mail(to: 'test@example.com', + from: 'info@example.com', + content_transfer_encoding: 'quoted-printable', + content_type: 'text/plain; charset=UTF-8', + body: mail_body) end before do @@ -46,9 +52,16 @@ describe Gitlab::Email::Hook::SmimeSignatureInterceptor do ca_cert: root_certificate.cert, signed_data: mail.encoded) + # re-verify signature from a new Mail object content + # See https://gitlab.com/gitlab-org/gitlab/issues/197386 + Gitlab::Email::Smime::Signer.verify_signature( + cert: certificate.cert, + ca_cert: root_certificate.cert, + signed_data: Mail.new(mail).encoded) + # envelope in a Mail object and obtain the body decoded_mail = Mail.new(p7enc.data) - expect(decoded_mail.body.encoded).to eq('signed hello') + expect(decoded_mail.body.decoded.dup.force_encoding(decoded_mail.charset)).to eq(mail_body) end end diff --git a/spec/lib/gitlab/tab_width_spec.rb b/spec/lib/gitlab/tab_width_spec.rb new file mode 100644 index 00000000000..3b5014d27e4 --- /dev/null +++ b/spec/lib/gitlab/tab_width_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +describe Gitlab::TabWidth, lib: true do + describe '.css_class_for_user' do + it 'returns default CSS class when user is nil' do + css_class = described_class.css_class_for_user(nil) + + expect(css_class).to eq('tab-width-8') + end + + it "returns CSS class for user's tab width", :aggregate_failures do + [1, 6, 12].each do |i| + user = double('user', tab_width: i) + css_class = described_class.css_class_for_user(user) + + expect(css_class).to eq("tab-width-#{i}") + end + end + + it 'raises if tab width is out of valid range', :aggregate_failures do + [0, 13, 'foo', nil].each do |i| + expect do + user = double('user', tab_width: i) + described_class.css_class_for_user(user) + end.to raise_error(ArgumentError) + end + end + end +end diff --git a/spec/models/deploy_token_spec.rb b/spec/models/deploy_token_spec.rb index 5c14d57cf18..568699cf3f6 100644 --- a/spec/models/deploy_token_spec.rb +++ b/spec/models/deploy_token_spec.rb @@ -7,6 +7,8 @@ describe DeployToken do it { is_expected.to have_many :project_deploy_tokens } it { is_expected.to have_many(:projects).through(:project_deploy_tokens) } + it { is_expected.to have_many :group_deploy_tokens } + it { is_expected.to have_many(:groups).through(:group_deploy_tokens) } it_behaves_like 'having unique enum values' @@ -17,6 +19,29 @@ describe DeployToken do it { is_expected.to allow_value('GitLab+deploy_token-3.14').for(:username) } it { is_expected.not_to allow_value('<script>').for(:username).with_message(username_format_message) } it { is_expected.not_to allow_value('').for(:username).with_message(username_format_message) } + it { is_expected.to validate_presence_of(:deploy_token_type) } + end + + describe 'deploy_token_type validations' do + context 'when a deploy token is associated to a group' do + it 'does not allow setting a project to it' do + group_token = create(:deploy_token, :group) + group_token.projects << build(:project) + + expect(group_token).not_to be_valid + expect(group_token.errors.full_messages).to include('Deploy token cannot have projects assigned') + end + end + + context 'when a deploy token is associated to a project' do + it 'does not allow setting a group to it' do + project_token = create(:deploy_token) + project_token.groups << build(:group) + + expect(project_token).not_to be_valid + expect(project_token.errors.full_messages).to include('Deploy token cannot have groups assigned') + end + end end describe '#ensure_token' do @@ -125,33 +150,148 @@ describe DeployToken do end end + describe '#holder' do + subject { deploy_token.holder } + + context 'when the token is of project type' do + it 'returns the relevant holder token' do + expect(subject).to eq(deploy_token.project_deploy_tokens.first) + end + end + + context 'when the token is of group type' do + let(:group) { create(:group) } + let(:deploy_token) { create(:deploy_token, :group) } + + it 'returns the relevant holder token' do + expect(subject).to eq(deploy_token.group_deploy_tokens.first) + end + end + end + describe '#has_access_to?' do let(:project) { create(:project) } subject { deploy_token.has_access_to?(project) } - context 'when deploy token is active and related to project' do - let(:deploy_token) { create(:deploy_token, projects: [project]) } + context 'when a project is not passed in' do + let(:project) { nil } - it { is_expected.to be_truthy } + it { is_expected.to be_falsy } end - context 'when deploy token is active but not related to project' do - let(:deploy_token) { create(:deploy_token) } + context 'when a project is passed in' do + context 'when deploy token is active and related to project' do + let(:deploy_token) { create(:deploy_token, projects: [project]) } - it { is_expected.to be_falsy } - end + it { is_expected.to be_truthy } + end - context 'when deploy token is revoked and related to project' do - let(:deploy_token) { create(:deploy_token, :revoked, projects: [project]) } + context 'when deploy token is active but not related to project' do + let(:deploy_token) { create(:deploy_token) } - it { is_expected.to be_falsy } - end + it { is_expected.to be_falsy } + end - context 'when deploy token is revoked and not related to the project' do - let(:deploy_token) { create(:deploy_token, :revoked) } + context 'when deploy token is revoked and related to project' do + let(:deploy_token) { create(:deploy_token, :revoked, projects: [project]) } - it { is_expected.to be_falsy } + it { is_expected.to be_falsy } + end + + context 'when deploy token is revoked and not related to the project' do + let(:deploy_token) { create(:deploy_token, :revoked) } + + it { is_expected.to be_falsy } + end + + context 'and when the token is of group type' do + let_it_be(:group) { create(:group) } + let(:deploy_token) { create(:deploy_token, :group) } + + before do + deploy_token.groups << group + end + + context 'and the allow_group_deploy_token feature flag is turned off' do + it 'is false' do + stub_feature_flags(allow_group_deploy_token: false) + + is_expected.to be_falsy + end + end + + context 'and the allow_group_deploy_token feature flag is turned on' do + before do + stub_feature_flags(allow_group_deploy_token: true) + end + + context 'and the passed-in project does not belong to any group' do + it { is_expected.to be_falsy } + end + + context 'and the passed-in project belongs to the token group' do + it 'is true' do + group.projects << project + + is_expected.to be_truthy + end + end + + context 'and the passed-in project belongs to a subgroup' do + let(:child_group) { create(:group, parent_id: group.id) } + let(:grandchild_group) { create(:group, parent_id: child_group.id) } + + before do + grandchild_group.projects << project + end + + context 'and the token group is an ancestor (grand-parent) of this group' do + it { is_expected.to be_truthy } + end + + context 'and the token group is not ancestor of this group' do + let(:child2_group) { create(:group, parent_id: group.id) } + + it 'is false' do + deploy_token.groups = [child2_group] + + is_expected.to be_falsey + end + end + end + + context 'and the passed-in project does not belong to the token group' do + it { is_expected.to be_falsy } + end + + context 'and the project belongs to a group that is parent of the token group' do + let(:super_group) { create(:group) } + let(:deploy_token) { create(:deploy_token, :group) } + let(:group) { create(:group, parent_id: super_group.id) } + + it 'is false' do + super_group.projects << project + + is_expected.to be_falsey + end + end + end + end + + context 'and the token is of project type' do + let(:deploy_token) { create(:deploy_token, projects: [project]) } + + context 'and the passed-in project is the same as the token project' do + it { is_expected.to be_truthy } + end + + context 'and the passed-in project is not the same as the token project' do + subject { deploy_token.has_access_to?(create(:project)) } + + it { is_expected.to be_falsey } + end + end end end @@ -183,7 +323,7 @@ describe DeployToken do end end - context 'when passign a value' do + context 'when passing a value' do let(:expires_at) { Date.today + 5.months } let(:deploy_token) { create(:deploy_token, expires_at: expires_at) } diff --git a/spec/models/group_deploy_token_spec.rb b/spec/models/group_deploy_token_spec.rb new file mode 100644 index 00000000000..d38abafa7ed --- /dev/null +++ b/spec/models/group_deploy_token_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GroupDeployToken, type: :model do + let(:group) { create(:group) } + let(:deploy_token) { create(:deploy_token) } + + subject(:group_deploy_token) { create(:group_deploy_token, group: group, deploy_token: deploy_token) } + + it { is_expected.to belong_to :group } + it { is_expected.to belong_to :deploy_token } + + it { is_expected.to validate_presence_of :deploy_token } + it { is_expected.to validate_presence_of :group } + it { is_expected.to validate_uniqueness_of(:deploy_token_id).scoped_to(:group_id) } +end diff --git a/spec/models/user_preference_spec.rb b/spec/models/user_preference_spec.rb index bb88983e140..7884b87cc26 100644 --- a/spec/models/user_preference_spec.rb +++ b/spec/models/user_preference_spec.rb @@ -85,4 +85,19 @@ describe UserPreference do expect(user_preference.timezone).to eq(Time.zone.tzinfo.name) end end + + describe '#tab_width' do + it 'is set to 8 by default' do + # Intentionally not using factory here to test the constructor. + pref = UserPreference.new + expect(pref.tab_width).to eq(8) + end + + it do + is_expected.to validate_numericality_of(:tab_width) + .only_integer + .is_greater_than_or_equal_to(1) + .is_less_than_or_equal_to(12) + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 74e38e79616..855b8e3a8a7 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -20,6 +20,9 @@ describe User, :do_not_mock_admin_mode do describe 'delegations' do it { is_expected.to delegate_method(:path).to(:namespace).with_prefix } + + it { is_expected.to delegate_method(:tab_width).to(:user_preference) } + it { is_expected.to delegate_method(:tab_width=).to(:user_preference).with_arguments(5) } end describe 'associations' do @@ -4126,4 +4129,41 @@ describe User, :do_not_mock_admin_mode do end end end + + describe 'internal methods' do + let_it_be(:user) { create(:user) } + let!(:ghost) { described_class.ghost } + let!(:alert_bot) { described_class.alert_bot } + let!(:non_internal) { [user] } + let!(:internal) { [ghost, alert_bot] } + + it 'returns non internal users' do + expect(described_class.internal).to eq(internal) + expect(internal.all?(&:internal?)).to eq(true) + end + + it 'returns internal users' do + expect(described_class.non_internal).to eq(non_internal) + expect(non_internal.all?(&:internal?)).to eq(false) + end + + describe '#bot?' do + it 'marks bot users' do + expect(user.bot?).to eq(false) + expect(ghost.bot?).to eq(false) + + expect(alert_bot.bot?).to eq(true) + end + end + end + + describe 'bots & humans' do + it 'returns corresponding users' do + human = create(:user) + bot = create(:user, :bot) + + expect(described_class.humans).to match_array([human]) + expect(described_class.bots).to match_array([bot]) + end + end end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index 1a4b8315fde..3b08726c75a 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -559,4 +559,18 @@ describe ProjectPolicy do end end end + + context 'alert bot' do + let(:current_user) { User.alert_bot } + + subject { described_class.new(current_user, project) } + + it { is_expected.to be_allowed(:reporter_access) } + + context 'within a private project' do + let(:project) { create(:project, :private) } + + it { is_expected.to be_allowed(:admin_issue) } + end + end end |