summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/boards/models/list.js2
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue58
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue52
-rw-r--r--app/controllers/concerns/sessionless_authentication.rb4
-rw-r--r--app/controllers/concerns/static_object_external_storage.rb24
-rw-r--r--app/controllers/profiles_controller.rb9
-rw-r--r--app/controllers/projects/repositories_controller.rb4
-rw-r--r--app/helpers/application_helper.rb19
-rw-r--r--app/helpers/application_settings_helper.rb2
-rw-r--r--app/models/application_setting.rb8
-rw-r--r--app/models/application_setting_implementation.rb4
-rw-r--r--app/models/ci/pipeline.rb8
-rw-r--r--app/models/user.rb11
-rw-r--r--app/views/admin/application_settings/_repository_static_objects.html.haml18
-rw-r--r--app/views/admin/application_settings/repository.html.haml11
-rw-r--r--app/views/profiles/keys/_key_table.html.haml2
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml20
-rw-r--r--app/views/projects/buttons/_download_links.html.haml3
-rw-r--r--app/views/projects/wikis/show.html.haml2
-rw-r--r--app/views/shared/snippets/_header.html.haml2
-rw-r--r--changelogs/unreleased/62122-hide-resolve-thread-button-from-guest.yml5
-rw-r--r--changelogs/unreleased/67248-snippet-title-whitespace.yml5
-rw-r--r--changelogs/unreleased/add-source-and-merge_request-to-pipeline-webhook.yml5
-rw-r--r--changelogs/unreleased/ce-indicator-for-pipeline-for-merge-train.yml5
-rw-r--r--changelogs/unreleased/static-objects-external-storage.yml5
-rw-r--r--config/gitlab.yml.example4
-rw-r--r--config/routes/profile.rb1
-rw-r--r--db/migrate/20190722104947_add_static_object_token_to_users.rb18
-rw-r--r--db/migrate/20190722132830_add_static_objects_external_storage_columns_to_application_settings.rb14
-rw-r--r--db/migrate/20190725183432_add_index_to_index_on_static_object_token.rb21
-rw-r--r--db/schema.rb4
-rw-r--r--doc/administration/auth/smartcard.md69
-rw-r--r--doc/administration/geo/replication/high_availability.md8
-rw-r--r--doc/administration/index.md1
-rw-r--r--doc/administration/static_objects_external_storage.md50
-rw-r--r--doc/ci/interactive_web_terminal/index.md5
-rw-r--r--doc/development/documentation/feature-change-workflow.md9
-rw-r--r--doc/development/new_fe_guide/style/prettier.md35
-rw-r--r--doc/user/analytics/cycle_analytics.md8
-rw-r--r--doc/user/analytics/index.md13
-rw-r--r--doc/user/permissions.md10
-rw-r--r--doc/user/project/integrations/github.md9
-rw-r--r--doc/user/project/integrations/webhooks.md13
-rw-r--r--doc/user/project/members/index.md20
-rw-r--r--doc/user/project/protected_branches.md5
-rw-r--r--doc/workflow/gitlab_flow.md3
-rw-r--r--lib/gitlab/auth/request_authenticator.rb4
-rw-r--r--lib/gitlab/auth/user_auth_finders.rb22
-rw-r--r--lib/gitlab/data_builder/pipeline.rb17
-rw-r--r--locale/gitlab.pot46
-rw-r--r--qa/qa.rb2
-rw-r--r--qa/qa/page/profile/ssh_keys.rb8
-rw-r--r--qa/qa/page/project/wiki/show.rb10
-rw-r--r--qa/qa/resource/protected_branch.rb (renamed from qa/qa/resource/branch.rb)41
-rw-r--r--qa/qa/resource/repository/project_push.rb1
-rw-r--r--qa/qa/resource/repository/wiki_push.rb11
-rw-r--r--qa/qa/runtime/api/client.rb9
-rw-r--r--qa/qa/specs/features/api/3_create/repository/project_archive_compare_spec.rb3
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/repository/push_protected_branch_spec.rb3
-rw-r--r--qa/qa/specs/features/browser_ui/4_verify/pipeline/create_and_process_pipeline_spec.rb2
-rw-r--r--spec/controllers/concerns/static_object_external_storage_spec.rb96
-rw-r--r--spec/controllers/projects/repositories_controller_spec.rb54
-rw-r--r--spec/features/merge_request/user_sees_merge_widget_spec.rb46
-rw-r--r--spec/features/projects/branches/download_buttons_spec.rb5
-rw-r--r--spec/features/projects/files/download_buttons_spec.rb8
-rw-r--r--spec/features/projects/show/download_buttons_spec.rb2
-rw-r--r--spec/features/projects/tags/download_buttons_spec.rb5
-rw-r--r--spec/frontend/diffs/components/diff_file_header_spec.js472
-rw-r--r--spec/frontend/notes/components/discussion_actions_spec.js9
-rw-r--r--spec/helpers/application_helper_spec.rb37
-rw-r--r--spec/javascripts/diffs/components/diff_file_header_spec.js713
-rw-r--r--spec/javascripts/diffs/store/actions_spec.js2
-rw-r--r--spec/javascripts/notes/components/noteable_discussion_spec.js4
-rw-r--r--spec/javascripts/notes/mock_data.js3
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js86
-rw-r--r--spec/javascripts/vue_mr_widget/mock_data.js4
-rw-r--r--spec/lib/gitlab/auth/user_auth_finders_spec.rb54
-rw-r--r--spec/lib/gitlab/data_builder/pipeline_spec.rb18
-rw-r--r--spec/models/application_setting_spec.rb14
-rw-r--r--spec/models/user_spec.rb10
-rw-r--r--spec/support/shared_examples/features/archive_download_buttons_shared_examples.rb59
82 files changed, 1510 insertions, 987 deletions
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 7e0ccb9bd2a..080841ee82b 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -1,9 +1,9 @@
/* eslint-disable no-underscore-dangle, class-methods-use-this, consistent-return, no-shadow, no-param-reassign */
-/* global ListIssue */
import { __ } from '~/locale';
import ListLabel from './label';
import ListAssignee from './assignee';
+import ListIssue from './issue';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import boardsStore from '../stores/boards_store';
import ListMilestone from './milestone';
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 69ec6ab8600..bfcc726a030 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -57,26 +57,12 @@ export default {
required: true,
},
},
- data() {
- return {
- blobForkSuggestion: null,
- };
- },
computed: {
...mapGetters('diffs', ['diffHasExpandedDiscussions', 'diffHasDiscussions']),
- hasExpandedDiscussions() {
- return this.diffHasExpandedDiscussions(this.diffFile);
- },
diffContentIDSelector() {
return `#diff-content-${this.diffFile.file_hash}`;
},
- icon() {
- if (this.diffFile.submodule) {
- return 'archive';
- }
- return this.diffFile.blob.icon;
- },
titleLink() {
if (this.diffFile.submodule) {
return this.diffFile.submodule_tree_url || this.diffFile.submodule_link;
@@ -99,9 +85,6 @@ export default {
return this.diffFile.file_path;
},
- titleTag() {
- return this.diffFile.file_hash ? 'a' : 'span';
- },
isUsingLfs() {
return this.diffFile.stored_externally && this.diffFile.external_storage === 'lfs';
},
@@ -135,9 +118,6 @@ export default {
isModeChanged() {
return this.diffFile.viewer.name === diffViewerModes.mode_changed;
},
- showExpandDiffToFullFileEnabled() {
- return gon.features.expandDiffFullFile && !this.diffFile.is_fully_expanded;
- },
expandDiffToFullFileTitle() {
if (this.diffFile.isShowingFullFile) {
return s__('MRDiff|Show changes only');
@@ -156,21 +136,12 @@ export default {
'toggleFileDiscussionWrappers',
'toggleFullDiff',
]),
- handleToggleFile(e, checkTarget) {
- if (
- !checkTarget ||
- e.target === this.$refs.header ||
- (e.target.classList && e.target.classList.contains('diff-toggle-caret'))
- ) {
- this.$emit('toggleFile');
- }
+ handleToggleFile() {
+ this.$emit('toggleFile');
},
showForkMessage() {
this.$emit('showForkMessage');
},
- handleToggleDiscussions() {
- this.toggleFileDiscussionWrappers(this.diffFile);
- },
handleFileNameClick(e) {
const isLinkToOtherPage =
this.diffFile.submodule_tree_url || this.diffFile.submodule_link || this.discussionPath;
@@ -178,7 +149,6 @@ export default {
if (!isLinkToOtherPage) {
e.preventDefault();
const selector = this.diffContentIDSelector;
-
scrollToElement(document.querySelector(selector));
window.location.hash = selector;
}
@@ -191,22 +161,23 @@ export default {
<div
ref="header"
class="js-file-title file-title file-title-flex-parent"
- @click="handleToggleFile($event, true)"
+ @click.self="handleToggleFile"
>
<div class="file-header-content">
<icon
v-if="collapsible"
+ ref="collapseIcon"
:name="collapseIcon"
:size="16"
aria-hidden="true"
class="diff-toggle-caret append-right-5"
- @click.stop="handleToggle"
+ @click.stop="handleToggleFile"
/>
<a
v-once
id="diffFile.file_path"
ref="titleWrapper"
- class="append-right-4 js-title-wrapper"
+ class="append-right-4"
:href="titleLink"
@click="handleFileNameClick"
>
@@ -214,7 +185,7 @@ export default {
:file-name="filePath"
:size="18"
aria-hidden="true"
- css-classes="js-file-icon append-right-5"
+ css-classes="append-right-5"
/>
<span v-if="isFileRenamed">
<strong
@@ -260,12 +231,13 @@ export default {
<template v-if="diffFile.blob && diffFile.blob.readable_text">
<span v-gl-tooltip.hover :title="s__('MergeRequests|Toggle comments for this file')">
<gl-button
+ ref="toggleDiscussionsButton"
:disabled="!diffHasDiscussions(diffFile)"
- :class="{ active: hasExpandedDiscussions }"
+ :class="{ active: diffHasExpandedDiscussions(diffFile) }"
class="js-btn-vue-toggle-comments btn"
data-qa-selector="toggle_comments_button"
type="button"
- @click="handleToggleDiscussions"
+ @click="toggleFileDiscussionWrappers(diffFile)"
>
<icon name="comment" />
</gl-button>
@@ -282,8 +254,9 @@ export default {
<a
v-if="diffFile.replaced_view_path"
+ ref="replacedFileButton"
:href="diffFile.replaced_view_path"
- class="btn view-file js-view-replaced-file"
+ class="btn view-file"
v-html="viewReplacedFileButtonText"
>
</a>
@@ -292,7 +265,7 @@ export default {
ref="expandDiffToFullFileButton"
v-gl-tooltip.hover
:title="expandDiffToFullFileTitle"
- class="expand-file js-expand-file"
+ class="expand-file"
@click="toggleFullDiff(diffFile.file_path)"
>
<gl-loading-icon v-if="diffFile.isLoadingFullFile" color="dark" inline />
@@ -304,7 +277,7 @@ export default {
v-gl-tooltip.hover
:href="diffFile.view_path"
target="blank"
- class="view-file js-view-file-button"
+ class="view-file"
:title="viewFileButtonText"
>
<icon name="doc-text" />
@@ -312,12 +285,13 @@ export default {
<a
v-if="diffFile.external_url"
+ ref="externalLink"
v-gl-tooltip.hover
:href="diffFile.external_url"
:title="`View on ${diffFile.formatted_external_url}`"
target="_blank"
rel="noopener noreferrer"
- class="btn btn-file-option js-external-url"
+ class="btn btn-file-option"
>
<icon name="external-link" />
</a>
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index edab750b572..e3be91a4966 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -35,6 +35,13 @@ export default {
required: true,
},
},
+ computed: {
+ userCanResolveDiscussion() {
+ return this.discussion.notes.every(
+ note => note.current_user && note.current_user.can_resolve,
+ );
+ },
+ },
};
</script>
@@ -46,7 +53,7 @@ export default {
@onClick="$emit('showReplyForm')"
/>
- <div class="btn-group discussion-actions" role="group">
+ <div v-if="userCanResolveDiscussion" class="btn-group discussion-actions" role="group">
<div class="btn-group">
<resolve-discussion-button
v-if="discussion.resolvable"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 40c095aa954..4b5201bbca7 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -1,7 +1,7 @@
<script>
/* eslint-disable vue/require-default-prop */
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
-import { sprintf, __ } from '~/locale';
+import { sprintf, s__ } from '~/locale';
import PipelineStage from '~/pipelines/components/stage.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import Icon from '~/vue_shared/components/icon.vue';
@@ -73,8 +73,8 @@ export default {
},
errorText() {
return sprintf(
- __(
- 'Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation.%{linkEnd}',
+ s__(
+ 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation.%{linkEnd}',
),
{
linkStart: `<a href="${this.troubleshootingDocsPath}">`,
@@ -89,6 +89,9 @@ export default {
isMergeRequestPipeline() {
return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline);
},
+ showSourceBranch() {
+ return Boolean(this.pipeline.ref.branch);
+ },
},
};
</script>
@@ -109,7 +112,7 @@ export default {
<div class="ci-widget-content">
<div class="media-body">
<div class="font-weight-bold js-pipeline-info-container">
- {{ s__('Pipeline|Pipeline') }}
+ {{ pipeline.details.name }}
<gl-link :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number"
>#{{ pipeline.id }}</gl-link
>
@@ -121,48 +124,13 @@ export default {
class="commit-sha js-commit-link font-weight-normal"
>{{ pipeline.commit.short_id }}</gl-link
>
+ </template>
+ <template v-if="showSourceBranch">
{{ s__('Pipeline|on') }}
- <template v-if="isTriggeredByMergeRequest">
- <gl-link
- v-gl-tooltip
- :href="pipeline.merge_request.path"
- :title="pipeline.merge_request.title"
- class="font-weight-normal"
- >!{{ pipeline.merge_request.iid }}</gl-link
- >
- {{ s__('Pipeline|with') }}
- <tooltip-on-truncate
- :title="pipeline.merge_request.source_branch"
- truncate-target="child"
- class="label-branch label-truncate"
- >
- <gl-link
- :href="pipeline.merge_request.source_branch_path"
- class="font-weight-normal"
- >{{ pipeline.merge_request.source_branch }}</gl-link
- >
- </tooltip-on-truncate>
-
- <template v-if="isMergeRequestPipeline">
- {{ s__('Pipeline|into') }}
- <tooltip-on-truncate
- :title="pipeline.merge_request.target_branch"
- truncate-target="child"
- class="label-branch label-truncate"
- >
- <gl-link
- :href="pipeline.merge_request.target_branch_path"
- class="font-weight-normal"
- >{{ pipeline.merge_request.target_branch }}</gl-link
- >
- </tooltip-on-truncate>
- </template>
- </template>
<tooltip-on-truncate
- v-else
:title="sourceBranch"
truncate-target="child"
- class="label-branch label-truncate"
+ class="label-branch label-truncate font-weight-normal"
v-html="sourceBranchLink"
/>
</template>
diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb
index 4304b8565ce..ba06384a37a 100644
--- a/app/controllers/concerns/sessionless_authentication.rb
+++ b/app/controllers/concerns/sessionless_authentication.rb
@@ -2,10 +2,10 @@
# == SessionlessAuthentication
#
-# Controller concern to handle PAT and RSS token authentication methods
+# Controller concern to handle PAT, RSS, and static objects token authentication methods
#
module SessionlessAuthentication
- # This filter handles personal access tokens, and atom requests with rss tokens
+ # This filter handles personal access tokens, atom requests with rss tokens, and static object tokens
def authenticate_sessionless_user!(request_format)
user = Gitlab::Auth::RequestAuthenticator.new(request).find_sessionless_user(request_format)
diff --git a/app/controllers/concerns/static_object_external_storage.rb b/app/controllers/concerns/static_object_external_storage.rb
new file mode 100644
index 00000000000..dbfe0ed3adf
--- /dev/null
+++ b/app/controllers/concerns/static_object_external_storage.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module StaticObjectExternalStorage
+ extend ActiveSupport::Concern
+
+ included do
+ include ApplicationHelper
+ end
+
+ def redirect_to_external_storage
+ return if external_storage_request?
+
+ redirect_to external_storage_url_or_path(request.fullpath, project)
+ end
+
+ def external_storage_request?
+ header_token = request.headers['X-Gitlab-External-Storage-Token']
+ return false unless header_token.present?
+
+ external_storage_token = Gitlab::CurrentSettings.static_objects_external_storage_auth_token
+ ActiveSupport::SecurityUtils.secure_compare(header_token, external_storage_token) ||
+ raise(Gitlab::Access::AccessDeniedError)
+ end
+end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 1d16ddb1608..958a24b6c0e 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -46,6 +46,15 @@ class ProfilesController < Profiles::ApplicationController
redirect_to profile_personal_access_tokens_path
end
+ def reset_static_object_token
+ Users::UpdateService.new(current_user, user: @user).execute! do |user|
+ user.reset_static_object_token!
+ end
+
+ redirect_to profile_personal_access_tokens_path,
+ notice: s_('Profiles|Static object token was successfully reset')
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def audit_log
@events = AuditEvent.where(entity_type: "User", entity_id: current_user.id)
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index a51759641e4..d69f9e65874 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -2,6 +2,9 @@
class Projects::RepositoriesController < Projects::ApplicationController
include ExtractsPath
+ include StaticObjectExternalStorage
+
+ prepend_before_action(only: [:archive]) { authenticate_sessionless_user!(:archive) }
# Authorize
before_action :require_non_empty_project, except: :create
@@ -9,6 +12,7 @@ class Projects::RepositoriesController < Projects::ApplicationController
before_action :assign_append_sha, only: :archive
before_action :authorize_download_code!
before_action :authorize_admin_project!, only: :create
+ before_action :redirect_to_external_storage, only: :archive, if: :static_objects_external_storage_enabled?
def create
@project.create_repository
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index ffa5719fefb..1671aa5bd04 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -169,6 +169,25 @@ module ApplicationHelper
Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/'
end
+ def static_objects_external_storage_enabled?
+ Gitlab::CurrentSettings.static_objects_external_storage_enabled?
+ end
+
+ def external_storage_url_or_path(path, project = @project)
+ return path unless static_objects_external_storage_enabled?
+
+ uri = URI(Gitlab::CurrentSettings.static_objects_external_storage_url)
+ path = URI(path) # `path` could have query parameters, so we need to split query and path apart
+
+ query = Rack::Utils.parse_nested_query(path.query)
+ query['token'] = current_user.static_object_token unless project.public?
+
+ uri.path = path.path
+ uri.query = query.to_query unless query.empty?
+
+ uri.to_s
+ end
+
def page_filter_path(options = {})
without = options.delete(:without)
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index b1a6e988a1d..93e282e44be 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -168,6 +168,8 @@ module ApplicationSettingsHelper
:asset_proxy_secret_key,
:asset_proxy_url,
:asset_proxy_whitelist,
+ :static_objects_external_storage_auth_token,
+ :static_objects_external_storage_url,
:authorized_keys_enabled,
:auto_devops_enabled,
:auto_devops_domain,
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index a2cf081375e..c9cd0140ed8 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -8,6 +8,7 @@ class ApplicationSetting < ApplicationRecord
add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
add_authentication_token_field :health_check_access_token
+ add_authentication_token_field :static_objects_external_storage_auth_token
belongs_to :instance_administration_project, class_name: "Project"
@@ -202,6 +203,13 @@ class ApplicationSetting < ApplicationRecord
allow_blank: false,
if: :asset_proxy_enabled?
+ validates :static_objects_external_storage_url,
+ addressable_url: true, allow_blank: true
+
+ validates :static_objects_external_storage_auth_token,
+ presence: true,
+ if: :static_objects_external_storage_url?
+
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index f402c0e2775..8d9597aa5a4 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -306,6 +306,10 @@ module ApplicationSettingImplementation
archive_builds_in_seconds.seconds.ago if archive_builds_in_seconds
end
+ def static_objects_external_storage_enabled?
+ static_objects_external_storage_url.present?
+ end
+
private
def array_to_string(arr)
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index d2271c1335c..4aaabed6b7b 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -835,12 +835,12 @@ module Ci
return unless merge_request_event?
strong_memoize(:merge_request_event_type) do
- if detached_merge_request_pipeline?
- :detached
+ if merge_train_pipeline?
+ :merge_train
elsif merge_request_pipeline?
:merged_result
- elsif merge_train_pipeline?
- :merge_train
+ elsif detached_merge_request_pipeline?
+ :detached
end
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 9ca01715578..48acdfeb2ed 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -25,6 +25,7 @@ class User < ApplicationRecord
add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) }
add_authentication_token_field :feed_token
+ add_authentication_token_field :static_object_token
default_value_for :admin, false
default_value_for(:external) { Gitlab::CurrentSettings.user_default_external }
@@ -55,6 +56,9 @@ class User < ApplicationRecord
BLOCKED_MESSAGE = "Your account has been blocked. Please contact your GitLab " \
"administrator if you think this is an error."
+ # Removed in GitLab 12.3. Keep until after 2019-09-22.
+ self.ignored_columns += %i[support_bot]
+
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour
# rubocop: disable CodeReuse/ServiceClass
@@ -1431,6 +1435,13 @@ class User < ApplicationRecord
ensure_feed_token!
end
+ # Each existing user needs to have a `static_object_token`.
+ # We do this on read since migrating all existing users is not a feasible
+ # solution.
+ def static_object_token
+ ensure_static_object_token!
+ end
+
def sync_attribute?(attribute)
return true if ldap_user? && attribute == :email
diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml
new file mode 100644
index 00000000000..03aa48b2282
--- /dev/null
+++ b/app/views/admin/application_settings/_repository_static_objects.html.haml
@@ -0,0 +1,18 @@
+= form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-repository-static-objects-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :static_objects_external_storage_url, class: 'label-bold' do
+ = _('External storage URL')
+ = f.text_field :static_objects_external_storage_url, class: 'form-control'
+ %span.form-text.text-muted#static_objects_external_storage_url_help_block
+ = _('URL of the external storage that will serve the repository static objects (e.g. archives, blobs, ...).')
+ .form-group
+ = f.label :static_objects_external_storage_auth_token, class: 'label-bold' do
+ = _('External storage authentication token')
+ = f.text_field :static_objects_external_storage_auth_token, class: 'form-control'
+ %span.form-text.text-muted#static_objects_external_storage_auth_token_help_block
+ = _('A secure token that identifies an external storage request.')
+
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml
index b50a0dd5a18..25f8b6541b5 100644
--- a/app/views/admin/application_settings/repository.html.haml
+++ b/app/views/admin/application_settings/repository.html.haml
@@ -34,3 +34,14 @@
= _('Configure automatic git checks and housekeeping on repositories.')
.settings-content
= render 'repository_check'
+
+%section.settings.as-repository-static-objects.no-animate#js-repository-static-objects-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = _('Repository static objects')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Serve repository static objects (e.g. archives, blobs, ...) from an external storage (e.g. a CDN).')
+ .settings-content
+ = render 'repository_static_objects'
diff --git a/app/views/profiles/keys/_key_table.html.haml b/app/views/profiles/keys/_key_table.html.haml
index 4a6d8a1870d..8b862522645 100644
--- a/app/views/profiles/keys/_key_table.html.haml
+++ b/app/views/profiles/keys/_key_table.html.haml
@@ -1,7 +1,7 @@
- is_admin = local_assigns.fetch(:admin, false)
- if @keys.any?
- %ul.content-list
+ %ul.content-list{ data: { qa_selector: 'ssh_keys_list' } }
= render partial: 'profiles/keys/key', collection: @keys, locals: { is_admin: is_admin }
- else
%p.settings-message.text-center
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 08a39fc4f58..d9e94908b80 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -54,3 +54,23 @@
- reset_link = link_to s_('AccessTokens|reset it'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.') }
- reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can create issues as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link }
= reset_message.html_safe
+
+- if static_objects_external_storage_enabled?
+ %hr
+ .row.prepend-top-default
+ .col-lg-4
+ %h4.prepend-top-0
+ = s_('AccessTokens|Static object token')
+ %p
+ = s_('AccessTokens|Your static object token is used to authenticate you when repository static objects (e.g. archives, blobs, ...) are being served from an external storage.')
+ %p
+ = s_('AccessTokens|It cannot be used to access any other data.')
+ .col-lg-8
+ = label_tag :static_object_token, s_('AccessTokens|Static object token'), class: "label-bold"
+ = text_field_tag :static_object_token, current_user.static_object_token, class: 'form-control', readonly: true, onclick: 'this.select()'
+ %p.form-text.text-muted
+ - reset_link = url_for [:reset, :static_object_token, :profile]
+ - reset_link_start = '<a data-confirm="%{confirm}" rel="nofollow" data-method="put" href="%{url}">'.html_safe % { confirm: s_('AccessTokens|Are you sure?'), url: reset_link }
+ - reset_link_end = '</a>'.html_safe
+ - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can access repository static objects as if they were you. You should %{reset_link_start}reset it%{reset_link_end} if that ever happens.') % { reset_link_start: reset_link_start, reset_link_end: reset_link_end }
+ = reset_message.html_safe
diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml
index d344167a6c5..b256d94065b 100644
--- a/app/views/projects/buttons/_download_links.html.haml
+++ b/app/views/projects/buttons/_download_links.html.haml
@@ -2,4 +2,5 @@
.btn-group.ml-0.w-100
- formats.each do |(fmt, extra_class)|
- = link_to fmt, project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt), rel: 'nofollow', download: '', class: "btn btn-xs #{extra_class}"
+ - archive_path = project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt)
+ = link_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', class: "btn btn-xs #{extra_class}"
diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml
index 51b7f2dd4b4..ebd99cf8605 100644
--- a/app/views/projects/wikis/show.html.haml
+++ b/app/views/projects/wikis/show.html.haml
@@ -26,7 +26,7 @@
= (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe
.prepend-top-default.append-bottom-default
- .md.md-file
+ .md.md-file{ data: { qa_selector: 'wiki_page_content' } }
= render_wiki_content(@page)
= render 'sidebar'
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index ebb634fe75f..1a9ae68f53d 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -17,7 +17,7 @@
= render "snippets/actions"
.snippet-header.limited-header-width
- %h2.snippet-title.prepend-top-0.append-bottom-0.qa-snippet-title
+ %h2.snippet-title.prepend-top-0.mb-3.qa-snippet-title
= markdown_field(@snippet, :title)
- if @snippet.description.present?
diff --git a/changelogs/unreleased/62122-hide-resolve-thread-button-from-guest.yml b/changelogs/unreleased/62122-hide-resolve-thread-button-from-guest.yml
new file mode 100644
index 00000000000..0576b15b3aa
--- /dev/null
+++ b/changelogs/unreleased/62122-hide-resolve-thread-button-from-guest.yml
@@ -0,0 +1,5 @@
+---
+title: Hide resolve thread button from guest
+merge_request: 32859
+author:
+type: changed
diff --git a/changelogs/unreleased/67248-snippet-title-whitespace.yml b/changelogs/unreleased/67248-snippet-title-whitespace.yml
new file mode 100644
index 00000000000..b6460faac4d
--- /dev/null
+++ b/changelogs/unreleased/67248-snippet-title-whitespace.yml
@@ -0,0 +1,5 @@
+---
+title: Add bottom margin to snippet title
+merge_request: 32877
+author:
+type: fixed
diff --git a/changelogs/unreleased/add-source-and-merge_request-to-pipeline-webhook.yml b/changelogs/unreleased/add-source-and-merge_request-to-pipeline-webhook.yml
new file mode 100644
index 00000000000..e7300335427
--- /dev/null
+++ b/changelogs/unreleased/add-source-and-merge_request-to-pipeline-webhook.yml
@@ -0,0 +1,5 @@
+---
+title: Add source and merge_request fields to pipeline event webhook
+merge_request: 32373
+author: Bian Jiaping
+type: added
diff --git a/changelogs/unreleased/ce-indicator-for-pipeline-for-merge-train.yml b/changelogs/unreleased/ce-indicator-for-pipeline-for-merge-train.yml
new file mode 100644
index 00000000000..06d2f4b56f5
--- /dev/null
+++ b/changelogs/unreleased/ce-indicator-for-pipeline-for-merge-train.yml
@@ -0,0 +1,5 @@
+---
+title: Make MR pipeline widget text more descriptive
+merge_request: 32025
+author:
+type: changed
diff --git a/changelogs/unreleased/static-objects-external-storage.yml b/changelogs/unreleased/static-objects-external-storage.yml
new file mode 100644
index 00000000000..fd687b2262c
--- /dev/null
+++ b/changelogs/unreleased/static-objects-external-storage.yml
@@ -0,0 +1,5 @@
+---
+title: Enable serving static objects from an external storage
+merge_request: 31025
+author:
+type: added
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index 87159b695f9..92674aafa90 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -718,6 +718,10 @@ production: &base
# Browser session with smartcard sign-in is required for Git access
# required_for_git_access: false
+ # Use X.509 SAN extensions certificates to identify GitLab users
+ # Add a subjectAltName to your certificates like: email:user
+ # san_extensions: true
+
## Kerberos settings
kerberos:
# Allow the HTTP Negotiate authentication method for Git clients
diff --git a/config/routes/profile.rb b/config/routes/profile.rb
index 83a2b33514b..403f430850e 100644
--- a/config/routes/profile.rb
+++ b/config/routes/profile.rb
@@ -8,6 +8,7 @@ resource :profile, only: [:show, :update] do
put :reset_incoming_email_token
put :reset_feed_token
+ put :reset_static_object_token
put :update_username
end
diff --git a/db/migrate/20190722104947_add_static_object_token_to_users.rb b/db/migrate/20190722104947_add_static_object_token_to_users.rb
new file mode 100644
index 00000000000..6ef85d9acaa
--- /dev/null
+++ b/db/migrate/20190722104947_add_static_object_token_to_users.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddStaticObjectTokenToUsers < ActiveRecord::Migration[5.2]
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column :users, :static_object_token, :string, limit: 255
+ end
+
+ def down
+ remove_column :users, :static_object_token
+ end
+end
diff --git a/db/migrate/20190722132830_add_static_objects_external_storage_columns_to_application_settings.rb b/db/migrate/20190722132830_add_static_objects_external_storage_columns_to_application_settings.rb
new file mode 100644
index 00000000000..a23e6ed66cd
--- /dev/null
+++ b/db/migrate/20190722132830_add_static_objects_external_storage_columns_to_application_settings.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddStaticObjectsExternalStorageColumnsToApplicationSettings < ActiveRecord::Migration[5.2]
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ def change
+ add_column :application_settings, :static_objects_external_storage_url, :string, limit: 255
+ add_column :application_settings, :static_objects_external_storage_auth_token, :string, limit: 255
+ end
+end
diff --git a/db/migrate/20190725183432_add_index_to_index_on_static_object_token.rb b/db/migrate/20190725183432_add_index_to_index_on_static_object_token.rb
new file mode 100644
index 00000000000..423c45b9543
--- /dev/null
+++ b/db/migrate/20190725183432_add_index_to_index_on_static_object_token.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddIndexToIndexOnStaticObjectToken < ActiveRecord::Migration[5.2]
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :users, :static_object_token, unique: true
+ end
+
+ def down
+ remove_concurrent_index :users, :static_object_token
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 14ce50b0619..3906976d296 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -284,6 +284,8 @@ ActiveRecord::Schema.define(version: 2019_09_10_000130) do
t.text "asset_proxy_whitelist"
t.text "encrypted_asset_proxy_secret_key"
t.string "encrypted_asset_proxy_secret_key_iv"
+ t.string "static_objects_external_storage_url", limit: 255
+ t.string "static_objects_external_storage_auth_token", limit: 255
t.index ["custom_project_templates_group_id"], name: "index_application_settings_on_custom_project_templates_group_id"
t.index ["file_template_project_id"], name: "index_application_settings_on_file_template_project_id"
t.index ["instance_administration_project_id"], name: "index_applicationsettings_on_instance_administration_project_id"
@@ -3566,6 +3568,7 @@ ActiveRecord::Schema.define(version: 2019_09_10_000130) do
t.integer "bot_type", limit: 2
t.string "first_name", limit: 255
t.string "last_name", limit: 255
+ t.string "static_object_token", limit: 255
t.index ["accepted_term_id"], name: "index_users_on_accepted_term_id"
t.index ["admin"], name: "index_users_on_admin"
t.index ["bot_type"], name: "index_users_on_bot_type"
@@ -3585,6 +3588,7 @@ ActiveRecord::Schema.define(version: 2019_09_10_000130) do
t.index ["state"], name: "index_users_on_state"
t.index ["state"], name: "index_users_on_state_and_internal", where: "(ghost IS NOT TRUE)"
t.index ["state"], name: "index_users_on_state_and_internal_ee", where: "((ghost IS NOT TRUE) AND (bot_type IS NULL))"
+ t.index ["static_object_token"], name: "index_users_on_static_object_token", unique: true
t.index ["unconfirmed_email"], name: "index_users_on_unconfirmed_email", where: "(unconfirmed_email IS NOT NULL)"
t.index ["username"], name: "index_users_on_username"
t.index ["username"], name: "index_users_on_username_trigram", opclass: :gin_trgm_ops, using: :gin
diff --git a/doc/administration/auth/smartcard.md b/doc/administration/auth/smartcard.md
index 4f236d1afb8..920a2f0b399 100644
--- a/doc/administration/auth/smartcard.md
+++ b/doc/administration/auth/smartcard.md
@@ -39,6 +39,45 @@ Certificate:
Subject: CN=Gitlab User, emailAddress=gitlab-user@example.com
```
+### Authentication against a local database with X.509 certificates and SAN extensions **(PREMIUM ONLY)**
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/8605) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.3.
+
+Smartcards with X.509 certificates using SAN extensions can be used to authenticate
+with GitLab.
+
+NOTE: **Note:**
+This is an experimental feature. Smartcard authentication against local databases may
+change or be removed completely in future releases.
+
+To use a smartcard with an X.509 certificate to authenticate against a local
+database with GitLab, at least one of the `subjectAltName` (SAN) extensions
+need to define the user identity (`email`) within the GitLab instance (`URI`).
+
+`URI`: needs to match `Gitlab.config.host.gitlab`.
+
+For example:
+
+```text
+Certificate:
+ Data:
+ Version: 1 (0x0)
+ Serial Number: 12856475246677808609 (0xb26b601ecdd555e1)
+ Signature Algorithm: sha256WithRSAEncryption
+ Issuer: O=Random Corp Ltd, CN=Random Corp
+ Validity
+ Not Before: Oct 30 12:00:00 2018 GMT
+ Not After : Oct 30 12:00:00 2019 GMT
+ ...
+ X509v3 extensions:
+ X509v3 Key Usage:
+ Key Encipherment, Data Encipherment
+ X509v3 Extended Key Usage:
+ TLS Web Server Authentication
+ X509v3 Subject Alternative Name:
+ email:gitlab-user@example.com, URI:http://gitlab.example.com/
+```
+
### Authentication against an LDAP server
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/7693) in
@@ -152,6 +191,36 @@ attribute. As a prerequisite, you must use an LDAP server that:
1. Save the file and [restart](../restart_gitlab.md#installations-from-source)
GitLab for the changes to take effect.
+### Additional steps when using SAN extensions
+
+**For Omnibus installations**
+
+1. Add to `/etc/gitlab/gitlab.rb`:
+
+ ```ruby
+ gitlab_rails['smartcard_san_extensions'] = true
+ ```
+
+1. Save the file and [reconfigure](../restart_gitlab.md#omnibus-gitlab-reconfigure)
+ GitLab for the changes to take effect.
+
+**For installations from source**
+
+1. Add the `san_extensions` line to config/gitlab.yml` within the smartcard section:
+
+ ```yaml
+ smartcard:
+ enabled: true
+ ca_file: '/etc/ssl/certs/CA.pem'
+ client_certificate_required_port: 3444
+
+ # Enable the use of SAN extensions to match users with certificates
+ san_extensions: true
+ ```
+
+1. Save the file and [restart](../restart_gitlab.md#installations-from-source)
+ GitLab for the changes to take effect.
+
### Additional steps when authenticating against an LDAP server
**For Omnibus installations**
diff --git a/doc/administration/geo/replication/high_availability.md b/doc/administration/geo/replication/high_availability.md
index c737fa37077..a0684cfa726 100644
--- a/doc/administration/geo/replication/high_availability.md
+++ b/doc/administration/geo/replication/high_availability.md
@@ -88,16 +88,20 @@ major differences:
Therefore, we will set up the HA components one-by-one, and include deviations
from the normal HA setup.
-### Step 1: Configure the Redis and NFS services on the **secondary** node
+### Step 1: Configure the Redis and Gitaly services on the **secondary** node
Configure the following services, again using the non-Geo high availability
documentation:
- [Configuring Redis for GitLab HA](../../high_availability/redis.md) for high
availability.
-- [NFS](../../high_availability/nfs.md) which will store data that is
+- [Gitaly](../../high_availability/gitaly.md), which will store data that is
synchronized from the **primary** node.
+NOTE: **Note:**
+[NFS](../../high_availability/nfs.md) can be used in place of Gitaly but is not
+recommended.
+
### Step 2: Configure the main read-only replica PostgreSQL database on the **secondary** node
NOTE: **Note:** The following documentation assumes the database will be run on
diff --git a/doc/administration/index.md b/doc/administration/index.md
index b58291b7478..df3501ae950 100644
--- a/doc/administration/index.md
+++ b/doc/administration/index.md
@@ -143,6 +143,7 @@ Learn how to install, configure, update, and maintain your GitLab instance.
- [Repository storage types](repository_storage_types.md): Information about the different repository storage types.
- [Repository storage rake tasks](raketasks/storage.md): A collection of rake tasks to list and migrate existing projects and attachments associated with it from Legacy storage to Hashed storage.
- [Limit repository size](../user/admin_area/settings/account_and_limit_settings.md): Set a hard limit for your repositories' size. **(STARTER ONLY)**
+- [Static objects external storage](static_objects_external_storage.md): Set external storage for static objects in a repository.
## Continuous Integration settings
diff --git a/doc/administration/static_objects_external_storage.md b/doc/administration/static_objects_external_storage.md
new file mode 100644
index 00000000000..e4d60c77199
--- /dev/null
+++ b/doc/administration/static_objects_external_storage.md
@@ -0,0 +1,50 @@
+# Static objects external storage
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/31025) in GitLab 12.3.
+
+GitLab can be configured to serve repository static objects (for example, archives) from an external
+storage, such as a CDN.
+
+## Configuring
+
+To configure external storage for static objects:
+
+1. Navigate to **Admin Area > Settings > Repository**.
+1. Expand the **Repository static objects** section.
+1. Enter the base URL and an arbitrary token.
+
+The token is required to distinguish requests coming from the external storage, so users don't
+circumvent the external storage and go for the application directly. The token is expected to be
+set in the `X-Gitlab-External-Storage-Token` header in requests originating from the external
+storage.
+
+## Serving private static objects
+
+GitLab will append a user-specific token for static object URLs that belong to private projects,
+so an external storage can be authenticated on behalf of the user. When processing requests originating
+from the external storage, GitLab will look for the token in the `token` query parameter or in
+the `X-Gitlab-Static-Object-Token` header to check the user's ability to access the requested object.
+
+## Requests flow example
+
+The following example shows a sequence of requests and responses between the user,
+GitLab, and the CDN:
+
+```mermaid
+sequenceDiagram
+ User->>GitLab: GET /project/-/archive/master.zip
+ GitLab->>User: 302 Found
+ Note over User,GitLab: Location: https://cdn.com/project/-/archive/master.zip?token=secure-user-token
+ User->>CDN: GET /project/-/archive/master.zip?token=secure-user-token
+ alt object not in cache
+ CDN->>GitLab: GET /project/-/archive/master.zip
+ Note over CDN,GitLab: X-Gitlab-External-Storage-Token: secure-cdn-token<br/>X-Gitlab-Static-Object-Token: secure-user-token
+ GitLab->>CDN: 200 OK
+ CDN->>User: master.zip
+ else object in cache
+ CDN->>GitLab: GET /project/-/archive/master.zip
+ Note over CDN,GitLab: X-Gitlab-External-Storage-Token: secure-cdn-token<br/>X-Gitlab-Static-Object-Token: secure-user-token<br/>If-None-Match: etag-value
+ GitLab->>CDN: 304 Not Modified
+ CDN->>User: master.zip
+ end
+```
diff --git a/doc/ci/interactive_web_terminal/index.md b/doc/ci/interactive_web_terminal/index.md
index 58307660e51..49a148cb57d 100644
--- a/doc/ci/interactive_web_terminal/index.md
+++ b/doc/ci/interactive_web_terminal/index.md
@@ -28,6 +28,11 @@ Two things need to be configured for the interactive web terminal to work:
- If you are using a reverse proxy with your GitLab instance, web terminals need to be
[enabled](../../administration/integration/terminal.md#enabling-and-disabling-terminal-support)
+NOTE: **Note:**
+Interactive web terminals are not yet supported by
+[`gitlab-runner` helm chart](https://docs.gitlab.com/charts/charts/gitlab/gitlab-runner/index.html),
+but support [is planned](https://gitlab.com/gitlab-org/charts/gitlab-runner/issues/79).
+
## Debugging a running job
NOTE: **Note:** Not all executors are
diff --git a/doc/development/documentation/feature-change-workflow.md b/doc/development/documentation/feature-change-workflow.md
index 00c76fe0f1b..38ccae41df4 100644
--- a/doc/development/documentation/feature-change-workflow.md
+++ b/doc/development/documentation/feature-change-workflow.md
@@ -92,11 +92,10 @@ do the following:
#### Authoring
-As a developer, you must ship the documentation with the code of the feature that
-you are creating or updating. The documentation is an essential part of the product.
+As a developer, if a ~feature issue also contains the ~Documentation label, you must ship the new or updated documentation with the code of the feature. The documentation is an essential part of the product.
Technical writers are happy to help, as requested and planned on an issue-by-issue basis.
-Follow the process below unless otherwise agreed with the product manager and technical writer for a given issue:
+For feature issues requiring documentation, follow the process below unless otherwise agreed with the product manager and technical writer for a given issue:
- Include any new and edited docs in the MR introducing the code.
- Use the Documentation requirements confirmed by the Product Manager in the
@@ -111,8 +110,8 @@ Follow the process below unless otherwise agreed with the product manager and te
idea or outline, or request any other help, ping the Technical Writer for the relevant
[DevOps stage](https://about.gitlab.com/handbook/product/categories/#devops-stages)
in your issue or MR, or write within `#docs` on the GitLab Slack.
-- The docs must be merged with the code **by the feature freeze date**, otherwise
- the feature cannot be included with the release. A policy for documenting feature-flagged
+- If you are working on documentation in a separate MR, ensure that if the code is merged by the 17th, the docs are as well, per the [Engineering Workflow](https://about.gitlab.com/handbook/engineering/workflow/). If the docs are not ready, the PM can approve merging the code if the engineer and tech writer commit to get documentation merged by the 21st. Otherwise the feature is not considered complete, and should not be merged.
+- A policy for documenting feature-flagged
issues is forthcoming and you are welcome to join the [discussion](https://gitlab.com/gitlab-org/gitlab-ce/issues/56813).
#### Reviews and merging
diff --git a/doc/development/new_fe_guide/style/prettier.md b/doc/development/new_fe_guide/style/prettier.md
index 5f44c640d76..17b209d419e 100644
--- a/doc/development/new_fe_guide/style/prettier.md
+++ b/doc/development/new_fe_guide/style/prettier.md
@@ -61,35 +61,38 @@ This will go over all files in a specific folder and save it.
## VSCode Settings
-### Format on Save
+### Select Prettier as default formatter
-To automatically format your files with Prettier, add the following properties to your User or Workspace Settings:
+To select Prettier as a formatter, add the following properties to your User or Workspace Settings:
```javascript
{
+ "[html]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
"[javascript]": {
- "editor.formatOnSave": true
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[vue]": {
- "editor.formatOnSave": true
- },
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ }
}
```
-### Conflicts with Vetur Extension
+### Format on Save
-There are some [runtime issues](https://github.com/vuejs/vetur/issues/950) with [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) and [the Vetur extension](https://marketplace.visualstudio.com/items?itemName=octref.vetur) for VSCode. To fix this, try adding the following properties to your User or Workspace Settings:
+To automatically format your files with Prettier, add the following properties to your User or Workspace Settings:
```javascript
{
- "prettier.disableLanguages": [],
- "vetur.format.defaultFormatter.html": "none",
- "vetur.format.defaultFormatter.js": "none",
- "vetur.format.defaultFormatter.css": "none",
- "vetur.format.defaultFormatter.less": "none",
- "vetur.format.defaultFormatter.postcss": "none",
- "vetur.format.defaultFormatter.scss": "none",
- "vetur.format.defaultFormatter.stylus": "none",
- "vetur.format.defaultFormatter.ts": "none",
+ "[html]": {
+ "editor.formatOnSave": true
+ },
+ "[javascript]": {
+ "editor.formatOnSave": true
+ },
+ "[vue]": {
+ "editor.formatOnSave": true
+ },
}
```
diff --git a/doc/user/analytics/cycle_analytics.md b/doc/user/analytics/cycle_analytics.md
index b7389c8689d..377af433342 100644
--- a/doc/user/analytics/cycle_analytics.md
+++ b/doc/user/analytics/cycle_analytics.md
@@ -1,7 +1,10 @@
# Cycle Analytics
> - Introduced prior to GitLab 12.2 at the project level.
-> - [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/12077) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.2 at the group level (enabled by feature flag `analytics`).
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/12077) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.2 at the group level.
+
+NOTE: **Note:**
+As of GitLab 12.3 this feature is enabled by the `cycle_analytics` feature flag.
Cycle Analytics measures the time spent to go from an [idea to production] - also known
as cycle time - for each of your projects. Cycle Analytics displays the median time for an idea to
@@ -24,9 +27,6 @@ Cycle Analytics is available:
In the future, multiple groups will be selectable which will effectively make this an
instance-level feature.
- NOTE: **Note:**
- Requires the [analytics workspace](index.md) to be enabled.
-
- At the project level via **Project > Cycle Analytics**.
There are seven stages that are tracked as part of the Cycle Analytics calculations.
diff --git a/doc/user/analytics/index.md b/doc/user/analytics/index.md
index ec719c0b4a1..ba2735dc324 100644
--- a/doc/user/analytics/index.md
+++ b/doc/user/analytics/index.md
@@ -1,22 +1,21 @@
# Analytics workspace
-> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/12077) in GitLab 12.2 (enabled using `analytics` feature flag).
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/12077) in GitLab 12.2.
The Analytics workspace will make it possible to aggregate analytics across
GitLab, so that users can view information across multiple projects and groups
in one place.
-To access the centralized analytics workspace:
-
-1. Ensure it's enabled. Requires a GitLab administrator to enable it with the `analytics` feature
- flag.
-1. Once enabled, click on **Analytics** from the top navigation bar.
+To access the centralized analytics workspace, click on **Analytics** from the top navigation bar.
## Available analytics
From the centralized analytics workspace, the following analytics are available:
-- [Cycle Analytics](cycle_analytics.md).
+- [Cycle Analytics](cycle_analytics.md):
+
+1. Requires a GitLab administrator to enable it with the `cycle_analytics` feature flag.
+1. Once enabled, click on **Analytics** and then **Cycle Analytics** from the top navigation bar.
NOTE: **Note:**
Project-level Cycle Analytics are still available at a project's **Project > Cycle Analytics**.
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index 07a30fa5a93..46a3090e268 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -152,13 +152,9 @@ which visibility level you select on project settings.
### Protected branches
-To prevent people from messing with history or pushing code without
-review, we've created protected branches. Read through the documentation on
-[protected branches](project/protected_branches.md)
-to learn more.
-
-Additionally, you can allow or forbid users with Maintainer and/or
-Developer permissions to push to a protected branch. Read through the documentation on
+Additional restrictions can be applied on a per-branch basis with [protected branches](project/protected_branches.md).
+Additionally, you can customize permissions to allow or prevent project
+Maintainers and Developers from pushing to a protected branch. Read through the documentation on
[Allowed to Merge and Allowed to Push settings](project/protected_branches.md#using-the-allowed-to-merge-and-allowed-to-push-settings)
to learn more.
diff --git a/doc/user/project/integrations/github.md b/doc/user/project/integrations/github.md
index d0399f9193b..1a2c45a3b33 100644
--- a/doc/user/project/integrations/github.md
+++ b/doc/user/project/integrations/github.md
@@ -30,19 +30,18 @@ with `repo:status` access granted:
1. Select the "Active" checkbox.
1. Paste the token you've generated on GitHub
1. Enter the path to your project on GitHub, such as `https://github.com/username/repository`
-1. Optionally check "Static status check names" checkbox to enable static status check names.
+1. Optionally uncheck **Static status check names** checkbox to disable static status check names.
1. Save or optionally click "Test Settings".
#### Static / dynamic status check names
-Since GitLab 11.5 it is possible to opt-in to using static status check names.
+> - Introduced in GitLab 11.5: using static status check names as opt-in option.
+> - [In GitLab 12.4](https://gitlab.com/gitlab-org/gitlab-ee/issues/9931), static status check names is default behavior for new projects.
This makes it possible to mark these status checks as _Required_ on GitHub.
-If you check "Static status check names" checkbox on the integration page, your
+With **Static status check names** enabled on the integration page, your
GitLab instance host name is going to be appended to a status check name,
whereas in case of dynamic status check names, a branch name is going to be
appended.
-Dynamic status check name is a default behavior.
-
![Configure GitHub Project Integration](img/github_configuration.png)
diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md
index 211bbdc2bb9..b508d904606 100644
--- a/doc/user/project/integrations/webhooks.md
+++ b/doc/user/project/integrations/webhooks.md
@@ -1001,6 +1001,7 @@ X-Gitlab-Event: Pipeline Hook
"tag": false,
"sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2",
"before_sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2",
+ "source": "merge_request_event",
"status": "success",
"stages":[
"build",
@@ -1017,6 +1018,18 @@ X-Gitlab-Event: Pipeline Hook
}
]
},
+ "merge_request": {
+ "id": 1,
+ "iid": 1,
+ "title": "Test",
+ "source_branch": "test",
+ "source_project_id": 1,
+ "target_branch": "master",
+ "target_project_id": 1,
+ "state": "opened",
+ "merge_status": "can_be_merged",
+ "url": "http://192.168.64.1:3005/gitlab-org/gitlab-test/merge_requests/1"
+ },
"user":{
"name": "Administrator",
"username": "root",
diff --git a/doc/user/project/members/index.md b/doc/user/project/members/index.md
index 21016dca358..2f8394eb104 100644
--- a/doc/user/project/members/index.md
+++ b/doc/user/project/members/index.md
@@ -68,22 +68,26 @@ invitation, change their access level, or even delete them.
Once the user accepts the invitation, they will be prompted to create a new
GitLab account using the same e-mail address the invitation was sent to.
-## Request access to a project
+## Project membership and requesting access
-As a project owner you can enable or disable non members to request access to
-your project. Go to the project settings and click on **Allow users to request access**.
+Project owners can :
-As a user, you can request to be a member of a project. Go to the project you'd
-like to be a member of, and click the **Request Access** button on the right
+- Allow non-members to request access to the project.
+- Prevent non-members from requesting access.
+
+To configure this, go to the project settings and click on **Allow users to request access**.
+
+GitLab users can request to become a member of a project. Go to the project you'd
+like to be a member of and click the **Request Access** button on the right
side of your screen.
![Request access button](img/request_access_button.png)
-Once access is requested:
+After access is requested:
-- Up to ten project maintainers are notified of your request via email.
+- Up to ten project maintainers are notified of the request via email.
Email is sent to the most recently active project maintainers.
-- Any project maintainer can approve or decline your request on the members page.
+- Any project maintainer can approve or decline the request on the members page.
NOTE: **Note:**
If a project does not have any maintainers, the notification is sent to the
diff --git a/doc/user/project/protected_branches.md b/doc/user/project/protected_branches.md
index 895c8ac88e7..1de3e3b5387 100644
--- a/doc/user/project/protected_branches.md
+++ b/doc/user/project/protected_branches.md
@@ -5,9 +5,8 @@ type: reference, howto
# Protected Branches
[Permissions](../permissions.md) in GitLab are fundamentally defined around the
-idea of having read or write permission to the repository and branches. To
-prevent people from messing with history or pushing code without review, we've
-created protected branches.
+idea of having read or write permission to the repository and branches. To impose
+further restrictions on certain branches, they can be protected.
## Overview
diff --git a/doc/workflow/gitlab_flow.md b/doc/workflow/gitlab_flow.md
index 2f365e42cc9..136e05281a6 100644
--- a/doc/workflow/gitlab_flow.md
+++ b/doc/workflow/gitlab_flow.md
@@ -1,4 +1,3 @@
-
# Introduction to GitLab Flow
![GitLab Flow](img/gitlab_flow.png)
@@ -60,7 +59,7 @@ For example, many projects do releases but don't need to do hotfixes.
In reaction to Git flow, GitHub created a simpler alternative.
[GitHub flow](https://guides.github.com/introduction/flow/index.html) has only feature branches and a `master` branch.
This flow is clean and straightforward, and many organizations have adopted it with great success.
-Atlassian recommends [a similar strategy](https://www.atlassian.com/blog/archives/simple-git-workflow-simple), although they rebase feature branches.
+Atlassian recommends [a similar strategy](https://www.atlassian.com/blog/git/simple-git-workflow-is-simple), although they rebase feature branches.
Merging everything into the `master` branch and frequently deploying means you minimize the amount of unreleased code, which is in line with lean and continuous delivery best practices.
However, this flow still leaves a lot of questions unanswered regarding deployments, environments, releases, and integrations with issues.
With GitLab flow, we offer additional guidance for these questions.
diff --git a/lib/gitlab/auth/request_authenticator.rb b/lib/gitlab/auth/request_authenticator.rb
index 176766d1a8b..aca8804b04c 100644
--- a/lib/gitlab/auth/request_authenticator.rb
+++ b/lib/gitlab/auth/request_authenticator.rb
@@ -24,7 +24,9 @@ module Gitlab
end
def find_sessionless_user(request_format)
- find_user_from_web_access_token(request_format) || find_user_from_feed_token(request_format)
+ find_user_from_web_access_token(request_format) ||
+ find_user_from_feed_token(request_format) ||
+ find_user_from_static_object_token(request_format)
rescue Gitlab::Auth::AuthenticationError
nil
end
diff --git a/lib/gitlab/auth/user_auth_finders.rb b/lib/gitlab/auth/user_auth_finders.rb
index 97755117edc..76d41eede23 100644
--- a/lib/gitlab/auth/user_auth_finders.rb
+++ b/lib/gitlab/auth/user_auth_finders.rb
@@ -28,6 +28,15 @@ module Gitlab
current_request.env['warden']&.authenticate if verified_request?
end
+ def find_user_from_static_object_token(request_format)
+ return unless valid_static_objects_format?(request_format)
+
+ token = current_request.params[:token].presence || current_request.headers['X-Gitlab-Static-Object-Token'].presence
+ return unless token
+
+ User.find_by_static_object_token(token) || raise(UnauthorizedError)
+ end
+
def find_user_from_feed_token(request_format)
return unless valid_rss_format?(request_format)
@@ -154,6 +163,15 @@ module Gitlab
end
end
+ def valid_static_objects_format?(request_format)
+ case request_format
+ when :archive
+ archive_request?
+ else
+ false
+ end
+ end
+
def rss_request?
current_request.path.ends_with?('.atom') || current_request.format.atom?
end
@@ -165,6 +183,10 @@ module Gitlab
def api_request?
current_request.path.starts_with?("/api/")
end
+
+ def archive_request?
+ current_request.path.include?('/-/archive/')
+ end
end
end
end
diff --git a/lib/gitlab/data_builder/pipeline.rb b/lib/gitlab/data_builder/pipeline.rb
index e1e813849bf..da3d6c47431 100644
--- a/lib/gitlab/data_builder/pipeline.rb
+++ b/lib/gitlab/data_builder/pipeline.rb
@@ -9,6 +9,7 @@ module Gitlab
{
object_kind: 'pipeline',
object_attributes: hook_attrs(pipeline),
+ merge_request: pipeline.merge_request && merge_request_attrs(pipeline.merge_request),
user: pipeline.user.try(:hook_attrs),
project: pipeline.project.hook_attrs(backward: false),
commit: pipeline.commit.try(:hook_attrs),
@@ -23,6 +24,7 @@ module Gitlab
tag: pipeline.tag,
sha: pipeline.sha,
before_sha: pipeline.before_sha,
+ source: pipeline.source,
status: pipeline.status,
detailed_status: pipeline.detailed_status(nil).label,
stages: pipeline.stages_names,
@@ -33,6 +35,21 @@ module Gitlab
}
end
+ def merge_request_attrs(merge_request)
+ {
+ id: merge_request.id,
+ iid: merge_request.iid,
+ title: merge_request.title,
+ source_branch: merge_request.source_branch,
+ source_project_id: merge_request.source_project_id,
+ target_branch: merge_request.target_branch,
+ target_project_id: merge_request.target_project_id,
+ state: merge_request.state,
+ merge_status: merge_request.merge_status,
+ url: Gitlab::UrlBuilder.build(merge_request)
+ }
+ end
+
def build_hook_attrs(build)
{
id: build.id,
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index f2d3a39d593..81ff65a0c5e 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -517,6 +517,9 @@ msgstr ""
msgid "A regular expression that will be used to find the test coverage output in the job trace. Leave blank to disable"
msgstr ""
+msgid "A secure token that identifies an external storage request."
+msgstr ""
+
msgid "A user with write access to the source branch selected this option"
msgstr ""
@@ -568,6 +571,9 @@ msgstr ""
msgid "AccessTokens|Access Tokens"
msgstr ""
+msgid "AccessTokens|Are you sure?"
+msgstr ""
+
msgid "AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working."
msgstr ""
@@ -586,6 +592,9 @@ msgstr ""
msgid "AccessTokens|It cannot be used to access any other data."
msgstr ""
+msgid "AccessTokens|Keep this token secret. Anyone who gets ahold of it can access repository static objects as if they were you. You should %{reset_link_start}reset it%{reset_link_end} if that ever happens."
+msgstr ""
+
msgid "AccessTokens|Keep this token secret. Anyone who gets ahold of it can create issues as if they were you. You should %{link_reset_it} if that ever happens."
msgstr ""
@@ -595,6 +604,9 @@ msgstr ""
msgid "AccessTokens|Personal Access Tokens"
msgstr ""
+msgid "AccessTokens|Static object token"
+msgstr ""
+
msgid "AccessTokens|They are the only accepted password when you have Two-Factor Authentication (2FA) enabled."
msgstr ""
@@ -610,6 +622,9 @@ msgstr ""
msgid "AccessTokens|Your incoming email token is used to authenticate you when you create a new issue by email, and is included in your personal project-specific email addresses."
msgstr ""
+msgid "AccessTokens|Your static object token is used to authenticate you when repository static objects (e.g. archives, blobs, ...) are being served from an external storage."
+msgstr ""
+
msgid "AccessTokens|reset it"
msgstr ""
@@ -3443,9 +3458,6 @@ msgstr ""
msgid "Could not remove the trigger."
msgstr ""
-msgid "Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation.%{linkEnd}"
-msgstr ""
-
msgid "Could not revoke impersonation token %{token_name}."
msgstr ""
@@ -4897,6 +4909,12 @@ msgstr ""
msgid "External authorization request timeout"
msgstr ""
+msgid "External storage URL"
+msgstr ""
+
+msgid "External storage authentication token"
+msgstr ""
+
msgid "ExternalAuthorizationService|Classification label"
msgstr ""
@@ -5439,7 +5457,6 @@ msgstr ""
msgid "Go to file (MRs only)"
msgstr ""
-
msgid "Go to file permalink (while viewing a file)"
msgstr ""
@@ -8234,6 +8251,9 @@ msgstr ""
msgid "Pipeline|Commit"
msgstr ""
+msgid "Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation.%{linkEnd}"
+msgstr ""
+
msgid "Pipeline|Coverage"
msgstr ""
@@ -8300,18 +8320,12 @@ msgstr ""
msgid "Pipeline|for"
msgstr ""
-msgid "Pipeline|into"
-msgstr ""
-
msgid "Pipeline|on"
msgstr ""
msgid "Pipeline|success"
msgstr ""
-msgid "Pipeline|with"
-msgstr ""
-
msgid "Pipeline|with stage"
msgstr ""
@@ -8723,6 +8737,9 @@ msgstr ""
msgid "Profiles|Some options are unavailable for LDAP accounts"
msgstr ""
+msgid "Profiles|Static object token was successfully reset"
+msgstr ""
+
msgid "Profiles|Tell us about yourself in fewer than 250 characters"
msgstr ""
@@ -9745,6 +9762,9 @@ msgstr ""
msgid "Repository mirror"
msgstr ""
+msgid "Repository static objects"
+msgstr ""
+
msgid "Repository storage"
msgstr ""
@@ -10296,6 +10316,9 @@ msgstr ""
msgid "September"
msgstr ""
+msgid "Serve repository static objects (e.g. archives, blobs, ...) from an external storage (e.g. a CDN)."
+msgstr ""
+
msgid "Server supports batch API only, please update your Git LFS client to version 1.0.1 and up."
msgstr ""
@@ -12485,6 +12508,9 @@ msgstr ""
msgid "U2F only works with HTTPS-enabled websites. Contact your administrator for more details."
msgstr ""
+msgid "URL of the external storage that will serve the repository static objects (e.g. archives, blobs, ...)."
+msgstr ""
+
msgid "Unable to apply suggestions to a deleted line."
msgstr ""
diff --git a/qa/qa.rb b/qa/qa.rb
index 9bf28d396ba..b38c39a621f 100644
--- a/qa/qa.rb
+++ b/qa/qa.rb
@@ -54,7 +54,7 @@ module QA
autoload :MergeRequestFromFork, 'qa/resource/merge_request_from_fork'
autoload :DeployKey, 'qa/resource/deploy_key'
autoload :DeployToken, 'qa/resource/deploy_token'
- autoload :Branch, 'qa/resource/branch'
+ autoload :ProtectedBranch, 'qa/resource/protected_branch'
autoload :CiVariable, 'qa/resource/ci_variable'
autoload :Runner, 'qa/resource/runner'
autoload :PersonalAccessToken, 'qa/resource/personal_access_token'
diff --git a/qa/qa/page/profile/ssh_keys.rb b/qa/qa/page/profile/ssh_keys.rb
index ce1813b14d0..082202f91ca 100644
--- a/qa/qa/page/profile/ssh_keys.rb
+++ b/qa/qa/page/profile/ssh_keys.rb
@@ -14,6 +14,10 @@ module QA
element :delete_key_button
end
+ view 'app/views/profiles/keys/_key_table.html.haml' do
+ element :ssh_keys_list
+ end
+
def add_key(public_key, title)
fill_element :key_public_key_field, public_key
fill_element :key_title_field, title
@@ -28,6 +32,10 @@ module QA
click_element :delete_key_button
end
end
+
+ def keys_list
+ find_element(:ssh_keys_list).text
+ end
end
end
end
diff --git a/qa/qa/page/project/wiki/show.rb b/qa/qa/page/project/wiki/show.rb
index f79ad510084..44619d177b1 100644
--- a/qa/qa/page/project/wiki/show.rb
+++ b/qa/qa/page/project/wiki/show.rb
@@ -11,11 +11,21 @@ module QA
element :clone_repository_link, 'Clone repository' # rubocop:disable QA/ElementWithPattern
end
+ view 'app/views/projects/wikis/show.html.haml' do
+ element :wiki_page_content
+ end
+
def click_clone_repository
click_on 'Clone repository'
end
+
+ def wiki_text
+ find_element(:wiki_page_content).text
+ end
end
end
end
end
end
+
+QA::Page::Project::Wiki::Show.prepend_if_ee('QA::EE::Page::Project::Wiki::Show')
diff --git a/qa/qa/resource/branch.rb b/qa/qa/resource/protected_branch.rb
index 6dc47e36977..c27647cf3ce 100644
--- a/qa/qa/resource/branch.rb
+++ b/qa/qa/resource/protected_branch.rb
@@ -2,13 +2,24 @@
module QA
module Resource
- class Branch < Base
- attr_accessor :project, :branch_name,
- :allow_to_push, :allow_to_merge, :protected
+ class ProtectedBranch < Base
+ attr_accessor :branch_name, :allow_to_push, :allow_to_merge, :protected
attribute :project do
- Project.fabricate! do |resource|
+ Project.fabricate_via_api! do |resource|
resource.name = 'protected-branch-project'
+ resource.initialize_with_readme = true
+ end
+ end
+
+ attribute :branch do
+ Repository::ProjectPush.fabricate! do |project_push|
+ project_push.project = project
+ project_push.file_name = 'new_file.md'
+ project_push.commit_message = 'Add new file'
+ project_push.branch_name = branch_name
+ project_push.new_branch = true
+ project_push.remote_branch = @branch_name
end
end
@@ -20,32 +31,16 @@ module QA
end
def fabricate!
- project.visit!
-
- Repository::ProjectPush.fabricate! do |resource|
- resource.project = project
- resource.file_name = 'kick-off.txt'
- resource.commit_message = 'First commit'
- end
+ populate(:branch)
- branch = Repository::ProjectPush.fabricate! do |resource|
- resource.project = project
- resource.file_name = 'README.md'
- resource.commit_message = 'Add readme'
- resource.branch_name = 'master'
- resource.new_branch = false
- resource.remote_branch = @branch_name
- end
-
- Page::Project::Show.perform do |page|
- page.wait { page.has_content?(branch_name) }
- end
+ project.wait_for_push_new_branch @branch_name
# The upcoming process will make it access the Protected Branches page,
# select the already created branch and protect it according
# to `allow_to_push` variable.
return branch unless @protected
+ project.visit!
Page::Project::Menu.perform(&:go_to_repository_settings)
Page::Project::Settings::Repository.perform do |setting|
diff --git a/qa/qa/resource/repository/project_push.rb b/qa/qa/resource/repository/project_push.rb
index e98880ce195..c84ade3a140 100644
--- a/qa/qa/resource/repository/project_push.rb
+++ b/qa/qa/resource/repository/project_push.rb
@@ -33,7 +33,6 @@ module QA
def fabricate!
super
project.wait_for_push @commit_message if @wait_for_push
- project.visit!
end
end
end
diff --git a/qa/qa/resource/repository/wiki_push.rb b/qa/qa/resource/repository/wiki_push.rb
index 95712300854..06cca85cbb2 100644
--- a/qa/qa/resource/repository/wiki_push.rb
+++ b/qa/qa/resource/repository/wiki_push.rb
@@ -31,6 +31,17 @@ module QA
end
end
+ def repository_ssh_uri
+ @repository_ssh_uri ||= begin
+ wiki.visit!
+ Page::Project::Wiki::Show.act do
+ click_clone_repository
+ choose_repository_clone_ssh
+ repository_location.uri
+ end
+ end
+ end
+
def fabricate!
super
wiki.visit!
diff --git a/qa/qa/runtime/api/client.rb b/qa/qa/runtime/api/client.rb
index 25d8f3c0fbb..594913c757a 100644
--- a/qa/qa/runtime/api/client.rb
+++ b/qa/qa/runtime/api/client.rb
@@ -53,7 +53,14 @@ module QA
Page::Main::Login.perform { |login| login.sign_in_using_credentials(@user) }
end
- Resource::PersonalAccessToken.fabricate!.access_token
+ token = Resource::PersonalAccessToken.fabricate!.access_token
+
+ # If this is a new session, that tests that follow could fail if they
+ # try to sign in without starting a new session
+ # Sign out so the tests can successfully sign in
+ Page::Main::Menu.perform(&:sign_out) if @is_new_session
+
+ token
end
end
end
diff --git a/qa/qa/specs/features/api/3_create/repository/project_archive_compare_spec.rb b/qa/qa/specs/features/api/3_create/repository/project_archive_compare_spec.rb
index 3fe04e8b835..ce8425cb3d1 100644
--- a/qa/qa/specs/features/api/3_create/repository/project_archive_compare_spec.rb
+++ b/qa/qa/specs/features/api/3_create/repository/project_archive_compare_spec.rb
@@ -21,7 +21,6 @@ module QA
user_info[:api_client] = Runtime::API::Client.new(:gitlab, user: user_info[:user])
user_info[:api_client].personal_access_token
user_info[:project] = create_project(user_info[:user], user_info[:api_client], @project_name)
- Page::Main::Menu.perform(&:sign_out)
end
end
@@ -43,7 +42,7 @@ module QA
end
def create_project(user, api_client, project_name)
- project = Resource::Project.fabricate! do |project|
+ project = Resource::Project.fabricate_via_api! do |project|
project.standalone = true
project.add_name_uuid = false
project.name = project_name
diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/push_protected_branch_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/push_protected_branch_spec.rb
index e159e517cbb..dd80905d184 100644
--- a/qa/qa/specs/features/browser_ui/3_create/repository/push_protected_branch_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/repository/push_protected_branch_spec.rb
@@ -8,6 +8,7 @@ module QA
let(:project) do
Resource::Project.fabricate! do |resource|
resource.name = 'protected-branch-project'
+ resource.initialize_with_readme = true
end
end
@@ -42,7 +43,7 @@ module QA
end
def create_protected_branch(allow_to_push:)
- Resource::Branch.fabricate! do |resource|
+ Resource::ProtectedBranch.fabricate! do |resource|
resource.branch_name = branch_name
resource.project = project
resource.allow_to_push = allow_to_push
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 7c1d4489c47..2952a54ff5d 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
@@ -58,7 +58,7 @@ module QA
paths:
- my-artifacts/
EOF
- end
+ end.project.visit!
expect(page).to have_content('Add .gitlab-ci.yml')
diff --git a/spec/controllers/concerns/static_object_external_storage_spec.rb b/spec/controllers/concerns/static_object_external_storage_spec.rb
new file mode 100644
index 00000000000..3a0219ddaa1
--- /dev/null
+++ b/spec/controllers/concerns/static_object_external_storage_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe StaticObjectExternalStorage do
+ controller(Projects::ApplicationController) do
+ include StaticObjectExternalStorage # rubocop:disable RSpec/DescribedClass
+
+ before_action :redirect_to_external_storage, if: :static_objects_external_storage_enabled?
+
+ def show
+ head :ok
+ end
+ end
+
+ let(:project) { create(:project, :public) }
+ let(:user) { create(:user, static_object_token: 'hunter1') }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ context 'when external storage is not configured' do
+ it 'calls the action normally' do
+ expect(Gitlab::CurrentSettings.static_objects_external_storage_url).to be_blank
+
+ do_request
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+
+ context 'when external storage is configured' do
+ before do
+ allow_any_instance_of(ApplicationSetting).to receive(:static_objects_external_storage_url).and_return('https://cdn.gitlab.com')
+ allow_any_instance_of(ApplicationSetting).to receive(:static_objects_external_storage_auth_token).and_return('letmein')
+
+ routes.draw { get '/:namespace_id/:id' => 'projects/application#show' }
+ end
+
+ context 'when external storage token is empty' do
+ let(:base_redirect_url) { "https://cdn.gitlab.com/#{project.namespace.to_param}/#{project.to_param}" }
+
+ context 'when project is public' do
+ it 'redirects to external storage URL without adding a token parameter' do
+ do_request
+
+ expect(response).to redirect_to(base_redirect_url)
+ end
+ end
+
+ context 'when project is not public' do
+ let(:project) { create(:project, :private) }
+
+ it 'redirects to external storage URL a token parameter added' do
+ do_request
+
+ expect(response).to redirect_to("#{base_redirect_url}?token=#{user.static_object_token}")
+ end
+
+ context 'when path includes extra parameters' do
+ it 'includes the parameters in the redirect URL' do
+ do_request(foo: 'bar')
+
+ expect(response.location).to eq("#{base_redirect_url}?foo=bar&token=#{user.static_object_token}")
+ end
+ end
+ end
+ end
+
+ context 'when external storage token is present' do
+ context 'when token is correct' do
+ it 'calls the action normally' do
+ request.headers['X-Gitlab-External-Storage-Token'] = 'letmein'
+ do_request
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+
+ context 'when token is incorrect' do
+ it 'return 403' do
+ request.headers['X-Gitlab-External-Storage-Token'] = 'donotletmein'
+ do_request
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+ end
+ end
+
+ def do_request(extra_params = {})
+ get :show, params: { namespace_id: project.namespace, id: project }.merge(extra_params)
+ end
+end
diff --git a/spec/controllers/projects/repositories_controller_spec.rb b/spec/controllers/projects/repositories_controller_spec.rb
index fcab4d73dca..084644484c5 100644
--- a/spec/controllers/projects/repositories_controller_spec.rb
+++ b/spec/controllers/projects/repositories_controller_spec.rb
@@ -125,5 +125,59 @@ describe Projects::RepositoriesController do
end
end
end
+
+ context 'as a sessionless user' do
+ let(:user) { create(:user) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when no token is provided' do
+ it 'redirects to sign in page' do
+ get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master' }, format: 'zip'
+
+ expect(response).to have_gitlab_http_status(302)
+ end
+ end
+
+ context 'when a token param is present' do
+ context 'when token is correct' do
+ it 'calls the action normally' do
+ get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master', token: user.static_object_token }, format: 'zip'
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+
+ context 'when token is incorrect' do
+ it 'redirects to sign in page' do
+ get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master', token: 'foobar' }, format: 'zip'
+
+ expect(response).to have_gitlab_http_status(302)
+ end
+ end
+ end
+
+ context 'when a token header is present' do
+ context 'when token is correct' do
+ it 'calls the action normally' do
+ request.headers['X-Gitlab-Static-Object-Token'] = user.static_object_token
+ get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master' }, format: 'zip'
+
+ expect(response).to have_gitlab_http_status(200)
+ end
+ end
+
+ context 'when token is incorrect' do
+ it 'redirects to sign in page' do
+ request.headers['X-Gitlab-Static-Object-Token'] = 'foobar'
+ get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master' }, format: 'zip'
+
+ expect(response).to have_gitlab_http_status(302)
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb
index 3f2a676462b..d19835741e3 100644
--- a/spec/features/merge_request/user_sees_merge_widget_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb
@@ -189,26 +189,20 @@ describe 'Merge request > User sees merge widget', :js do
visit project_merge_request_path(project, merge_request)
end
- it 'shows head pipeline information' do
- within '.ci-widget-content' do
- expect(page).to have_content("Pipeline ##{pipeline.id} pending " \
- "for #{pipeline.short_sha} " \
- "on #{merge_request.to_reference} " \
- "with #{merge_request.source_branch}")
+ shared_examples 'pipeline widget' do
+ it 'shows head pipeline information' do
+ within '.ci-widget-content' do
+ expect(page).to have_content("Detached merge request pipeline ##{pipeline.id} pending for #{pipeline.short_sha}")
+ end
end
end
+ include_examples 'pipeline widget'
+
context 'when source project is a forked project' do
let(:source_project) { fork_project(project, user, repository: true) }
- it 'shows head pipeline information' do
- within '.ci-widget-content' do
- expect(page).to have_content("Pipeline ##{pipeline.id} pending " \
- "for #{pipeline.short_sha} " \
- "on #{merge_request.to_reference} " \
- "with #{merge_request.source_branch}")
- end
- end
+ include_examples 'pipeline widget'
end
end
@@ -234,29 +228,21 @@ describe 'Merge request > User sees merge widget', :js do
visit project_merge_request_path(project, merge_request)
end
- it 'shows head pipeline information' do
- within '.ci-widget-content' do
- expect(page).to have_content("Pipeline ##{pipeline.id} pending " \
- "for #{pipeline.short_sha} " \
- "on #{merge_request.to_reference} " \
- "with #{merge_request.source_branch} " \
- "into #{merge_request.target_branch}")
+ shared_examples 'pipeline widget' do
+ it 'shows head pipeline information' do
+ within '.ci-widget-content' do
+ expect(page).to have_content("Merged result pipeline ##{pipeline.id} pending for #{pipeline.short_sha}")
+ end
end
end
+ include_examples 'pipeline widget'
+
context 'when source project is a forked project' do
let(:source_project) { fork_project(project, user, repository: true) }
let(:merge_sha) { source_project.commit.sha }
- it 'shows head pipeline information' do
- within '.ci-widget-content' do
- expect(page).to have_content("Pipeline ##{pipeline.id} pending " \
- "for #{pipeline.short_sha} " \
- "on #{merge_request.to_reference} " \
- "with #{merge_request.source_branch} " \
- "into #{merge_request.target_branch}")
- end
- end
+ include_examples 'pipeline widget'
end
end
diff --git a/spec/features/projects/branches/download_buttons_spec.rb b/spec/features/projects/branches/download_buttons_spec.rb
index 401425187b0..e0b0e22823e 100644
--- a/spec/features/projects/branches/download_buttons_spec.rb
+++ b/spec/features/projects/branches/download_buttons_spec.rb
@@ -29,6 +29,11 @@ describe 'Download buttons in branches page' do
end
describe 'when checking branches' do
+ it_behaves_like 'archive download buttons' do
+ let(:ref) { 'binary-encoding' }
+ let(:path_to_visit) { project_branches_filtered_path(project, state: 'all', search: ref) }
+ end
+
context 'with artifacts' do
before do
visit project_branches_filtered_path(project, state: 'all', search: 'binary-encoding')
diff --git a/spec/features/projects/files/download_buttons_spec.rb b/spec/features/projects/files/download_buttons_spec.rb
index a4889f8d4c4..871f5212ddd 100644
--- a/spec/features/projects/files/download_buttons_spec.rb
+++ b/spec/features/projects/files/download_buttons_spec.rb
@@ -24,11 +24,17 @@ describe 'Projects > Files > Download buttons in files tree' do
before do
sign_in(user)
project.add_developer(user)
+ end
- visit project_tree_path(project, project.default_branch)
+ it_behaves_like 'archive download buttons' do
+ let(:path_to_visit) { project_tree_path(project, project.default_branch) }
end
context 'with artifacts' do
+ before do
+ visit project_tree_path(project, project.default_branch)
+ end
+
it 'shows download artifacts button' do
href = latest_succeeded_project_artifacts_path(project, "#{project.default_branch}/download", job: 'build')
diff --git a/spec/features/projects/show/download_buttons_spec.rb b/spec/features/projects/show/download_buttons_spec.rb
index 5e7453bcdb7..0d609069426 100644
--- a/spec/features/projects/show/download_buttons_spec.rb
+++ b/spec/features/projects/show/download_buttons_spec.rb
@@ -29,6 +29,8 @@ describe 'Projects > Show > Download buttons' do
end
describe 'when checking project main page' do
+ it_behaves_like 'archive download buttons'
+
context 'with artifacts' do
before do
visit project_path(project)
diff --git a/spec/features/projects/tags/download_buttons_spec.rb b/spec/features/projects/tags/download_buttons_spec.rb
index 76b2704ae49..64141cf5dc9 100644
--- a/spec/features/projects/tags/download_buttons_spec.rb
+++ b/spec/features/projects/tags/download_buttons_spec.rb
@@ -30,6 +30,11 @@ describe 'Download buttons in tags page' do
end
describe 'when checking tags' do
+ it_behaves_like 'archive download buttons' do
+ let(:path_to_visit) { project_tags_path(project) }
+ let(:ref) { tag }
+ end
+
context 'with artifacts' do
before do
visit project_tags_path(project)
diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js
new file mode 100644
index 00000000000..ac770c896bd
--- /dev/null
+++ b/spec/frontend/diffs/components/diff_file_header_spec.js
@@ -0,0 +1,472 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
+import EditButton from '~/diffs/components/edit_button.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+import diffDiscussionsMockData from '../mock_data/diff_discussions';
+import { truncateSha } from '~/lib/utils/text_utility';
+import { diffViewerModes } from '~/ide/constants';
+import { __, sprintf } from '~/locale';
+import { scrollToElement } from '~/lib/utils/common_utils';
+
+jest.mock('~/lib/utils/common_utils');
+
+const diffFile = Object.freeze(
+ Object.assign(diffDiscussionsMockData.diff_file, {
+ edit_path: 'link:/to/edit/path',
+ blob: {
+ id: '848ed9407c6730ff16edb3dd24485a0eea24292a',
+ path: 'lib/base.js',
+ name: 'base.js',
+ mode: '100644',
+ readable_text: true,
+ icon: 'file-text-o',
+ },
+ }),
+);
+
+describe('DiffFileHeader component', () => {
+ let wrapper;
+
+ const diffHasExpandedDiscussionsResultMock = jest.fn();
+ const diffHasDiscussionsResultMock = jest.fn();
+ const mockStoreConfig = {
+ state: {},
+ modules: {
+ diffs: {
+ namespaced: true,
+ getters: {
+ diffHasExpandedDiscussions: () => diffHasExpandedDiscussionsResultMock,
+ diffHasDiscussions: () => diffHasDiscussionsResultMock,
+ },
+ actions: {
+ toggleFileDiscussions: jest.fn(),
+ toggleFileDiscussionWrappers: jest.fn(),
+ toggleFullDiff: jest.fn(),
+ },
+ },
+ },
+ };
+
+ afterEach(() => {
+ [
+ diffHasDiscussionsResultMock,
+ diffHasExpandedDiscussionsResultMock,
+ ...Object.values(mockStoreConfig.modules.diffs.actions),
+ ].forEach(mock => mock.mockReset());
+ });
+
+ const findHeader = () => wrapper.find({ ref: 'header' });
+ const findTitleLink = () => wrapper.find({ ref: 'titleWrapper' });
+ const findExpandButton = () => wrapper.find({ ref: 'expandDiffToFullFileButton' });
+ const findFileActions = () => wrapper.find('.file-actions');
+ const findModeChangedLine = () => wrapper.find({ ref: 'fileMode' });
+ const findLfsLabel = () => wrapper.find('.label-lfs');
+ const findToggleDiscussionsButton = () => wrapper.find({ ref: 'toggleDiscussionsButton' });
+ const findExternalLink = () => wrapper.find({ ref: 'externalLink' });
+ const findReplacedFileButton = () => wrapper.find({ ref: 'replacedFileButton' });
+ const findViewFileButton = () => wrapper.find({ ref: 'viewButton' });
+ const findCollapseIcon = () => wrapper.find({ ref: 'collapseIcon' });
+
+ const findIconByName = iconName => {
+ const icons = wrapper.findAll(Icon).filter(w => w.props('name') === iconName);
+ if (icons.length === 0) return icons;
+ if (icons.length > 1) {
+ throw new Error(`Multiple icons found for ${iconName}`);
+ }
+ return icons.at(0);
+ };
+
+ const createComponent = props => {
+ const localVue = createLocalVue();
+ localVue.use(Vuex);
+ const store = new Vuex.Store(mockStoreConfig);
+
+ wrapper = shallowMount(DiffFileHeader, {
+ propsData: {
+ diffFile,
+ canCurrentUserFork: false,
+ ...props,
+ },
+ localVue,
+ store,
+ sync: false,
+ });
+ };
+
+ it.each`
+ visibility | collapsible
+ ${'visible'} | ${true}
+ ${'hidden'} | ${false}
+ `('collapse toggle is $visibility if collapsible is $collapsible', ({ collapsible }) => {
+ createComponent({ collapsible });
+ expect(findCollapseIcon().exists()).toBe(collapsible);
+ });
+
+ it.each`
+ expanded | icon
+ ${true} | ${'chevron-down'}
+ ${false} | ${'chevron-right'}
+ `('collapse icon is $icon if expanded is $expanded', ({ icon, expanded }) => {
+ createComponent({ expanded, collapsible: true });
+ expect(findCollapseIcon().props('name')).toBe(icon);
+ });
+
+ it('when header is clicked emits toggleFile', () => {
+ createComponent();
+ findHeader().trigger('click');
+ expect(wrapper.emitted().toggleFile).toBeDefined();
+ });
+
+ it('when collapseIcon is clicked emits toggleFile', () => {
+ createComponent({ collapsible: true });
+ findCollapseIcon().vm.$emit('click', new Event('click'));
+ expect(wrapper.emitted().toggleFile).toBeDefined();
+ });
+
+ it('when other element in header is clicked does not emits toggleFile', () => {
+ createComponent({ collapsible: true });
+ findTitleLink().trigger('click');
+ expect(wrapper.emitted().toggleFile).not.toBeDefined();
+ });
+
+ it('displays a copy to clipboard button', () => {
+ createComponent();
+ expect(wrapper.find(ClipboardButton).exists()).toBe(true);
+ });
+
+ describe('for submodule', () => {
+ const submoduleDiffFile = {
+ ...diffFile,
+ submodule: true,
+ submodule_link: 'link://to/submodule',
+ };
+
+ it('prefers submodule_tree_url over submodule_link for href', () => {
+ const submoduleTreeUrl = 'some://tree/url';
+ createComponent({
+ discussionLink: 'discussionLink',
+ diffFile: {
+ ...submoduleDiffFile,
+ submodule_tree_url: 'some://tree/url',
+ },
+ });
+
+ expect(findTitleLink().attributes('href')).toBe(submoduleTreeUrl);
+ });
+
+ it('uses submodule_link for href if submodule_tree_url does not exists', () => {
+ const submoduleLink = 'link://to/submodule';
+ createComponent({
+ discussionLink: 'discussionLink',
+ diffFile: submoduleDiffFile,
+ });
+
+ expect(findTitleLink().attributes('href')).toBe(submoduleLink);
+ });
+
+ it('uses file_path + SHA as link text', () => {
+ createComponent({
+ diffFile: submoduleDiffFile,
+ });
+
+ expect(findTitleLink().text()).toContain(
+ `${diffFile.file_path} @ ${truncateSha(diffFile.blob.id)}`,
+ );
+ });
+
+ it('does not render file actions', () => {
+ createComponent({
+ diffFile: submoduleDiffFile,
+ addMergeRequestButtons: true,
+ });
+ expect(findFileActions().exists()).toBe(false);
+ });
+ });
+
+ describe('for any file', () => {
+ const otherModes = Object.keys(diffViewerModes).filter(m => m !== 'mode_changed');
+
+ it('when edit button emits showForkMessage event it is re-emitted', () => {
+ createComponent({
+ addMergeRequestButtons: true,
+ });
+ wrapper.find(EditButton).vm.$emit('showForkMessage');
+ expect(wrapper.emitted().showForkMessage).toBeDefined();
+ });
+
+ it('for mode_changed file mode displays mode changes', () => {
+ createComponent({
+ diffFile: {
+ ...diffFile,
+ a_mode: 'old-mode',
+ b_mode: 'new-mode',
+ viewer: {
+ ...diffFile.viewer,
+ name: diffViewerModes.mode_changed,
+ },
+ },
+ });
+ expect(findModeChangedLine().text()).toMatch(/old-mode.+new-mode/);
+ });
+
+ it.each(otherModes.map(m => [m]))('for %s file mode does not display mode changes', mode => {
+ createComponent({
+ diffFile: {
+ ...diffFile,
+ a_mode: 'old-mode',
+ b_mode: 'new-mode',
+ viewer: {
+ ...diffFile.viewer,
+ name: diffViewerModes[mode],
+ },
+ },
+ });
+ expect(findModeChangedLine().exists()).toBeFalsy();
+ });
+
+ it('displays the LFS label for files stored in LFS', () => {
+ createComponent({
+ diffFile: { ...diffFile, stored_externally: true, external_storage: 'lfs' },
+ });
+ expect(findLfsLabel().exists()).toBe(true);
+ });
+
+ it('does not display the LFS label for files stored in repository', () => {
+ createComponent({
+ diffFile: { ...diffFile, stored_externally: false },
+ });
+ expect(findLfsLabel().exists()).toBe(false);
+ });
+
+ it('does not render view replaced file button if no replaced view path is present', () => {
+ createComponent({
+ diffFile: { ...diffFile, replaced_view_path: null },
+ });
+ expect(findReplacedFileButton().exists()).toBe(false);
+ });
+
+ describe('when addMergeRequestButtons is false', () => {
+ it('does not render file actions', () => {
+ createComponent({ addMergeRequestButtons: false });
+ expect(findFileActions().exists()).toBe(false);
+ });
+ it('should not render edit button', () => {
+ createComponent({ addMergeRequestButtons: false });
+ expect(wrapper.find(EditButton).exists()).toBe(false);
+ });
+ });
+
+ describe('when addMergeRequestButtons is true', () => {
+ describe('without discussions', () => {
+ it('renders a disabled toggle discussions button', () => {
+ diffHasDiscussionsResultMock.mockReturnValue(false);
+ createComponent({ addMergeRequestButtons: true });
+ expect(findToggleDiscussionsButton().attributes('disabled')).toBe('true');
+ });
+ });
+
+ describe('with discussions', () => {
+ it('dispatches toggleFileDiscussionWrappers when user clicks on toggle discussions button', () => {
+ diffHasDiscussionsResultMock.mockReturnValue(true);
+ createComponent({ addMergeRequestButtons: true });
+ expect(findToggleDiscussionsButton().attributes('disabled')).toBeFalsy();
+ findToggleDiscussionsButton().vm.$emit('click');
+ expect(
+ mockStoreConfig.modules.diffs.actions.toggleFileDiscussionWrappers,
+ ).toHaveBeenCalledWith(expect.any(Object), diffFile, undefined);
+ });
+ });
+
+ it('should show edit button', () => {
+ createComponent({
+ addMergeRequestButtons: true,
+ });
+ expect(wrapper.find(EditButton).exists()).toBe(true);
+ });
+
+ describe('view on environment button', () => {
+ it('is displayed when external url is provided', () => {
+ const externalUrl = 'link://to/external';
+ const formattedExternalUrl = 'link://formatted';
+ createComponent({
+ diffFile: {
+ ...diffFile,
+ external_url: externalUrl,
+ formatted_external_url: formattedExternalUrl,
+ },
+ addMergeRequestButtons: true,
+ });
+ expect(findExternalLink().exists()).toBe(true);
+ });
+
+ it('is hidden by default', () => {
+ createComponent({ addMergeRequestButtons: true });
+ expect(findExternalLink().exists()).toBe(false);
+ });
+ });
+
+ describe('without file blob', () => {
+ beforeEach(() => {
+ createComponent({ diffFile: { ...diffFile, blob: false } });
+ });
+
+ it('should not render toggle discussions button', () => {
+ expect(findToggleDiscussionsButton().exists()).toBe(false);
+ });
+
+ it('should not render edit button', () => {
+ expect(wrapper.find(EditButton).exists()).toBe(false);
+ });
+ });
+ describe('with file blob', () => {
+ it('should render correct file view button', () => {
+ const viewPath = 'link://view-path';
+ createComponent({
+ diffFile: { ...diffFile, view_path: viewPath },
+ addMergeRequestButtons: true,
+ });
+ expect(findViewFileButton().attributes('href')).toBe(viewPath);
+ expect(findViewFileButton().attributes('data-original-title')).toEqual(
+ `View file @ ${diffFile.content_sha.substr(0, 8)}`,
+ );
+ });
+ });
+ });
+
+ describe('expand full file button', () => {
+ describe('when diff is fully expanded', () => {
+ it('is not rendered', () => {
+ createComponent({
+ diffFile: {
+ ...diffFile,
+ is_fully_expanded: true,
+ },
+ });
+ expect(findExpandButton().exists()).toBe(false);
+ });
+ });
+ describe('when diff is not fully expanded', () => {
+ const fullyNotExpandedFileProps = {
+ diffFile: {
+ ...diffFile,
+ is_fully_expanded: false,
+ edit_path: 'link/to/edit/path.txt',
+ isShowingFullFile: false,
+ },
+ addMergeRequestButtons: true,
+ };
+
+ it.each`
+ iconName | isShowingFullFile
+ ${'doc-expand'} | ${false}
+ ${'doc-changes'} | ${true}
+ `(
+ 'shows $iconName when isShowingFullFile set to $isShowingFullFile',
+ ({ iconName, isShowingFullFile }) => {
+ createComponent({
+ ...fullyNotExpandedFileProps,
+ diffFile: { ...fullyNotExpandedFileProps.diffFile, isShowingFullFile },
+ });
+ expect(findIconByName(iconName).exists()).toBe(true);
+ },
+ );
+
+ it('renders expand to full file button if not showing full file already', () => {
+ createComponent(fullyNotExpandedFileProps);
+ expect(findExpandButton().exists()).toBe(true);
+ });
+
+ it('renders loading icon when loading full file', () => {
+ createComponent(fullyNotExpandedFileProps);
+ expect(findExpandButton().exists()).toBe(true);
+ });
+
+ it('toggles full diff on click', () => {
+ createComponent(fullyNotExpandedFileProps);
+ findExpandButton().vm.$emit('click');
+ expect(mockStoreConfig.modules.diffs.actions.toggleFullDiff).toHaveBeenCalled();
+ });
+ });
+ });
+
+ it('uses discussionPath for link if it is defined', () => {
+ const discussionPath = 'link://to/discussion';
+ createComponent({
+ discussionPath,
+ });
+ expect(findTitleLink().attributes('href')).toBe(discussionPath);
+ });
+
+ it('uses local anchor for link as last resort', () => {
+ createComponent();
+ expect(findTitleLink().attributes('href')).toMatch(/^#diff-content/);
+ });
+
+ describe('when local anchor for link is clicked', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('scrolls to target', () => {
+ findTitleLink().trigger('click');
+ expect(scrollToElement).toHaveBeenCalled();
+ });
+
+ it('updates anchor in URL', () => {
+ findTitleLink().trigger('click');
+ expect(window.location.href).toMatch(/#diff-content/);
+ });
+ });
+ });
+
+ describe('for new file', () => {
+ it('displays the path', () => {
+ createComponent({ diffFile: { ...diffFile, new_file: true } });
+ expect(findTitleLink().text()).toBe(diffFile.file_path);
+ });
+ });
+
+ describe('for deleted file', () => {
+ it('displays the path', () => {
+ createComponent({ diffFile: { ...diffFile, deleted_file: true } });
+ expect(findTitleLink().text()).toBe(
+ sprintf(__('%{filePath} deleted'), { filePath: diffFile.file_path }, false),
+ );
+ });
+
+ it('does not show edit button', () => {
+ createComponent({ diffFile: { ...diffFile, deleted_file: true } });
+ expect(wrapper.find(EditButton).exists()).toBe(false);
+ });
+ });
+
+ describe('for renamed file', () => {
+ it('displays old and new path if the file was renamed', () => {
+ createComponent({
+ diffFile: {
+ ...diffFile,
+ renamed_file: true,
+ old_path_html: 'old',
+ new_path_html: 'new',
+ },
+ });
+ expect(findTitleLink().text()).toMatch(/^old.+new/s);
+ });
+ });
+
+ describe('for replaced file', () => {
+ it('renders view replaced file button', () => {
+ const replacedViewPath = 'some/path';
+ createComponent({
+ diffFile: {
+ ...diffFile,
+ replaced_view_path: replacedViewPath,
+ },
+ addMergeRequestButtons: true,
+ });
+ expect(findReplacedFileButton().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js
index 0a52c81571e..f582729d773 100644
--- a/spec/frontend/notes/components/discussion_actions_spec.js
+++ b/spec/frontend/notes/components/discussion_actions_spec.js
@@ -65,6 +65,15 @@ describe('DiscussionActions', () => {
expect(wrapper.find(JumpToNextDiscussionButton).exists()).toBe(false);
});
+
+ it('does not renders discussion button for non-member', () => {
+ const discussion = JSON.parse(JSON.stringify(discussionMock));
+ discussion.notes[1].current_user.can_resolve = false;
+ createComponent({ discussion });
+
+ expect(wrapper.find(ResolveDiscussionButton).exists()).toBe(false);
+ expect(wrapper.find(ResolveWithIssueButton).exists()).toBe(false);
+ });
});
describe('events handling', () => {
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index b81249a1e29..4a3ff7e0095 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -195,4 +195,41 @@ describe ApplicationHelper do
end
end
end
+
+ describe '#external_storage_url_or_path' do
+ let(:project) { create(:project) }
+
+ context 'when external storage is disabled' do
+ it 'returns the passed path' do
+ expect(helper.external_storage_url_or_path('/foo/bar', project)).to eq('/foo/bar')
+ end
+ end
+
+ context 'when external storage is enabled' do
+ let(:user) { create(:user, static_object_token: 'hunter1') }
+
+ before do
+ allow_any_instance_of(ApplicationSetting).to receive(:static_objects_external_storage_url).and_return('https://cdn.gitlab.com')
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ it 'returns the external storage URL prepended to the path' do
+ expect(helper.external_storage_url_or_path('/foo/bar', project)).to eq("https://cdn.gitlab.com/foo/bar?token=#{user.static_object_token}")
+ end
+
+ it 'preserves the path query parameters' do
+ url = helper.external_storage_url_or_path('/foo/bar?unicode=1', project)
+
+ expect(url).to eq("https://cdn.gitlab.com/foo/bar?token=#{user.static_object_token}&unicode=1")
+ end
+
+ context 'when project is public' do
+ let(:project) { create(:project, :public) }
+
+ it 'returns does not append a token parameter' do
+ expect(helper.external_storage_url_or_path('/foo/bar', project)).to eq('https://cdn.gitlab.com/foo/bar')
+ end
+ end
+ end
+ end
end
diff --git a/spec/javascripts/diffs/components/diff_file_header_spec.js b/spec/javascripts/diffs/components/diff_file_header_spec.js
deleted file mode 100644
index 356e7a8f1fe..00000000000
--- a/spec/javascripts/diffs/components/diff_file_header_spec.js
+++ /dev/null
@@ -1,713 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import diffsModule from '~/diffs/store/modules';
-import notesModule from '~/notes/stores/modules';
-import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
-import mountComponent, { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import diffDiscussionsMockData from '../mock_data/diff_discussions';
-import { diffViewerModes } from '~/ide/constants';
-
-Vue.use(Vuex);
-
-describe('diff_file_header', () => {
- let vm;
- let props;
- const diffDiscussionMock = diffDiscussionsMockData;
- const Component = Vue.extend(DiffFileHeader);
-
- const store = new Vuex.Store({
- modules: {
- diffs: diffsModule(),
- notes: notesModule(),
- },
- });
-
- beforeEach(() => {
- const diffFile = diffDiscussionMock.diff_file;
-
- diffFile.added_lines = 2;
- diffFile.removed_lines = 1;
-
- props = {
- diffFile: { ...diffFile },
- canCurrentUserFork: false,
- };
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('computed', () => {
- describe('icon', () => {
- beforeEach(() => {
- props.diffFile.blob.icon = 'file-text-o';
- });
-
- it('returns the blob icon for files', () => {
- props.diffFile.submodule = false;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.icon).toBe(props.diffFile.blob.icon);
- });
-
- it('returns the archive icon for submodules', () => {
- props.diffFile.submodule = true;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.icon).toBe('archive');
- });
- });
-
- describe('titleLink', () => {
- beforeEach(() => {
- props.discussionPath = 'link://to/discussion';
- Object.assign(props.diffFile, {
- submodule_link: 'link://to/submodule',
- submodule_tree_url: 'some://tree/url',
- });
- });
-
- it('returns the discussionPath for files', () => {
- props.diffFile.submodule = false;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.titleLink).toBe(props.discussionPath);
- });
-
- it('returns the submoduleTreeUrl for submodules', () => {
- props.diffFile.submodule = true;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.titleLink).toBe(props.diffFile.submodule_tree_url);
- });
-
- it('returns the submoduleLink for submodules without submoduleTreeUrl', () => {
- Object.assign(props.diffFile, {
- submodule: true,
- submodule_tree_url: null,
- });
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.titleLink).toBe(props.diffFile.submodule_link);
- });
-
- it('sets the correct path to the discussion', () => {
- props.discussionPath = 'link://to/discussion';
- vm = mountComponentWithStore(Component, { props, store });
- const href = vm.$el.querySelector('.js-title-wrapper').getAttribute('href');
-
- expect(href).toBe(vm.discussionPath);
- });
- });
-
- describe('filePath', () => {
- beforeEach(() => {
- Object.assign(props.diffFile, {
- blob: { id: 'b10b1db10b1d' },
- file_path: 'path/to/file',
- });
- });
-
- it('returns the filePath for files', () => {
- props.diffFile.submodule = false;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.filePath).toBe(props.diffFile.file_path);
- });
-
- it('appends the truncated blob id for submodules', () => {
- props.diffFile.submodule = true;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.filePath).toBe(
- `${props.diffFile.file_path} @ ${props.diffFile.blob.id.substr(0, 8)}`,
- );
- });
- });
-
- describe('titleTag', () => {
- it('returns a link tag if fileHash is set', () => {
- props.diffFile.file_hash = 'some hash';
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.titleTag).toBe('a');
- });
-
- it('returns a span tag if fileHash is not set', () => {
- props.diffFile.file_hash = null;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.titleTag).toBe('span');
- });
- });
-
- describe('isUsingLfs', () => {
- beforeEach(() => {
- Object.assign(props.diffFile, {
- stored_externally: true,
- external_storage: 'lfs',
- });
- });
-
- it('returns true if file is stored in LFS', () => {
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.isUsingLfs).toBe(true);
- });
-
- it('returns false if file is not stored externally', () => {
- props.diffFile.stored_externally = false;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.isUsingLfs).toBe(false);
- });
-
- it('returns false if file is not stored in LFS', () => {
- props.diffFile.external_storage = 'not lfs';
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.isUsingLfs).toBe(false);
- });
- });
-
- describe('collapseIcon', () => {
- it('returns chevron-down if the diff is expanded', () => {
- props.expanded = true;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.collapseIcon).toBe('chevron-down');
- });
-
- it('returns chevron-right if the diff is collapsed', () => {
- props.expanded = false;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.collapseIcon).toBe('chevron-right');
- });
- });
-
- describe('viewFileButtonText', () => {
- it('contains the truncated content SHA', () => {
- const dummySha = 'deebd00f is no SHA';
- props.diffFile.content_sha = dummySha;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.viewFileButtonText).not.toContain(dummySha);
- expect(vm.viewFileButtonText).toContain(dummySha.substr(0, 8));
- });
- });
-
- describe('viewReplacedFileButtonText', () => {
- it('contains the truncated base SHA', () => {
- const dummySha = 'deadabba sings no more';
- props.diffFile.diff_refs.base_sha = dummySha;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.viewReplacedFileButtonText).not.toContain(dummySha);
- expect(vm.viewReplacedFileButtonText).toContain(dummySha.substr(0, 8));
- });
- });
- });
-
- describe('methods', () => {
- describe('handleToggleFile', () => {
- beforeEach(() => {
- spyOn(vm, '$emit').and.stub();
- });
-
- it('emits toggleFile if checkTarget is false', () => {
- vm.handleToggleFile(null, false);
-
- expect(vm.$emit).toHaveBeenCalledWith('toggleFile');
- });
-
- it('emits toggleFile if checkTarget is true and event target is header', () => {
- vm.handleToggleFile({ target: vm.$refs.header }, true);
-
- expect(vm.$emit).toHaveBeenCalledWith('toggleFile');
- });
-
- it('does not emit toggleFile if checkTarget is true and event target is not header', () => {
- vm.handleToggleFile({ target: 'not header' }, true);
-
- expect(vm.$emit).not.toHaveBeenCalled();
- });
- });
-
- describe('handleFileNameClick', () => {
- let e;
-
- beforeEach(() => {
- e = { preventDefault: () => {} };
- spyOn(e, 'preventDefault');
- });
-
- describe('when file name links to other page', () => {
- it('does not call preventDefault if submodule tree url exists', () => {
- vm = mountComponent(Component, {
- ...props,
- diffFile: { ...props.diffFile, submodule_tree_url: 'foobar.com' },
- });
-
- vm.handleFileNameClick(e);
-
- expect(e.preventDefault).not.toHaveBeenCalled();
- });
-
- it('does not call preventDefault if submodule_link exists', () => {
- vm = mountComponent(Component, {
- ...props,
- diffFile: { ...props.diffFile, submodule_link: 'foobar.com' },
- });
- vm.handleFileNameClick(e);
-
- expect(e.preventDefault).not.toHaveBeenCalled();
- });
-
- it('does not call preventDefault if discussionPath exists', () => {
- vm = mountComponent(Component, {
- ...props,
- discussionPath: 'Foo bar',
- });
-
- vm.handleFileNameClick(e);
-
- expect(e.preventDefault).not.toHaveBeenCalled();
- });
- });
-
- describe('scrolling to diff', () => {
- let scrollToElement;
- let el;
-
- beforeEach(() => {
- el = document.createElement('div');
- spyOn(document, 'querySelector').and.returnValue(el);
- scrollToElement = spyOnDependency(DiffFileHeader, 'scrollToElement');
- vm = mountComponent(Component, props);
-
- vm.handleFileNameClick(e);
- });
-
- it('calls scrollToElement with file content', () => {
- expect(scrollToElement).toHaveBeenCalledWith(el);
- });
-
- it('element adds the content id to the window location', () => {
- expect(window.location.hash).toContain(props.diffFile.file_hash);
- });
-
- it('calls preventDefault when button does not link to other page', () => {
- expect(e.preventDefault).toHaveBeenCalled();
- });
- });
- });
- });
-
- describe('template', () => {
- describe('collapse toggle', () => {
- const collapseToggle = () => vm.$el.querySelector('.diff-toggle-caret');
-
- it('is visible if collapsible is true', () => {
- props.collapsible = true;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(collapseToggle()).not.toBe(null);
- });
-
- it('is hidden if collapsible is false', () => {
- props.collapsible = false;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(collapseToggle()).toBe(null);
- });
- });
-
- it('displays an file icon in the title', () => {
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector('svg.js-file-icon use').getAttribute('xlink:href')).toContain(
- 'ruby',
- );
- });
-
- describe('file paths', () => {
- const filePaths = () => vm.$el.querySelectorAll('.file-title-name');
-
- it('displays the path of a added file', () => {
- props.diffFile.renamed_file = false;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(filePaths()).toHaveLength(1);
- expect(filePaths()[0]).toHaveText(props.diffFile.file_path);
- });
-
- it('displays path for deleted file', () => {
- props.diffFile.renamed_file = false;
- props.diffFile.deleted_file = true;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(filePaths()).toHaveLength(1);
- expect(filePaths()[0]).toHaveText(`${props.diffFile.file_path} deleted`);
- });
-
- it('displays old and new path if the file was renamed', () => {
- props.diffFile.renamed_file = true;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(filePaths()).toHaveLength(2);
- expect(filePaths()[0]).toHaveText(props.diffFile.old_path_html);
- expect(filePaths()[1]).toHaveText(props.diffFile.new_path_html);
- });
- });
-
- it('displays a copy to clipboard button', () => {
- vm = mountComponentWithStore(Component, { props, store });
-
- const button = vm.$el.querySelector('.btn-clipboard');
-
- expect(button).not.toBe(null);
- expect(button.dataset.clipboardText).toBe('{"text":"CHANGELOG.rb","gfm":"`CHANGELOG.rb`"}');
- });
-
- describe('file mode', () => {
- it('it displays old and new file mode if it changed', () => {
- props.diffFile.viewer.name = diffViewerModes.mode_changed;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- const { fileMode } = vm.$refs;
-
- expect(fileMode).not.toBe(undefined);
- expect(fileMode).toContainText(props.diffFile.a_mode);
- expect(fileMode).toContainText(props.diffFile.b_mode);
- });
-
- it('does not display the file mode if it has not changed', () => {
- props.diffFile.viewer.name = diffViewerModes.text;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- const { fileMode } = vm.$refs;
-
- expect(fileMode).toBe(undefined);
- });
- });
-
- describe('LFS label', () => {
- const lfsLabel = () => vm.$el.querySelector('.label-lfs');
-
- it('displays the LFS label for files stored in LFS', () => {
- Object.assign(props.diffFile, {
- stored_externally: true,
- external_storage: 'lfs',
- });
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(lfsLabel()).not.toBe(null);
- expect(lfsLabel()).toHaveText('LFS');
- });
-
- it('does not display the LFS label for files stored in repository', () => {
- props.diffFile.stored_externally = false;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(lfsLabel()).toBe(null);
- });
- });
-
- describe('edit button', () => {
- it('should not render edit button if addMergeRequestButtons is not true', () => {
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector('.js-edit-blob')).toEqual(null);
- });
-
- it('should show edit button when file is editable', () => {
- props.addMergeRequestButtons = true;
- props.diffFile.edit_path = '/';
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector('.js-edit-blob')).not.toBe(null);
- });
-
- it('should not show edit button when file is deleted', () => {
- props.addMergeRequestButtons = true;
- props.diffFile.deleted_file = true;
- props.diffFile.edit_path = '/';
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector('.js-edit-blob')).toEqual(null);
- });
- });
-
- describe('addMergeRequestButtons', () => {
- beforeEach(() => {
- props.addMergeRequestButtons = true;
- props.diffFile.edit_path = '';
- });
-
- describe('view on environment button', () => {
- const url = 'some.external.url/';
- const title = 'url.title';
-
- it('displays link to external url', () => {
- props.diffFile.external_url = url;
- props.diffFile.formatted_external_url = title;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector(`a[href="${url}"]`)).not.toBe(null);
- expect(vm.$el.querySelector(`a[data-original-title="View on ${title}"]`)).not.toBe(null);
- });
-
- it('hides link if no external url', () => {
- props.diffFile.external_url = '';
- props.diffFile.formattedExternal_url = title;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector(`a[data-original-title="View on ${title}"]`)).toBe(null);
- });
- });
- });
-
- describe('handles toggle discussions', () => {
- it('renders a disabled button when diff has no discussions', () => {
- const propsCopy = Object.assign({}, props);
- propsCopy.diffFile.submodule = false;
- propsCopy.diffFile.blob = {
- id: '848ed9407c6730ff16edb3dd24485a0eea24292a',
- path: 'lib/base.js',
- name: 'base.js',
- mode: '100644',
- readable_text: true,
- icon: 'file-text-o',
- };
- propsCopy.addMergeRequestButtons = true;
- propsCopy.diffFile.deleted_file = true;
-
- vm = mountComponentWithStore(Component, {
- props: propsCopy,
- store,
- });
-
- expect(
- vm.$el.querySelector('.js-btn-vue-toggle-comments').getAttribute('disabled'),
- ).toEqual('disabled');
- });
-
- describe('with discussions', () => {
- it('dispatches toggleFileDiscussionWrappers when user clicks on toggle discussions button', () => {
- const propsCopy = Object.assign({}, props);
- propsCopy.diffFile.submodule = false;
- propsCopy.diffFile.blob = {
- id: '848ed9407c6730ff16edb3dd24485a0eea24292a',
- path: 'lib/base.js',
- name: 'base.js',
- mode: '100644',
- readable_text: true,
- icon: 'file-text-o',
- };
- propsCopy.addMergeRequestButtons = true;
- propsCopy.diffFile.deleted_file = true;
-
- const discussionGetter = () => [
- {
- ...diffDiscussionMock,
- },
- ];
- const notesModuleMock = notesModule();
- notesModuleMock.getters.discussions = discussionGetter;
- vm = mountComponentWithStore(Component, {
- props: propsCopy,
- store: new Vuex.Store({
- modules: {
- diffs: diffsModule(),
- notes: notesModuleMock,
- },
- }),
- });
-
- spyOn(vm, 'toggleFileDiscussionWrappers');
-
- vm.$el.querySelector('.js-btn-vue-toggle-comments').click();
-
- expect(vm.toggleFileDiscussionWrappers).toHaveBeenCalled();
- });
- });
- });
-
- describe('file actions', () => {
- it('should not render if diff file has a submodule', () => {
- props.diffFile.submodule = 'submodule';
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector('.file-actions')).toEqual(null);
- });
-
- it('should not render if add merge request buttons is false', () => {
- props.addMergeRequestButtons = false;
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector('.file-actions')).toEqual(null);
- });
-
- describe('with add merge request buttons enabled', () => {
- beforeEach(() => {
- props.addMergeRequestButtons = true;
- props.diffFile.edit_path = 'edit-path';
- });
-
- const viewReplacedFileButton = () => vm.$el.querySelector('.js-view-replaced-file');
- const viewFileButton = () => vm.$el.querySelector('.js-view-file-button');
- const externalUrl = () => vm.$el.querySelector('.js-external-url');
-
- it('should render if add merge request buttons is true and diff file does not have a submodule', () => {
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector('.file-actions')).not.toEqual(null);
- });
-
- it('should not render view replaced file button if no replaced view path is present', () => {
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(viewReplacedFileButton()).toEqual(null);
- });
-
- it('should render view replaced file button if replaced view path is present', () => {
- props.diffFile.replaced_view_path = 'replaced-view-path';
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(viewReplacedFileButton()).not.toEqual(null);
- expect(viewReplacedFileButton().getAttribute('href')).toBe('replaced-view-path');
- });
-
- it('should render correct file view button path', () => {
- props.diffFile.view_path = 'view-path';
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(viewFileButton().getAttribute('href')).toBe('view-path');
- expect(viewFileButton().getAttribute('data-original-title')).toEqual(
- `View file @ ${props.diffFile.content_sha.substr(0, 8)}`,
- );
- });
-
- it('should not render external url view link if diff file has no external url', () => {
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(externalUrl()).toEqual(null);
- });
-
- it('should render external url view link if diff file has external url', () => {
- props.diffFile.external_url = 'external_url';
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(externalUrl()).not.toEqual(null);
- expect(externalUrl().getAttribute('href')).toBe('external_url');
- });
- });
-
- describe('without file blob', () => {
- beforeEach(() => {
- props.diffFile.blob = null;
- props.addMergeRequestButtons = true;
- vm = mountComponentWithStore(Component, { props, store });
- });
-
- it('should not render toggle discussions button', () => {
- expect(vm.$el.querySelector('.js-btn-vue-toggle-comments')).toEqual(null);
- });
-
- it('should not render edit button', () => {
- expect(vm.$el.querySelector('.js-edit-blob')).toEqual(null);
- });
- });
- });
- });
-
- describe('expand full file button', () => {
- beforeEach(() => {
- props.addMergeRequestButtons = true;
- props.diffFile.edit_path = '/';
- });
-
- it('does not render button', () => {
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector('.js-expand-file')).toBe(null);
- });
-
- it('renders button', () => {
- props.diffFile.is_fully_expanded = false;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector('.js-expand-file')).not.toBe(null);
- });
-
- it('shows fully expanded text', () => {
- props.diffFile.is_fully_expanded = false;
- props.diffFile.isShowingFullFile = true;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector('.ic-doc-changes')).not.toBeNull();
- });
-
- it('shows expand text', () => {
- props.diffFile.is_fully_expanded = false;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector('.ic-doc-expand')).not.toBeNull();
- });
-
- it('renders loading icon', () => {
- props.diffFile.is_fully_expanded = false;
- props.diffFile.isLoadingFullFile = true;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- expect(vm.$el.querySelector('.js-expand-file .loading-container')).not.toBe(null);
- });
-
- it('calls toggleFullDiff on click', () => {
- props.diffFile.is_fully_expanded = false;
-
- vm = mountComponentWithStore(Component, { props, store });
-
- spyOn(vm.$store, 'dispatch').and.stub();
-
- vm.$el.querySelector('.js-expand-file').click();
-
- expect(vm.$store.dispatch).toHaveBeenCalledWith(
- 'diffs/toggleFullDiff',
- props.diffFile.file_path,
- );
- });
- });
-});
diff --git a/spec/javascripts/diffs/store/actions_spec.js b/spec/javascripts/diffs/store/actions_spec.js
index 5806cb47034..874891fcc6e 100644
--- a/spec/javascripts/diffs/store/actions_spec.js
+++ b/spec/javascripts/diffs/store/actions_spec.js
@@ -206,7 +206,7 @@ describe('DiffsStoreActions', () => {
position_type: 'text',
},
},
- hash: 'diff-content-1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a',
+ hash: 'ABC_123',
},
},
],
diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js
index 74805ca8c00..ea5c57b8a7c 100644
--- a/spec/javascripts/notes/components/noteable_discussion_spec.js
+++ b/spec/javascripts/notes/components/noteable_discussion_spec.js
@@ -255,6 +255,10 @@ describe('noteable_discussion component', () => {
discussion.notes = discussion.notes.map(note => ({
...note,
resolved: false,
+ current_user: {
+ ...note.current_user,
+ can_resolve: true,
+ },
}));
wrapper.setProps({ discussion });
diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js
index 3812d46f838..f0e58cbda4d 100644
--- a/spec/javascripts/notes/mock_data.js
+++ b/spec/javascripts/notes/mock_data.js
@@ -200,6 +200,7 @@ export const discussionMock = {
current_user: {
can_edit: true,
can_award_emoji: true,
+ can_resolve: true,
},
discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
emoji_awardable: true,
@@ -246,6 +247,7 @@ export const discussionMock = {
current_user: {
can_edit: true,
can_award_emoji: true,
+ can_resolve: true,
},
discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
emoji_awardable: true,
@@ -292,6 +294,7 @@ export const discussionMock = {
current_user: {
can_edit: true,
can_award_emoji: true,
+ can_resolve: true,
},
discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
emoji_awardable: true,
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
index fe831094ecf..67e85763fae 100644
--- a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
@@ -69,7 +69,6 @@ describe('MRWidgetPipeline', () => {
vm = mountComponent(Component, {
pipeline: mockData.pipeline,
hasCi: true,
- ciStatus: null,
troubleshootingDocsPath: 'help',
});
@@ -208,71 +207,66 @@ describe('MRWidgetPipeline', () => {
});
});
- describe('without pipeline.merge_request', () => {
- it('should render info that includes the commit and branch details', () => {
- const mockCopy = JSON.parse(JSON.stringify(mockData));
- delete mockCopy.pipeline.merge_request;
- const { pipeline } = mockCopy;
-
- vm = mountComponent(Component, {
- pipeline,
- hasCi: true,
- ciStatus: 'success',
- troubleshootingDocsPath: 'help',
- sourceBranchLink: mockCopy.source_branch_link,
- });
-
- const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id} on ${mockCopy.source_branch_link}`;
+ describe('for each type of pipeline', () => {
+ let pipeline;
- const actual = trimText(vm.$el.querySelector('.js-pipeline-info-container').innerText);
+ beforeEach(() => {
+ ({ pipeline } = JSON.parse(JSON.stringify(mockData)));
- expect(actual).toBe(expected);
+ pipeline.details.name = 'Pipeline';
+ pipeline.merge_request_event_type = undefined;
+ pipeline.ref.tag = false;
+ pipeline.ref.branch = false;
});
- });
-
- describe('with pipeline.merge_request and flags.merge_request_pipeline', () => {
- it('should render info that includes the commit, MR, source branch, and target branch details', () => {
- const mockCopy = JSON.parse(JSON.stringify(mockData));
- const { pipeline } = mockCopy;
- pipeline.flags.merge_request_pipeline = true;
- pipeline.flags.detached_merge_request_pipeline = false;
+ const factory = () => {
vm = mountComponent(Component, {
pipeline,
hasCi: true,
ciStatus: 'success',
troubleshootingDocsPath: 'help',
- sourceBranchLink: mockCopy.source_branch_link,
+ sourceBranchLink: mockData.source_branch_link,
});
+ };
+
+ describe('for a branch pipeline', () => {
+ it('renders a pipeline widget that reads "Pipeline <ID> <status> for <SHA> on <branch>"', () => {
+ pipeline.ref.branch = true;
- const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id} on !${pipeline.merge_request.iid} with ${pipeline.merge_request.source_branch} into ${pipeline.merge_request.target_branch}`;
+ factory();
- const actual = trimText(vm.$el.querySelector('.js-pipeline-info-container').innerText);
+ const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id} on ${mockData.source_branch_link}`;
+ const actual = trimText(vm.$el.querySelector('.js-pipeline-info-container').innerText);
- expect(actual).toBe(expected);
+ expect(actual).toBe(expected);
+ });
});
- });
- describe('with pipeline.merge_request and flags.detached_merge_request_pipeline', () => {
- it('should render info that includes the commit, MR, and source branch details', () => {
- const mockCopy = JSON.parse(JSON.stringify(mockData));
- const { pipeline } = mockCopy;
- pipeline.flags.merge_request_pipeline = false;
- pipeline.flags.detached_merge_request_pipeline = true;
+ describe('for a tag pipeline', () => {
+ it('renders a pipeline widget that reads "Pipeline <ID> <status> for <SHA> on <branch>"', () => {
+ pipeline.ref.tag = true;
- vm = mountComponent(Component, {
- pipeline,
- hasCi: true,
- ciStatus: 'success',
- troubleshootingDocsPath: 'help',
- sourceBranchLink: mockCopy.source_branch_link,
+ factory();
+
+ const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`;
+ const actual = trimText(vm.$el.querySelector('.js-pipeline-info-container').innerText);
+
+ expect(actual).toBe(expected);
});
+ });
+
+ describe('for a detached merge request pipeline', () => {
+ it('renders a pipeline widget that reads "Detached merge request pipeline <ID> <status> for <SHA>"', () => {
+ pipeline.details.name = 'Detached merge request pipeline';
+ pipeline.merge_request_event_type = 'detached';
- const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id} on !${pipeline.merge_request.iid} with ${pipeline.merge_request.source_branch}`;
+ factory();
- const actual = trimText(vm.$el.querySelector('.js-pipeline-info-container').innerText);
+ const expected = `Detached merge request pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`;
+ const actual = trimText(vm.$el.querySelector('.js-pipeline-info-container').innerText);
- expect(actual).toBe(expected);
+ expect(actual).toBe(expected);
+ });
});
});
});
diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js
index a55d5537df7..2f79806652b 100644
--- a/spec/javascripts/vue_mr_widget/mock_data.js
+++ b/spec/javascripts/vue_mr_widget/mock_data.js
@@ -259,6 +259,8 @@ export const mockStore = {
tooltip: 'passed',
},
},
+ flags: {},
+ ref: {},
},
mergePipeline: {
id: 1,
@@ -276,6 +278,8 @@ export const mockStore = {
tooltip: 'passed',
},
},
+ flags: {},
+ ref: {},
},
targetBranch: 'target-branch',
sourceBranch: 'source-branch',
diff --git a/spec/lib/gitlab/auth/user_auth_finders_spec.rb b/spec/lib/gitlab/auth/user_auth_finders_spec.rb
index 41265da97a4..dd8070c1240 100644
--- a/spec/lib/gitlab/auth/user_auth_finders_spec.rb
+++ b/spec/lib/gitlab/auth/user_auth_finders_spec.rb
@@ -115,6 +115,60 @@ describe Gitlab::Auth::UserAuthFinders do
end
end
+ describe '#find_user_from_static_object_token' do
+ context 'when request format is archive' do
+ before do
+ env['SCRIPT_NAME'] = 'project/-/archive/master.zip'
+ end
+
+ context 'when token header param is present' do
+ context 'when token is correct' do
+ it 'returns the user' do
+ request.headers['X-Gitlab-Static-Object-Token'] = user.static_object_token
+
+ expect(find_user_from_static_object_token(:archive)).to eq(user)
+ end
+ end
+
+ context 'when token is incorrect' do
+ it 'returns the user' do
+ request.headers['X-Gitlab-Static-Object-Token'] = 'foobar'
+
+ expect { find_user_from_static_object_token(:archive) }.to raise_error(Gitlab::Auth::UnauthorizedError)
+ end
+ end
+ end
+
+ context 'when token query param is present' do
+ context 'when token is correct' do
+ it 'returns the user' do
+ set_param(:token, user.static_object_token)
+
+ expect(find_user_from_static_object_token(:archive)).to eq(user)
+ end
+ end
+
+ context 'when token is incorrect' do
+ it 'returns the user' do
+ set_param(:token, 'foobar')
+
+ expect { find_user_from_static_object_token(:archive) }.to raise_error(Gitlab::Auth::UnauthorizedError)
+ end
+ end
+ end
+ end
+
+ context 'when request format is not archive' do
+ before do
+ env['script_name'] = 'url'
+ end
+
+ it 'returns nil' do
+ expect(find_user_from_static_object_token(:foo)).to be_nil
+ end
+ end
+ end
+
describe '#find_user_from_access_token' do
let(:personal_access_token) { create(:personal_access_token, user: user) }
diff --git a/spec/lib/gitlab/data_builder/pipeline_spec.rb b/spec/lib/gitlab/data_builder/pipeline_spec.rb
index 4afb7195b7b..931477d19c2 100644
--- a/spec/lib/gitlab/data_builder/pipeline_spec.rb
+++ b/spec/lib/gitlab/data_builder/pipeline_spec.rb
@@ -28,12 +28,14 @@ describe Gitlab::DataBuilder::Pipeline do
expect(attributes[:sha]).to eq(pipeline.sha)
expect(attributes[:tag]).to eq(pipeline.tag)
expect(attributes[:id]).to eq(pipeline.id)
+ expect(attributes[:source]).to eq(pipeline.source)
expect(attributes[:status]).to eq(pipeline.status)
expect(attributes[:detailed_status]).to eq('passed')
expect(build_data).to be_a(Hash)
expect(build_data[:id]).to eq(build.id)
expect(build_data[:status]).to eq(build.status)
expect(project_data).to eq(project.hook_attrs(backward: false))
+ expect(data[:merge_request]).to be_nil
end
context 'pipeline without variables' do
@@ -60,6 +62,22 @@ describe Gitlab::DataBuilder::Pipeline do
it 'returns a source ref' do
expect(attributes[:ref]).to eq(merge_request.source_branch)
end
+
+ it 'returns merge request' do
+ merge_request_attrs = data[:merge_request]
+
+ expect(merge_request_attrs).to be_a(Hash)
+ expect(merge_request_attrs[:id]).to eq(merge_request.id)
+ expect(merge_request_attrs[:iid]).to eq(merge_request.iid)
+ expect(merge_request_attrs[:title]).to eq(merge_request.title)
+ expect(merge_request_attrs[:source_branch]).to eq(merge_request.source_branch)
+ expect(merge_request_attrs[:source_project_id]).to eq(merge_request.source_project_id)
+ expect(merge_request_attrs[:target_branch]).to eq(merge_request.target_branch)
+ expect(merge_request_attrs[:target_project_id]).to eq(merge_request.target_project_id)
+ expect(merge_request_attrs[:state]).to eq(merge_request.state)
+ expect(merge_request_attrs[:merge_status]).to eq(merge_request.merge_status)
+ expect(merge_request_attrs[:url]).to eq("http://localhost/#{merge_request.target_project.full_path}/merge_requests/#{merge_request.iid}")
+ end
end
end
end
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index 4f7a6d102b8..d12f9b9100a 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -48,6 +48,10 @@ describe ApplicationSetting do
it { is_expected.not_to allow_value(nil).for(:outbound_local_requests_whitelist) }
it { is_expected.to allow_value([]).for(:outbound_local_requests_whitelist) }
+ it { is_expected.to allow_value(nil).for(:static_objects_external_storage_url) }
+ it { is_expected.to allow_value(http).for(:static_objects_external_storage_url) }
+ it { is_expected.to allow_value(https).for(:static_objects_external_storage_url) }
+
context "when user accepted let's encrypt terms of service" do
before do
setting.update(lets_encrypt_terms_of_service_accepted: true)
@@ -420,6 +424,16 @@ describe ApplicationSetting do
end
end
end
+
+ context 'static objects external storage' do
+ context 'when URL is set' do
+ before do
+ subject.static_objects_external_storage_url = http
+ end
+
+ it { is_expected.not_to allow_value(nil).for(:static_objects_external_storage_auth_token) }
+ end
+ end
end
context 'restrict creating duplicates' do
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 6722a3c627d..c339fad778b 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -945,6 +945,16 @@ describe User do
end
end
+ describe 'static object token' do
+ it 'ensures a static object token on read' do
+ user = create(:user, static_object_token: nil)
+ static_object_token = user.static_object_token
+
+ expect(static_object_token).not_to be_blank
+ expect(user.reload.static_object_token).to eq static_object_token
+ end
+ end
+
describe '#recently_sent_password_reset?' do
it 'is false when reset_password_sent_at is nil' do
user = build_stubbed(:user, reset_password_sent_at: nil)
diff --git a/spec/support/shared_examples/features/archive_download_buttons_shared_examples.rb b/spec/support/shared_examples/features/archive_download_buttons_shared_examples.rb
new file mode 100644
index 00000000000..920fcbde483
--- /dev/null
+++ b/spec/support/shared_examples/features/archive_download_buttons_shared_examples.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+shared_examples 'archive download buttons' do
+ let(:formats) { %w(zip tar.gz tar.bz2 tar) }
+ let(:path_to_visit) { project_path(project) }
+ let(:ref) { project.default_branch }
+
+ context 'when static objects external storage is enabled' do
+ before do
+ allow_any_instance_of(ApplicationSetting).to receive(:static_objects_external_storage_url).and_return('https://cdn.gitlab.com')
+ visit path_to_visit
+ end
+
+ context 'private project' do
+ it 'shows archive download buttons with external storage URL prepended and user token appended to their href' do
+ formats.each do |format|
+ path = archive_path(project, ref, format)
+ uri = URI('https://cdn.gitlab.com')
+ uri.path = path
+ uri.query = "token=#{user.static_object_token}"
+
+ expect(page).to have_link format, href: uri.to_s
+ end
+ end
+ end
+
+ context 'public project' do
+ let(:project) { create(:project, :repository, :public) }
+
+ it 'shows archive download buttons with external storage URL prepended to their href' do
+ formats.each do |format|
+ path = archive_path(project, ref, format)
+ uri = URI('https://cdn.gitlab.com')
+ uri.path = path
+
+ expect(page).to have_link format, href: uri.to_s
+ end
+ end
+ end
+ end
+
+ context 'when static objects external storage is disabled' do
+ before do
+ visit path_to_visit
+ end
+
+ it 'shows default archive download buttons' do
+ formats.each do |format|
+ path = archive_path(project, ref, format)
+
+ expect(page).to have_link format, href: path
+ end
+ end
+ end
+
+ def archive_path(project, ref, format)
+ project_archive_path(project, id: "#{ref}/#{project.path}-#{ref}", path: nil, format: format)
+ end
+end