Commit f697dc5e authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 874ead9c
......@@ -65,12 +65,6 @@ export default {
modalActionText() {
return this.variableBeingEdited ? __('Update variable') : __('Add variable');
},
primaryAction() {
return {
text: this.modalActionText,
attributes: { variant: 'success', disabled: !this.canSubmit },
};
},
maskedFeedback() {
return __('This variable can not be masked');
},
......@@ -120,6 +114,8 @@ export default {
ref="modal"
:modal-id="$options.modalId"
:title="modalActionText"
static
lazy
@hidden="resetModalHandler"
>
<form>
......@@ -127,7 +123,7 @@ export default {
<gl-form-input
id="ci-variable-key"
v-model="variableData.key"
data-qa-selector="variable_key"
data-qa-selector="ci_variable_key_field"
/>
</gl-form-group>
......@@ -142,7 +138,7 @@ export default {
v-model="variableData.secret_value"
rows="3"
max-rows="6"
data-qa-selector="variable_value"
data-qa-selector="ci_variable_value_field"
/>
</gl-form-group>
......@@ -189,7 +185,7 @@ export default {
<gl-form-checkbox
ref="masked-ci-variable"
v-model="variableData.masked"
data-qa-selector="variable_masked"
data-qa-selector="ci_variable_masked_checkbox"
>
{{ __('Mask variable') }}
<gl-link href="/help/ci/variables/README#masked-variables">
......@@ -218,6 +214,7 @@ export default {
ref="deleteCiVariable"
category="secondary"
variant="danger"
data-qa-selector="ci_variable_delete_button"
@click="deleteVarAndClose"
>{{ __('Delete variable') }}</gl-deprecated-button
>
......@@ -225,6 +222,7 @@ export default {
ref="updateOrAddVariable"
:disabled="!canSubmit"
variant="success"
data-qa-selector="ci_variable_save_button"
@click="updateOrAddVariable"
>{{ modalActionText }}
</gl-deprecated-button>
......
......@@ -26,7 +26,6 @@ export default {
{
key: 'value',
label: s__('CiVariables|Value'),
tdClass: 'qa-ci-variable-input-value',
customStyle: { width: '40%' },
},
{
......@@ -89,6 +88,7 @@ export default {
:fields="fields"
:items="variables"
tbody-tr-class="js-ci-variable-row"
data-qa-selector="ci_variable_table_content"
sort-by="key"
sort-direction="asc"
stacked="lg"
......@@ -150,6 +150,7 @@ export default {
<gl-deprecated-button
ref="edit-ci-variable"
v-gl-modal-directive="$options.modalId"
data-qa-selector="edit_ci_variable_button"
@click="editVariable(item)"
>
<gl-icon :size="$options.iconSize" name="pencil" />
......@@ -168,7 +169,7 @@ export default {
<gl-deprecated-button
v-if="tableIsNotEmpty"
ref="secret-value-reveal-button"
data-qa-selector="reveal_ci_variable_value"
data-qa-selector="reveal_ci_variable_value_button"
class="append-right-8"
@click="toggleValues(!valuesHidden)"
>{{ valuesButtonText }}</gl-deprecated-button
......@@ -176,7 +177,7 @@ export default {
<gl-deprecated-button
ref="add-ci-variable"
v-gl-modal-directive="$options.modalId"
data-qa-selector="add_ci_variable"
data-qa-selector="add_ci_variable_button"
variant="success"
>{{ __('Add Variable') }}</gl-deprecated-button
>
......
......@@ -232,3 +232,11 @@ export const truncateNamespace = (string = '') => {
return namespace;
};
/**
* Tests that the input is a String and has at least
* one non-whitespace character
* @param {String} obj The object to test
* @returns {Boolean}
*/
export const hasContent = obj => isString(obj) && obj.trim() !== '';
<script>
import { mapState, mapActions } from 'vuex';
import { GlDeprecatedButton, GlLink, GlFormInput, GlFormGroup } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { GlNewButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
import { escape as esc } from 'lodash';
import { __, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
......@@ -15,8 +15,7 @@ export default {
components: {
GlFormInput,
GlFormGroup,
GlDeprecatedButton,
GlLink,
GlNewButton,
MarkdownField,
AssetLinksForm,
},
......@@ -27,12 +26,14 @@ export default {
computed: {
...mapState('detail', [
'isFetchingRelease',
'isUpdatingRelease',
'fetchError',
'markdownDocsPath',
'markdownPreviewPath',
'releasesPagePath',
'updateReleaseApiDocsPath',
]),
...mapGetters('detail', ['isValid']),
showForm() {
return !this.isFetchingRelease && !this.fetchError;
},
......@@ -87,6 +88,9 @@ export default {
showAssetLinksForm() {
return this.glFeatures.releaseAssetLinkEditing;
},
isSaveChangesDisabled() {
return this.isUpdatingRelease || !this.isValid;
},
},
created() {
this.fetchRelease();
......@@ -163,17 +167,19 @@ export default {
<asset-links-form v-if="showAssetLinksForm" />
<div class="d-flex pt-3">
<gl-deprecated-button
class="mr-auto js-submit-button"
<gl-new-button
class="mr-auto js-no-auto-disable"
category="primary"
variant="success"
type="submit"
:aria-label="__('Save changes')"
:disabled="isSaveChangesDisabled"
>
{{ __('Save changes') }}
</gl-deprecated-button>
<gl-link :href="cancelPath" class="js-cancel-button btn btn-default">
</gl-new-button>
<gl-new-button :href="cancelPath" class="js-cancel-button">
{{ __('Cancel') }}
</gl-link>
</gl-new-button>
</div>
</form>
</div>
......
<script>
import { mapState, mapActions } from 'vuex';
import { mapState, mapActions, mapGetters } from 'vuex';
import {
GlSprintf,
GlLink,
GlFormGroup,
GlDeprecatedButton,
GlNewButton,
GlIcon,
GlTooltipDirective,
GlFormInput,
......@@ -12,13 +12,14 @@ import {
export default {
name: 'AssetLinksForm',
components: { GlSprintf, GlLink, GlFormGroup, GlDeprecatedButton, GlIcon, GlFormInput },
components: { GlSprintf, GlLink, GlFormGroup, GlNewButton, GlIcon, GlFormInput },
directives: { GlTooltip: GlTooltipDirective },
computed: {
...mapState('detail', ['release', 'releaseAssetsDocsPath']),
...mapGetters('detail', ['validationErrors']),
},
created() {
this.addEmptyAssetLink();
this.ensureAtLeastOneLink();
},
methods: {
...mapActions('detail', [
......@@ -32,6 +33,7 @@ export default {
},
onRemoveClicked(linkId) {
this.removeAssetLink(linkId);
this.ensureAtLeastOneLink();
},
onUrlInput(linkIdToUpdate, newUrl) {
this.updateAssetLinkUrl({ linkIdToUpdate, newUrl });
......@@ -39,6 +41,37 @@ export default {
onLinkTitleInput(linkIdToUpdate, newName) {
this.updateAssetLinkName({ linkIdToUpdate, newName });
},
hasDuplicateUrl(link) {
return Boolean(this.getLinkErrors(link).isDuplicate);
},
hasBadFormat(link) {
return Boolean(this.getLinkErrors(link).isBadFormat);
},
hasEmptyUrl(link) {
return Boolean(this.getLinkErrors(link).isUrlEmpty);
},
hasEmptyName(link) {
return Boolean(this.getLinkErrors(link).isNameEmpty);
},
getLinkErrors(link) {
return this.validationErrors.assets.links[link.id] || {};
},
isUrlValid(link) {
return !this.hasDuplicateUrl(link) && !this.hasBadFormat(link) && !this.hasEmptyUrl(link);
},
isNameValid(link) {
return !this.hasEmptyName(link);
},
/**
* Make sure the form is never completely empty by adding an
* empty row if the form contains 0 links
*/
ensureAtLeastOneLink() {
if (this.release.assets.links.length === 0) {
this.addEmptyAssetLink();
}
},
},
};
</script>
......@@ -69,60 +102,93 @@ export default {
<p>
{{
__(
'Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance.',
'Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance. Duplicate URLs are not allowed.',
)
}}
</p>
<div
v-for="(link, index) in release.assets.links"
:key="link.id"
class="d-flex flex-column flex-sm-row align-items-stretch align-items-sm-end"
class="row flex-column flex-sm-row align-items-stretch align-items-sm-start"
>
<gl-form-group
class="url-field form-group flex-grow-1 mr-sm-4"
class="url-field form-group col"
:label="__('URL')"
:label-for="`asset-url-${index}`"
>
<gl-form-input
:id="`asset-url-${index}`"
ref="urlInput"
:value="link.url"
type="text"
class="form-control"
:state="isUrlValid(link)"
@change="onUrlInput(link.id, $event)"
/>
<template #invalid-feedback>
<span v-if="hasEmptyUrl(link)" class="invalid-feedback d-inline">
{{ __('URL is required') }}
</span>
<span v-else-if="hasBadFormat(link)" class="invalid-feedback d-inline">
<gl-sprintf
:message="
__(
'URL must start with %{codeStart}http://%{codeEnd}, %{codeStart}https://%{codeEnd}, or %{codeStart}ftp://%{codeEnd}',
)
"
>
<template #code="{ content }">
<code>{{ content }}</code>
</template>
</gl-sprintf>
</span>
<span v-else-if="hasDuplicateUrl(link)" class="invalid-feedback d-inline">
{{ __('This URL is already used for another link; duplicate URLs are not allowed') }}
</span>
</template>
</gl-form-group>
<gl-form-group
class="link-title-field flex-grow-1 mr-sm-4"
class="link-title-field col"
:label="__('Link title')"
:label-for="`asset-link-name-${index}`"
>
<gl-form-input
:id="`asset-link-name-${index}`"
ref="nameInput"
:value="link.name"
type="text"
class="form-control"
:state="isNameValid(link)"
@change="onLinkTitleInput(link.id, $event)"
/>
<template v-slot:invalid-feedback>
<span v-if="hasEmptyName(link)" class="invalid-feedback d-inline">
{{ __('Link title is required') }}
</span>
</template>
</gl-form-group>
<gl-deprecated-button
<div class="mb-5 mb-sm-3 mt-sm-4 col col-sm-auto">
<gl-new-button
v-gl-tooltip
class="mb-5 mb-sm-3 flex-grow-0 flex-shrink-0 remove-button"
class="remove-button w-100"
:aria-label="__('Remove asset link')"
:title="__('Remove asset link')"
@click="onRemoveClicked(link.id)"
>
<gl-icon class="m-0" name="remove" />
<gl-icon class="mr-1 mr-sm-0 mb-1" :size="16" name="remove" />
<span class="d-inline d-sm-none">{{ __('Remove asset link') }}</span>
</gl-deprecated-button>
</gl-new-button>
</div>
</div>
<gl-deprecated-button
<gl-new-button
ref="addAnotherLinkButton"
variant="link"
class="align-self-end mb-5 mb-sm-0"
@click="onAddAnotherClicked"
>
{{ __('Add another link') }}
</gl-deprecated-button>
</gl-new-button>
</div>
</template>
import { isEmpty } from 'lodash';
import { hasContent } from '~/lib/utils/text_utility';
/**
* @param {Object} link The link to test
* @returns {Boolean} `true` if the release link is empty, i.e. it has
* empty (or whitespace-only) values for both `url` and `name`.
* Otherwise, `false`.
*/
const isEmptyReleaseLink = l => !/\S/.test(l.url) && !/\S/.test(l.name);
const isEmptyReleaseLink = link => !hasContent(link.url) && !hasContent(link.name);
/** Returns all release links that aren't empty */
export const releaseLinksToCreate = state => {
......@@ -22,3 +26,67 @@ export const releaseLinksToDelete = state => {
return state.originalRelease.assets.links;
};
/** Returns all validation errors on the release object */
export const validationErrors = state => {
const errors = {
assets: {
links: {},
},
};
if (!state.release) {
return errors;
}
// Each key of this object is a URL, and the value is an
// array of Release link objects that share this URL.
// This is used for detecting duplicate URLs.
const urlToLinksMap = new Map();
state.release.assets.links.forEach(link => {
errors.assets.links[link.id] = {};
// Only validate non-empty URLs
if (isEmptyReleaseLink(link)) {
return;
}
if (!hasContent(link.url)) {
errors.assets.links[link.id].isUrlEmpty = true;
}
if (!hasContent(link.name)) {
errors.assets.links[link.id].isNameEmpty = true;
}
const normalizedUrl = link.url.trim().toLowerCase();
// Compare each URL to every other URL and flag any duplicates
if (urlToLinksMap.has(normalizedUrl)) {
// a duplicate URL was found!
// add a validation error for each link that shares this URL
const duplicates = urlToLinksMap.get(normalizedUrl);
duplicates.push(link);
duplicates.forEach(duplicateLink => {
errors.assets.links[duplicateLink.id].isDuplicate = true;
});
} else {
// no duplicate URL was found
urlToLinksMap.set(normalizedUrl, [link]);
}
if (!/^(http|https|ftp):\/\//.test(normalizedUrl)) {
errors.assets.links[link.id].isBadFormat = true;
}
});
return errors;
};
/** Returns whether or not the release object is valid */
export const isValid = (_state, getters) => {
return Object.values(getters.validationErrors.assets.links).every(isEmpty);
};
......@@ -9,6 +9,7 @@ class Import::GithubController < Import::BaseController
before_action :expire_etag_cache, only: [:status, :create]
rescue_from Octokit::Unauthorized, with: :provider_unauthorized
rescue_from Octokit::TooManyRequests, with: :provider_rate_limit
def new
if !ci_cd_only? && github_import_configured? && logged_in_with_provider?
......@@ -142,6 +143,13 @@ class Import::GithubController < Import::BaseController
alert: "Access denied to your #{Gitlab::ImportSources.title(provider.to_s)} account."
end
def provider_rate_limit(exception)
reset_time = Time.at(exception.response_headers['x-ratelimit-reset'].to_i)
session[access_token_key] = nil
redirect_to new_import_url,
alert: _("GitHub API rate limit exceeded. Try again after %{reset_time}") % { reset_time: reset_time }
end
def access_token_key
:"#{provider}_access_token"
end
......@@ -180,7 +188,7 @@ class Import::GithubController < Import::BaseController
end
def client_options
{}
{ wait_for_rate_limit_reset: false }
end
def extra_import_params
......
......@@ -12,6 +12,8 @@ class JiraImportState < ApplicationRecord
belongs_to :user
belongs_to :label
scope :by_jira_project_key, -> (jira_project_key) { where(jira_project_key: jira_project_key) }
validates :project, presence: true
validates :jira_project_key, presence: true
validates :jira_project_name, presence: true
......
......@@ -1190,14 +1190,14 @@ class Project < ApplicationRecord
end
def external_issue_tracker
if has_external_issue_tracker.nil? # To populate existing projects
if has_external_issue_tracker.nil?
cache_has_external_issue_tracker
end
if has_external_issue_tracker?
return @external_issue_tracker if defined?(@external_issue_tracker)
@external_issue_tracker = services.external_issue_trackers.first
strong_memoize(:external_issue_tracker) do
services.external_issue_trackers.first
end
else
nil
end
......@@ -1217,7 +1217,7 @@ class Project < ApplicationRecord
def external_wiki
if has_external_wiki.nil?
cache_has_external_wiki # Populate
cache_has_external_wiki
end
if has_external_wiki
......
......@@ -33,8 +33,10 @@ module JiraImport
end
def build_jira_import
label = create_import_label(project)
project.jira_imports.build(
user: user,
label: label,
jira_project_key: jira_project_key,
# we do not have the jira_project_name or jira_project_xid yet so just set a mock value,
# we will once https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28190
......@@ -43,6 +45,22 @@ module JiraImport
)
end
def create_import_label(project)
label = ::Labels::CreateService.new(build_label_attrs(project)).execute(project: project)
raise Projects::ImportService::Error, _('Failed to create import label for jira import.') if label.blank?
label
end
def build_label_attrs(project)
import_start_time = Time.zone.now
jira_imports_for_project = project.jira_imports.by_jira_project_key(jira_project_key).size + 1
title = "jira-import::#{jira_project_key}-#{jira_imports_for_project}"
description = "Label for issues that were imported from jira on #{import_start_time.strftime('%Y-%m-%d %H:%M:%S')}"
color = "#{Label.color_for(title)}"
{ title: title, description: description, color: color }
end
def validate
return build_error_response(_('Jira import feature is disabled.')) unless project.jira_issues_import_feature_flag_enabled?
return build_error_response(_('You do not have permissions to run the import.')) unless user.can?(:admin_project, project)
......
---
title: API endpoint to create annotations for environments dashboard
merge_request: 29089
author:
type: added
---
title: Adjust label title applied to issues on import from Jira
merge_request: 29246
author:
type: changed
---
title: Better error message when importing a Github project and Github API rate limit is exceeded
merge_request: 28785
author:
type: fixed
---
title: Fix failing ci variable e2e test
merge_request: 25924
author:
type: fixed
# frozen_string_literal: true
class AddIndexToDeploymentsWhereClusterIdIsNotNull < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :deployments, :id, where: 'cluster_id IS NOT NULL', name: 'index_deployments_on_id_where_cluster_id_present'
end
def down
remove_concurrent_index :deployments, :id, where: 'cluster_id IS NOT NULL', name: 'index_deployments_on_id_where_cluster_id_present'
end
end
# 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 BackfillDeploymentClustersFromDeployments < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
MIGRATION = 'BackfillDeploymentClustersFromDeployments'
DELAY_INTERVAL = 2.minutes
BATCH_SIZE = 10_000
disable_ddl_transaction!
class Deployment < ActiveRecord::Base
include EachBatch
default_scope { where('cluster_id IS NOT NULL') }
self.table_name = 'deployments'
end
def up
say "Scheduling `#{MIGRATION}` jobs"
queue_background_migration_jobs_by_range_at_intervals(Deployment, MIGRATION, DELAY_INTERVAL, batch_size: BATCH_SIZE)
end
def down
# NOOP
end
end
......@@ -9087,6 +9087,8 @@ CREATE INDEX index_deployments_on_environment_id_and_status ON public.deployment
CREATE INDEX index_deployments_on_id_and_status ON public.deployments USING btree (id, status);
CREATE INDEX index_deployments_on_id_where_cluster_id_present ON public.deployments USING btree (id) WHERE (cluster_id IS NOT NULL);
CREATE INDEX index_deployments_on_project_id_and_id ON public.deployments USING btree (project_id, id DESC);
CREATE UNIQUE INDEX index_deployments_on_project_id_and_iid ON public.deployments USING btree (project_id, iid);
......@@ -13137,6 +13139,8 @@ COPY "schema_migrations" (version) FROM STDIN;
20200403184110
20200403185127
20200403185422
20200406102111
20200406102120
20200406135648
20200406165950
20200406171857
......
......@@ -152,6 +152,7 @@ module API
mount ::API::Members
mount ::API::MergeRequestDiffs
mount ::API::MergeRequests
mount ::API::Metrics::Dashboard::Annotations
mount ::API::Namespaces
mount ::API::Notes
mount ::API::Discussions
......
# frozen_string_literal: true
module API
module Entities
module Metrics
module Dashboard
class Annotation < Grape::Entity
expose :id
expose :starting_at
expose :ending_at
expose :dashboard_path
expose :description
expose :environment_id
expose :cluster_id
end
end
end
end
end
# frozen_string_literal: true
module API
module Metrics
module Dashboard
class Annotations < Grape::API
desc 'Create a new monitoring dashboard annotation' do
success Entities::Metrics::Dashboard::Annotation
end
params do
requires :starting_at, type: DateTime,
desc: 'Date time indicating starting moment to which the annotation relates.'
optional :ending_at, type: DateTime,
desc: 'Date time indicating ending moment to which the annotation relates.'
requires :dashboard_path, type: String,
desc: 'The path to a file defining the dashboard on which the annotation should be added'
requires :description, type: String, desc: 'The description of the annotation'
end
resource :environments do
post ':id/metrics_dashboard/annotations' do
environment = ::Environment.find(params[:id])
not_found! unless Feature.enabled?(:metrics_dashboard_annotations, environment.project)
forbidden! unless can?(current_user, :create_metrics_dashboard_annotation, environment)
result = ::Metrics::Dashboard::Annotations::CreateService.new(current_user, declared(params).merge(environment: environment)).execute
if result[:status] == :success
present result[:annotation], with: Entities::Metrics::Dashboard::Annotation
else
error!(result, 400)
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
# Backfill deployment_clusters for a range of deployments
class BackfillDeploymentClustersFromDeployments
def perform(start_id, end_id)
ActiveRecord::Base.connection.execute <<~SQL
INSERT INTO deployment_clusters (deployment_id, cluster_id)
SELECT deployments.id, deployments.cluster_id
FROM deployments
WHERE deployments.cluster_id IS NOT NULL
AND deployments.id BETWEEN #{start_id} AND #{end_id}
ON CONFLICT DO NOTHING
SQL
end
end
end
end
......@@ -11,28 +11,19 @@ module Gitlab
end
def execute
create_import_label(project)
cache_import_label(project)
import_jira_labels
end
private
def create_import_label(project)
label = Labels::CreateService.new(build_label_attrs(project)).execute(project: project)
raise Projects::ImportService::Error, _('Failed to create import label for jira import.') unless label
def cache_import_label(project)
label = project.jira_imports.by_jira_project_key(jira_project_key).last.label
raise Projects::ImportService::Error, _('Failed to find import label for jira import.') unless label
JiraImport.cache_import_label_id(project.id, label.id)
end
def build_label_attrs(project)
import_start_time = project&.import_state&.last_update_started_at || Time.now
title = "jira-import-#{import_start_time.strftime('%Y-%m-%d-%H-%M-%S')}"
description = "Label for issues that were imported from jira on #{import_start_time.strftime('%Y-%m-%d %H:%M:%S')}"
color = "#{Label.color_for(title)}"
{ title: title, description: description, color: color }
end
def import_jira_labels
# todo: import jira labels, see https://gitlab.com/gitlab-org/gitlab/-/issues/212651
job_waiter
......
......@@ -6,13 +6,14 @@ module Gitlab
GITHUB_SAFE_REMAINING_REQUESTS = 100
GITHUB_SAFE_SLEEP_TIME = 500
attr_reader :access_token, :host, :api_version
attr_reader :access_token, :host, :api_version, :wait_for_rate_limit_reset
def initialize(access_token, host: nil, api_version: 'v3')
def initialize(access_token, host: nil, api_version: 'v3', wait_for_rate_limit_reset: true)
@access_token = access_token
@host = host.to_s.sub(%r{/+\z}, '')
@api_version = api_version
@users = {}
@wait_for_rate_limit_reset = wait_for_rate_limit_reset
if access_token
::Octokit.auto_paginate = false
......@@ -120,7 +121,7 @@ module Gitlab
end
def request(method, *args, &block)
sleep rate_limit_sleep_time if rate_limit_exceed?
sleep rate_limit_sleep_time if wait_for_rate_limit_reset && rate_limit_exceed?
data = api.__send__(method, *args) # rubocop:disable GitlabSecurity/PublicSend
return data unless data.is_a?(Array)
......
......@@ -8670,6 +8670,9 @@ msgstr ""
msgid "Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later."
msgstr ""
msgid "Failed to find import label for jira import."
msgstr ""
msgid "Failed to get ref."
msgstr ""
......@@ -9723,6 +9726,9 @@ msgstr ""
msgid "Git version"
msgstr ""
msgid "GitHub API rate limit exceeded. Try again after %{reset_time}"
msgstr ""
msgid "GitHub import"
msgstr ""
......@@ -12153,6 +12159,9 @@ msgstr ""
msgid "Link title"
msgstr ""
msgid "Link title is required"
msgstr ""
msgid "Linked emails (%{email_count})"
msgstr ""
......@@ -14996,7 +15005,7 @@ msgstr ""
msgid "Pods in use"
msgstr ""
msgid "Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance."
msgid "Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance. Duplicate URLs are not allowed."
msgstr ""
msgid "Preferences"
......@@ -17199,6 +17208,18 @@ msgstr ""
msgid "Requirement"
msgstr ""
msgid "Requirement %{reference} has been added"
msgstr ""
msgid "Requirement %{reference} has been archived"
msgstr ""
msgid "Requirement %{reference} has been reopened"
msgstr ""
msgid "Requirement %{reference} has been updated"
msgstr ""
msgid "Requirement title cannot have more than %{limit} characters."
msgstr ""
......@@ -20697,6 +20718,9 @@ msgstr ""
msgid "This Project is currently archived and read-only. Please unarchive the project first if you want to resume Pull mirroring"
msgstr ""
msgid "This URL is already used for another link; duplicate URLs are not allowed"
msgstr ""
msgid "This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention."
msgstr ""
......@@ -21793,9 +21817,15 @@ msgstr ""
msgid "URL"
msgstr ""
msgid "URL is required"
msgstr ""
msgid "URL must be a valid url (ex: https://gitlab.com)"
msgstr ""
msgid "URL must start with %{codeStart}http://%{codeEnd}, %{codeStart}https://%{codeEnd}, or %{codeStart}ftp://%{codeEnd}"
msgstr ""
msgid "URL of the external storage that will serve the repository static objects (e.g. archives, blobs, ...)."
msgstr ""
......
......@@ -7,75 +7,47 @@ module QA
class CiVariables < Page::Base
include Common
view 'app/views/ci/variables/_variable_row.html.haml' do
element :variable_row, '.ci-variable-row-body' # rubocop:disable QA/ElementWithPattern
element :variable_key, '.qa-ci-variable-input-key' # rubocop:disable QA/ElementWithPattern
element :variable_value, '.qa-ci-variable-input-value' # rubocop:disable QA/ElementWithPattern
element :variable_masked
view 'app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue' do
element :ci_variable_key_field
element :ci_variable_value_field
element :ci_variable_masked_checkbox
element :ci_variable_save_button
element :ci_variable_delete_button
end
view 'app/views/ci/variables/_index.html.haml' do
element :save_variables, '.js-ci-variables-save-button' # rubocop:disable QA/ElementWithPattern
element :reveal_values, '.js-secret-value-reveal-button' # rubocop:disable QA/ElementWithPattern
view 'app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue' do
element :ci_variable_table_content
element :add_ci_variable_button
element :edit_ci_variable_button
element :reveal_ci_variable_value_button
end
def fill_variable(key, value, masked)
keys = all_elements(:ci_variable_input_key, minimum: 1)
index = keys.size - 1
# After we fill the key, JS would generate another field so
# we need to use the same index to find the corresponding one.
keys[index].set(key)
node = all_elements(:ci_variable_input_value, count: keys.size + 1)[index]
# Simply run `node.set(value)` is too slow for long text here,
# so we need to run JavaScript directly to set the value.
# The code was inspired from:
# https://github.com/teamcapybara/capybara/blob/679548cea10773d45e32808f4d964377cfe5e892/lib/capybara/selenium/node.rb#L217
execute_script("arguments[0].value = #{value.to_json}", node)
masked_node = all_elements(:variable_masked, count: keys.size + 1)[index]
toggle_masked(masked_node, masked)
end
def save_variables
find('.js-ci-variables-save-button').click
end
def reveal_variables
find('.js-secret-value-reveal-button').click
fill_element :ci_variable_key_field, key
fill_element :ci_variable_value_field, value
click_ci_variable_save_button
end
def variable_value(key)
within('.ci-variable-row-body', text: key) do
find('.qa-ci-variable-input-value').value
end
def click_add_variable
click_element :add_ci_variable_button
end
def remove_variable(location: :first)
within('.ci-variable-row-body', match: location) do
find('button.ci-variable-row-remove-button').click
def click_edit_ci_variable
within_element(:ci_variable_table_content) do
click_element :edit_ci_variable_button
end
save_variables
end
private
def toggle_masked(masked_node, masked)
wait_until(reload: false) do
masked_node.click
masked ? masked_enabled?(masked_node) : masked_disabled?(masked_node)
end
def click_ci_variable_save_button
click_element :ci_variable_save_button
end
def masked_enabled?(masked_node)
masked_node[:class].include?('is-checked')
def click_reveal_ci_variable_value_button
click_element :reveal_ci_variable_value_button
end
def masked_disabled?(masked_node)
!masked_enabled?(masked_node)
def click_ci_variable_delete_button
click_element :ci_variable_delete_button
end
end
end
......
......@@ -19,9 +19,8 @@ module QA
Page::Project::Settings::CICD.perform do |setting|
setting.expand_ci_variables do |page|
page.click_add_variable
page.fill_variable(key, value, masked)
page.save_variables
end
end
end
......
......@@ -2,7 +2,7 @@
module QA
context 'Verify' do
describe 'Add or Remove CI variable via UI', :smoke, quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/issues/207915', type: :stale } do
describe 'Add or Remove CI variable via UI', :smoke do
let!(:project) do
Resource::Project.fabricate_via_api! do |project|
project.name = 'project-with-ci-variables'
......@@ -10,6 +10,14 @@ module QA
end
end
before(:all) do
Runtime::Feature.enable_and_verify('new_variables_ui')
end
after(:all) do
Runtime::Feature.remove('new_variables_ui')
end
before do
Flow::Login.sign_in
add_ci_variable
......@@ -19,12 +27,12 @@ module QA
it 'user adds a CI variable' do
Page::Project::Settings::CICD.perform do |settings|
settings.expand_ci_variables do |page|
expect(page).to have_field(with: 'VARIABLE_KEY')
expect(page).not_to have_field(with: 'some_CI_variable')
expect(page).to have_text('VARIABLE_KEY')
expect(page).not_to have_text('some_CI_variable')
page.reveal_variables
page.click_reveal_ci_variable_value_button
expect(page).to have_field(with: 'some_CI_variable')
expect(page).to have_text('some_CI_variable')
end
end
end
......@@ -32,9 +40,10 @@ module QA
it 'user removes a CI variable' do
Page::Project::Settings::CICD.perform do |settings|
settings.expand_ci_variables do |page|
page.remove_variable
page.click_edit_ci_variable
page.click_ci_variable_delete_button
expect(page).not_to have_field(with: 'VARIABLE_KEY')
expect(page).not_to have_text('VARIABLE_KEY')
end
end
end
......
......@@ -14,8 +14,13 @@ module QA
end
def has_successful_build?
# Retry on errors such as:
# Selenium::WebDriver::Error::JavascriptError:
# javascript error: this.each is not a function
Support::Retrier.retry_on_exception(reload_page: page) do
page.has_text?('Finished: SUCCESS')
end
end
def no_failed_status_update?
page.has_no_text?('Failed to update Gitlab commit status')
......
......@@ -748,7 +748,7 @@ describe ApplicationController do
end
end
describe '#current_user_mode', :do_not_mock_admin_mode do
describe '#current_user_mode' do
include_context 'custom session'
controller(described_class) do
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe EnforcesAdminAuthentication, :do_not_mock_admin_mode do
describe EnforcesAdminAuthentication do
include AdminModeHelper
let(:user) { create(:user) }
......
......@@ -180,6 +180,11 @@ describe Groups::Settings::CiCdController do
group.add_owner(user)
end
context 'when admin mode is disabled' do
it { is_expected.to have_gitlab_http_status(:not_found) }
end
context 'when admin mode is enabled', :enable_admin_mode do
it { is_expected.to redirect_to(group_settings_ci_cd_path) }
context 'when service execution went wrong' do
......@@ -210,6 +215,7 @@ describe Groups::Settings::CiCdController do
end
end
end
end
describe 'POST create_deploy_token' do
context 'when ajax_new_deploy_token feature flag is disabled for the project' do
......
......@@ -10,7 +10,12 @@ describe Projects::Clusters::ApplicationsController do
end
shared_examples 'a secure endpoint' do
it { expect { subject }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { subject }.to be_allowed_for(:admin)
end
it 'is denied for admin when admin mode disabled' do
expect { subject }.to be_denied_for(:admin)
end
it { expect { subject }.to be_allowed_for(:owner).of(project) }
it { expect { subject }.to be_allowed_for(:maintainer).of(project) }
it { expect { subject }.to be_denied_for(:developer).of(project) }
......
......@@ -65,7 +65,12 @@ describe Projects::ClustersController do
describe 'security' do
let(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is disabled for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_denied_for(:developer).of(project) }
......@@ -151,7 +156,12 @@ describe Projects::ClustersController do
end
describe 'security' do
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is disabled for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_denied_for(:developer).of(project) }
......@@ -240,7 +250,12 @@ describe Projects::ClustersController do
allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil)
end
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is disabled for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_denied_for(:developer).of(project) }
......@@ -346,7 +361,12 @@ describe Projects::ClustersController do
stub_kubeclient_get_namespace('https://kubernetes.example.com', namespace: 'my-namespace')
end
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is disabled for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_denied_for(:developer).of(project) }
......@@ -414,7 +434,12 @@ describe Projects::ClustersController do
allow(WaitForClusterCreationWorker).to receive(:perform_in)
end
it { expect { post_create_aws }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { post_create_aws }.to be_allowed_for(:admin)
end
it 'is disabled for admin when admin mode disabled' do
expect { post_create_aws }.to be_denied_for(:admin)
end
it { expect { post_create_aws }.to be_allowed_for(:owner).of(project) }
it { expect { post_create_aws }.to be_allowed_for(:maintainer).of(project) }
it { expect { post_create_aws }.to be_denied_for(:developer).of(project) }
......@@ -469,7 +494,12 @@ describe Projects::ClustersController do
end
describe 'security' do
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is disabled for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_denied_for(:developer).of(project) }
......@@ -501,7 +531,12 @@ describe Projects::ClustersController do
end
describe 'security' do
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is disabled for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_denied_for(:developer).of(project) }
......@@ -541,7 +576,12 @@ describe Projects::ClustersController do
end
describe 'security' do
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is disabled for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_denied_for(:developer).of(project) }
......@@ -574,7 +614,12 @@ describe Projects::ClustersController do
end
describe 'security' do
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is disabled for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_denied_for(:developer).of(project) }
......@@ -677,7 +722,12 @@ describe Projects::ClustersController do
describe 'security' do
let_it_be(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) }
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is disabled for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_denied_for(:developer).of(project) }
......@@ -746,7 +796,12 @@ describe Projects::ClustersController do
describe 'security' do
let_it_be(:cluster) { create(:cluster, :provided_by_gcp, :production_environment, projects: [project]) }
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is disabled for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_denied_for(:developer).of(project) }
......
......@@ -163,7 +163,7 @@ describe Projects::DeployKeysController do
end
end
context 'with admin' do
context 'with admin', :enable_admin_mode do
before do
sign_in(admin)
end
......@@ -228,7 +228,7 @@ describe Projects::DeployKeysController do
end
end
context 'with admin' do
context 'with admin', :enable_admin_mode do
before do
sign_in(admin)
end
......@@ -284,7 +284,7 @@ describe Projects::DeployKeysController do
end
end
context 'with admin' do
context 'with admin', :enable_admin_mode do
before do
sign_in(admin)
end
......@@ -311,9 +311,17 @@ describe Projects::DeployKeysController do
context 'public deploy key attached to project' do
let(:extra_params) { deploy_key_params('updated title', '1') }
context 'admin mode disabled' do
it 'does not update the title of the deploy key' do
expect { subject }.not_to change { deploy_key.reload.title }
end
end
context 'admin mode enabled', :enable_admin_mode do
it 'updates the title of the deploy key' do
expect { subject }.to change { deploy_key.reload.title }.to('updated title')
end
end
it 'updates can_push of deploy_keys_project' do
expect { subject }.to change { deploy_keys_project.reload.can_push }.from(false).to(true)
......
......@@ -586,6 +586,7 @@ describe Projects::IssuesController do
expect(assigns(:issues)).to include request_forgery_timing_attack
end
context 'when admin mode is enabled', :enable_admin_mode do
it 'lists confidential issues for admin' do
sign_in(admin)
get_issues
......@@ -593,6 +594,16 @@ describe Projects::IssuesController do
expect(assigns(:issues)).to include unescaped_parameter_value
expect(assigns(:issues)).to include request_forgery_timing_attack
end
end
context 'when admin mode is disabled' do
it 'does not list confidential issues for admin' do
sign_in(admin)
get_issues
expect(assigns(:issues)).to eq [issue]
end
end
def get_issues
get :index,
......@@ -648,6 +659,7 @@ describe Projects::IssuesController do
expect(response).to have_gitlab_http_status http_status[:success]
end
context 'when admin mode is enabled', :enable_admin_mode do
it "returns #{http_status[:success]} for admin" do
sign_in(admin)
go(id: unescaped_parameter_value.to_param)
......@@ -656,6 +668,16 @@ describe Projects::IssuesController do
end
end
context 'when admin mode is disabled' do
xit 'returns 404 for admin' do
sign_in(admin)
go(id: unescaped_parameter_value.to_param)
expect(response).to have_gitlab_http_status :not_found
end
end
end
describe 'PUT #update' do
def update_issue(issue_params: {}, additional_params: {}, id: nil)
id ||= issue.iid
......
......@@ -391,6 +391,15 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do
sign_in(user)
end
context 'when admin mode is disabled' do
it 'settings_path is not available' do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('job/job_details')
expect(json_response['runners']).not_to have_key('settings_path')
end
end
context 'when admin mode is enabled', :enable_admin_mode do
it 'settings_path is available' do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('job/job_details')
......@@ -398,6 +407,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do
end
end
end
end
context 'when no trace is available' do
it 'has_trace is false' do
......
......@@ -39,6 +39,7 @@ describe Projects::MirrorsController do
expect(response).to have_gitlab_http_status(:not_found)
end
context 'when admin mode is enabled', :enable_admin_mode do
it 'allows requests from an admin user' do
user.update!(admin: true)
sign_in(user)
......@@ -47,6 +48,17 @@ describe Projects::MirrorsController do
expect(response).to redirect_to(project_settings_path)
end
end
context 'when admin mode is disabled' do
it 'disallows requests from an admin user' do
user.update!(admin: true)
sign_in(user)
subject_action
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
describe 'Access control' do
......
......@@ -127,7 +127,12 @@ describe Projects::PipelineSchedulesController do
describe 'security' do
let(:schedule) { attributes_for(:ci_pipeline_schedule) }
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is denied for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_allowed_for(:developer).of(project) }
......@@ -279,7 +284,12 @@ describe Projects::PipelineSchedulesController do
describe 'security' do
let(:schedule) { { description: 'updated_desc' } }
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is denied for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_allowed_for(:developer).of(project).own(pipeline_schedule) }
......@@ -343,7 +353,12 @@ describe Projects::PipelineSchedulesController do
end
describe 'security' do
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is denied for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_allowed_for(:developer).of(project).own(pipeline_schedule) }
......@@ -361,7 +376,12 @@ describe Projects::PipelineSchedulesController do
describe 'GET #take_ownership' do
describe 'security' do
it { expect { go }.to be_allowed_for(:admin) }
it 'is allowed for admin when admin mode enabled', :enable_admin_mode do
expect { go }.to be_allowed_for(:admin)
end
it 'is denied for admin when admin mode disabled' do
expect { go }.to be_denied_for(:admin)
end
it { expect { go }.to be_allowed_for(:owner).of(project) }
it { expect { go }.to be_allowed_for(:maintainer).of(project) }
it { expect { go }.to be_allowed_for(:developer).of(project).own(pipeline_schedule) }
......
......@@ -245,6 +245,16 @@ describe Projects::Settings::CiCdController do
context 'and user is an admin' do
let(:user) { create(:admin) }
context 'with admin mode disabled' do
it 'does not set max_artifacts_size' do
subject
project.reload
expect(project.max_artifacts_size).to be_nil
end
end
context 'with admin mode enabled', :enable_admin_mode do
it 'sets max_artifacts_size' do
subject
......@@ -255,6 +265,7 @@ describe Projects::Settings::CiCdController do
end
end
end
end
describe 'POST create_deploy_token' do
context 'when ajax_new_deploy_token feature flag is disabled for the project' do
......
......@@ -362,7 +362,7 @@ describe ProjectsController do
end
describe 'GET edit' do
it 'allows an admin user to access the page' do
it 'allows an admin user to access the page', :enable_admin_mode do
sign_in(create(:user, :admin))
get :edit,
......@@ -531,7 +531,7 @@ describe ProjectsController do
end
end
describe "#update" do
describe "#update", :enable_admin_mode do
render_views
let(:admin) { create(:admin) }
......@@ -672,7 +672,7 @@ describe ProjectsController do
end
end
describe '#transfer' do
describe '#transfer', :enable_admin_mode do
render_views
let(:project) { create(:project, :repository) }
......@@ -720,7 +720,7 @@ describe ProjectsController do
end
end
describe "#destroy" do
describe "#destroy", :enable_admin_mode do
let(:admin) { create(:admin) }
it "redirects to the dashboard", :sidekiq_might_not_need_inline do
......@@ -1094,7 +1094,7 @@ describe ProjectsController do
end
end
context 'for a DELETE request' do
context 'for a DELETE request', :enable_admin_mode do
before do
sign_in(create(:admin))
end
......
......@@ -224,4 +224,18 @@ describe('text_utility', () => {
});
});
});
describe('hasContent', () => {
it.each`
txt | result
${null} | ${false}
${undefined} | ${false}
${{ an: 'object' }} | ${false}
${''} | ${false}
${' \t\r\n'} | ${false}
${'hello'} | ${true}
`('returns $result for input $txt', ({ result, txt }) => {
expect(textUtils.hasContent(txt)).toEqual(result);
});
});
});
......@@ -5,14 +5,16 @@ import { release as originalRelease } from '../mock_data';
import * as commonUtils from '~/lib/utils/common_utils';
import { BACK_URL_PARAM } from '~/releases/constants';
import AssetLinksForm from '~/releases/components/asset_links_form.vue';
import { merge } from 'lodash';
describe('Release edit component', () => {
let wrapper;
let release;
let actions;
let getters;
let state;
const factory = (featureFlags = {}) => {
const factory = ({ featureFlags = {}, store: storeUpdates = {} } = {}) => {
state = {
release,
markdownDocsPath: 'path/to/markdown/docs',
......@@ -26,15 +28,30 @@ describe('Release edit component', () => {
addEmptyAssetLink: jest.fn(),
};
const store = new Vuex.Store({
getters = {
isValid: () => true,
validationErrors: () => ({
assets: {
links: [],
},
}),
};
const store = new Vuex.Store(
merge(
{
modules: {
detail: {
namespaced: true,
actions,
state,
getters,
},
},
});
},
storeUpdates,
),
);
wrapper = mount(ReleaseEditApp, {
store,
......@@ -55,6 +72,8 @@ describe('Release edit component', () => {
wrapper = null;
});
const findSubmitButton = () => wrapper.find('button[type=submit]');
describe(`basic functionality tests: all tests unrelated to the "${BACK_URL_PARAM}" parameter`, () => {
beforeEach(() => {
factory();
......@@ -101,7 +120,7 @@ describe('Release edit component', () => {
});
it('renders the "Save changes" button as type="submit"', () => {
expect(wrapper.find('.js-submit-button').attributes('type')).toBe('submit');
expect(findSubmitButton().attributes('type')).toBe('submit');
});
it('calls updateRelease when the form is submitted', () => {
......@@ -143,7 +162,7 @@ describe('Release edit component', () => {
describe('when the release_asset_link_editing feature flag is disabled', () => {
beforeEach(() => {
factory({ releaseAssetLinkEditing: false });
factory({ featureFlags: { releaseAssetLinkEditing: false } });
});
it('does not render the asset links portion of the form', () => {
......@@ -153,7 +172,7 @@ describe('Release edit component', () => {
describe('when the release_asset_link_editing feature flag is enabled', () => {
beforeEach(() => {
factory({ releaseAssetLinkEditing: true });
factory({ featureFlags: { releaseAssetLinkEditing: true } });
});
it('renders the asset links portion of the form', () => {
......@@ -161,4 +180,46 @@ describe('Release edit component', () => {
});
});
});
describe('validation', () => {
describe('when the form is valid', () => {
beforeEach(() => {
factory({
store: {
modules: {
detail: {
getters: {
isValid: () => true,
},
},
},
},
});
});
it('renders the submit button as enabled', () => {
expect(findSubmitButton().attributes('disabled')).toBeUndefined();
});
});
describe('when the form is invalid', () => {
beforeEach(() => {
factory({
store: {
modules: {
detail: {
getters: {
isValid: () => false,
},
},
},
},
});
});
it('renders the submit button as disabled', () => {
expect(findSubmitButton().attributes('disabled')).toBe('disabled');
});
});
});
});
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import AssetLinksForm from '~/releases/components/asset_links_form.vue';
import { release as originalRelease } from '../mock_data';
import * as commonUtils from '~/lib/utils/common_utils';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Release edit component', () => {
let wrapper;
let release;
let actions;
let getters;
let state;
const factory = ({ release: overriddenRelease, linkErrors } = {}) => {
state = {
release: overriddenRelease || release,
releaseAssetsDocsPath: 'path/to/release/assets/docs',
};
actions = {
addEmptyAssetLink: jest.fn(),
updateAssetLinkUrl: jest.fn(),
updateAssetLinkName: jest.fn(),
removeAssetLink: jest.fn().mockImplementation((_context, linkId) => {
state.release.assets.links = state.release.assets.links.filter(l => l.id !== linkId);
}),
};
getters = {
validationErrors: () => ({
assets: {
links: linkErrors || {},
},
}),
};
const store = new Vuex.Store({
modules: {
detail: {
namespaced: true,
actions,
state,
getters,
},
},
});
wrapper = mount(AssetLinksForm, {
localVue,
store,
});
};
beforeEach(() => {
release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true });
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with a basic store state', () => {
beforeEach(() => {
factory();
});
it('calls the "addEmptyAssetLink" store method when the "Add another link" button is clicked', () => {
expect(actions.addEmptyAssetLink).not.toHaveBeenCalled();
wrapper.find({ ref: 'addAnotherLinkButton' }).vm.$emit('click');
expect(actions.addEmptyAssetLink).toHaveBeenCalledTimes(1);
});
it('calls the "removeAssetLinks" store method when the remove button is clicked', () => {
expect(actions.removeAssetLink).not.toHaveBeenCalled();
wrapper.find('.remove-button').vm.$emit('click');
expect(actions.removeAssetLink).toHaveBeenCalledTimes(1);
});
it('calls the "updateAssetLinkUrl" store method when text is entered into the "URL" input field', () => {
const linkIdToUpdate = release.assets.links[0].id;
const newUrl = 'updated url';
expect(actions.updateAssetLinkUrl).not.toHaveBeenCalled();
wrapper.find({ ref: 'urlInput' }).vm.$emit('change', newUrl);
expect(actions.updateAssetLinkUrl).toHaveBeenCalledTimes(1);
expect(actions.updateAssetLinkUrl).toHaveBeenCalledWith(
expect.anything(),
{
linkIdToUpdate,
newUrl,
},
undefined,
);
});
it('calls the "updateAssetLinName" store method when text is entered into the "Link title" input field', () => {
const linkIdToUpdate = release.assets.links[0].id;
const newName = 'updated name';
expect(actions.updateAssetLinkName).not.toHaveBeenCalled();
wrapper.find({ ref: 'nameInput' }).vm.$emit('change', newName);
expect(actions.updateAssetLinkName).toHaveBeenCalledTimes(1);
expect(actions.updateAssetLinkName).toHaveBeenCalledWith(
expect.anything(),
{
linkIdToUpdate,
newName,
},
undefined,
);
});
});
describe('validation', () => {
let linkId;
beforeEach(() => {
linkId = release.assets.links[0].id;
});
const findUrlValidationMessage = () => wrapper.find('.url-field .invalid-feedback');
const findNameValidationMessage = () => wrapper.find('.link-title-field .invalid-feedback');
it('does not show any validation messages if there are no validation errors', () => {
factory();
expect(findUrlValidationMessage().exists()).toBe(false);
expect(findNameValidationMessage().exists()).toBe(false);
});
it('shows a validation error message when two links have the same URLs', () => {
factory({
linkErrors: {
[linkId]: { isDuplicate: true },
},
});
expect(findUrlValidationMessage().text()).toBe(
'This URL is already used for another link; duplicate URLs are not allowed',
);
});
it('shows a validation error message when a URL has a bad format', () => {
factory({
linkErrors: {
[linkId]: { isBadFormat: true },
},
});
expect(findUrlValidationMessage().text()).toBe(
'URL must start with http://, https://, or ftp://',
);
});
it('shows a validation error message when the URL is empty (and the title is not empty)', () => {
factory({
linkErrors: {
[linkId]: { isUrlEmpty: true },
},
});
expect(findUrlValidationMessage().text()).toBe('URL is required');
});
it('shows a validation error message when the title is empty (and the URL is not empty)', () => {
factory({
linkErrors: {
[linkId]: { isNameEmpty: true },
},
});
expect(findNameValidationMessage().text()).toBe('Link title is required');
});
});
describe('empty state', () => {
describe('when the release fetched from the API has no links', () => {
beforeEach(() => {
factory({
release: {
...release,
assets: {
links: [],
},
},
});
});
it('calls the addEmptyAssetLink store method when the component is created', () => {
expect(actions.addEmptyAssetLink).toHaveBeenCalledTimes(1);
});
});
describe('when the release fetched from the API has one link', () => {
beforeEach(() => {
factory({
release: {
...release,
assets: {
links: release.assets.links.slice(0, 1),
},
},
});
});
it('does not call the addEmptyAssetLink store method when the component is created', () => {
expect(actions.addEmptyAssetLink).not.toHaveBeenCalled();
});
it('calls addEmptyAssetLink when the final link is deleted by the user', () => {
wrapper.find('.remove-button').vm.$emit('click');
expect(actions.addEmptyAssetLink).toHaveBeenCalledTimes(1);
});
});
});
});
......@@ -56,4 +56,158 @@ describe('Release detail getters', () => {
expect(getters.releaseLinksToDelete(state)).toEqual(originalLinks);
});
});
describe('validationErrors', () => {
describe('when the form is valid', () => {
it('returns no validation errors', () => {
const state = {
release: {
assets: {
links: [
{ id: 1, url: 'https://example.com/valid', name: 'Link 1' },
{ id: 2, url: '', name: '' },
{ id: 3, url: '', name: ' ' },
{ id: 4, url: ' ', name: '' },
{ id: 5, url: ' ', name: ' ' },
],
},
},
};
const expectedErrors = {
assets: {
links: {
1: {},
2: {},
3: {},
4: {},
5: {},
},
},
};
expect(getters.validationErrors(state)).toEqual(expectedErrors);
});
});
describe('when the form is invalid', () => {
let actualErrors;
beforeEach(() => {
const state = {
release: {
assets: {
links: [
// Duplicate URLs
{ id: 1, url: 'https://example.com/duplicate', name: 'Link 1' },
{ id: 2, url: 'https://example.com/duplicate', name: 'Link 2' },
// the validation check ignores leading/trailing
// whitespace and is case-insensitive
{ id: 3, url: ' \tHTTPS://EXAMPLE.COM/DUPLICATE\n\r\n ', name: 'Link 3' },
// Invalid URL format
{ id: 4, url: 'invalid', name: 'Link 4' },
// Missing URL
{ id: 5, url: '', name: 'Link 5' },
{ id: 6, url: ' ', name: 'Link 6' },
// Missing title
{ id: 7, url: 'https://example.com/valid/1', name: '' },
{ id: 8, url: 'https://example.com/valid/2', name: ' ' },
],
},
},
};
actualErrors = getters.validationErrors(state);
});
it('returns a validation errors if links share a URL', () => {
const expectedErrors = {
assets: {
links: {
1: { isDuplicate: true },
2: { isDuplicate: true },
3: { isDuplicate: true },
},
},
};
expect(actualErrors).toMatchObject(expectedErrors);
});
it('returns a validation error if the URL is in the wrong format', () => {
const expectedErrors = {
assets: {
links: {
4: { isBadFormat: true },
},
},
};
expect(actualErrors).toMatchObject(expectedErrors);
});
it('returns a validation error if the URL missing (and the title is populated)', () => {
const expectedErrors = {
assets: {
links: {
6: { isUrlEmpty: true },
5: { isUrlEmpty: true },
},
},
};
expect(actualErrors).toMatchObject(expectedErrors);
});
it('returns a validation error if the title missing (and the URL is populated)', () => {
const expectedErrors = {
assets: {
links: {
7: { isNameEmpty: true },
8: { isNameEmpty: true },
},
},
};
expect(actualErrors).toMatchObject(expectedErrors);
});
});
});
describe('isValid', () => {
// the value of state is not actually used by this getter
const state = {};
it('returns true when the form is valid', () => {
const mockGetters = {
validationErrors: {
assets: {
links: {
1: {},
},
},
},
};
expect(getters.isValid(state, mockGetters)).toBe(true);
});
it('returns false when the form is invalid', () => {
const mockGetters = {
validationErrors: {
assets: {
links: {
1: { isNameEmpty: true },
},
},
},
};
expect(getters.isValid(state, mockGetters)).toBe(false);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::BackgroundMigration::BackfillDeploymentClustersFromDeployments, :migration, schema: 20200227140242 do
subject { described_class.new }
describe '#perform' do
it 'backfills deployment_cluster for all deployments in the given range with a non-null cluster_id' do
deployment_clusters = table(:deployment_clusters)
namespace = table(:namespaces).create(name: 'the-namespace', path: 'the-path')
project = table(:projects).create(name: 'the-project', namespace_id: namespace.id)
environment = table(:environments).create(name: 'the-environment', project_id: project.id, slug: 'slug')
cluster = table(:clusters).create(name: 'the-cluster')
deployment_data = { cluster_id: cluster.id, project_id: project.id, environment_id: environment.id, ref: 'abc', tag: false, sha: 'sha', status: 1 }
expected_deployment_1 = create_deployment(**deployment_data)
create_deployment(**deployment_data, cluster_id: nil) # no cluster_id
expected_deployment_2 = create_deployment(**deployment_data)
out_of_range_deployment = create_deployment(**deployment_data, cluster_id: cluster.id) # expected to be out of range
# to test "ON CONFLICT DO NOTHING"
existing_record_for_deployment_2 = deployment_clusters.create(
deployment_id: expected_deployment_2.id,
cluster_id: expected_deployment_2.cluster_id,
kubernetes_namespace: 'production'
)
subject.perform(expected_deployment_1.id, out_of_range_deployment.id - 1)
expect(deployment_clusters.all.pluck(:deployment_id, :cluster_id, :kubernetes_namespace)).to contain_exactly(
[expected_deployment_1.id, cluster.id, nil],
[expected_deployment_2.id, cluster.id, existing_record_for_deployment_2.kubernetes_namespace]
)
end
def create_deployment(**data)
@iid ||= 0
@iid += 1
table(:deployments).create(iid: @iid, **data)
end
end
end
......@@ -5,7 +5,6 @@ require 'spec_helper'
describe Gitlab::JiraImport::LabelsImporter do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:jira_import) { create(:jira_import_state, project: project) }
let_it_be(:jira_service) { create(:jira_service, project: project) }
subject { described_class.new(project).execute }
......@@ -15,29 +14,24 @@ describe Gitlab::JiraImport::LabelsImporter do
end
describe '#execute', :clean_gitlab_redis_cache do
context 'when label creation failes' do
before do
allow_next_instance_of(Labels::CreateService) do |instance|
allow(instance).to receive(:execute).and_return(nil)
end
end
context 'when label is missing from jira import' do
let_it_be(:no_label_jira_import) { create(:jira_import_state, label: nil, project: project) }
it 'raises error' do
expect { subject }.to raise_error(Projects::ImportService::Error, 'Failed to create import label for jira import.')
expect { subject }.to raise_error(Projects::ImportService::Error, 'Failed to find import label for jira import.')
end
end
context 'when label is created successfully' do
it 'creates import label' do
expect { subject }.to change { Label.count }.by(1)
end
context 'when label exists' do
let_it_be(:label) { create(:label) }
let_it_be(:jira_import_with_label) { create(:jira_import_state, label: label, project: project) }
it 'caches import label' do
expect(Gitlab::Cache::Import::Caching.read(Gitlab::JiraImport.import_label_cache_key(project.id))).to be nil
subject
expect(Gitlab::JiraImport.get_import_label_id(project.id).to_i).to be > 0
expect(Gitlab::JiraImport.get_import_label_id(project.id).to_i).to eq(label.id)
end
end
end
......
......@@ -5,8 +5,9 @@ require 'spec_helper'
describe Gitlab::LegacyGithubImport::Client do
let(:token) { '123456' }
let(:github_provider) { Settingslogic.new('app_id' => 'asd123', 'app_secret' => 'asd123', 'name' => 'github', 'args' => { 'client_options' => {} }) }
let(:wait_for_rate_limit_reset) { true }
subject(:client) { described_class.new(token) }
subject(:client) { described_class.new(token, wait_for_rate_limit_reset: wait_for_rate_limit_reset) }
before do
allow(Gitlab.config.omniauth).to receive(:providers).and_return([github_provider])
......@@ -88,10 +89,23 @@ describe Gitlab::LegacyGithubImport::Client do
end
end
context 'github rate limit' do
it 'does not raise error when rate limit is disabled' do
stub_request(:get, /api.github.com/)
allow(client.api).to receive(:rate_limit!).and_raise(Octokit::NotFound)
expect { client.issues {} }.not_to raise_error
expect { client.repos }.not_to raise_error
end
context 'when wait for rate limit is disabled' do
let(:wait_for_rate_limit_reset) { false }
it 'raises the error limit error when requested' do
stub_request(:get, /api.github.com/)
allow(client.api).to receive(:repos).and_raise(Octokit::TooManyRequests)
expect { client.repos }.to raise_error(Octokit::TooManyRequests)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20200406102120_backfill_deployment_clusters_from_deployments.rb')
describe BackfillDeploymentClustersFromDeployments, :migration, :sidekiq, schema: 20200227140242 do
describe '#up' do
it 'schedules BackfillDeploymentClustersFromDeployments background jobs' do
stub_const("#{described_class}::BATCH_SIZE", 2)
namespace = table(:namespaces).create(name: 'the-namespace', path: 'the-path')
project = table(:projects).create(name: 'the-project', namespace_id: namespace.id)
environment = table(:environments).create(name: 'the-environment', project_id: project.id, slug: 'slug')
cluster = table(:clusters).create(name: 'the-cluster')
deployment_data = { cluster_id: cluster.id, project_id: project.id, environment_id: environment.id, ref: 'abc', tag: false, sha: 'sha', status: 1 }
# batch 1
batch_1_begin = create_deployment(**deployment_data)
batch_1_end = create_deployment(**deployment_data)
# value that should not be included due to default scope
create_deployment(**deployment_data, cluster_id: nil)
# batch 2
batch_2_begin = create_deployment(**deployment_data)
batch_2_end = create_deployment(**deployment_data)
Sidekiq::Testing.fake! do
Timecop.freeze do
migrate!
# batch 1
expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, batch_1_begin.id, batch_1_end.id)
# batch 2
expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, batch_2_begin.id, batch_2_end.id)
expect(BackgroundMigrationWorker.jobs.size).to eq(2)
end
end
end
def create_deployment(**data)
@iid ||= 0
@iid += 1
table(:deployments).create(iid: @iid, **data)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe API::Metrics::Dashboard::Annotations do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :private, :repository, namespace: user.namespace) }
let_it_be(:environment) { create(:environment, project: project) }
let(:dashboard) { 'config/prometheus/common_metrics.yml' }
let(:starting_at) { Time.now.iso8601 }
let(:ending_at) { 1.hour.from_now.iso8601 }
let(:params) { attributes_for(:metrics_dashboard_annotation, environment: environment, starting_at: starting_at, ending_at: ending_at, dashboard_path: dashboard)}
describe 'POST /environments/:environment_id/metrics_dashboard/annotations' do
before :all do
project.add_developer(user)
end
context 'feature flag metrics_dashboard_annotations' do
context 'is on' do
before do
stub_feature_flags(metrics_dashboard_annotations: { enabled: true, thing: project })
end
context 'with correct permissions' do
context 'with valid parameters' do
it 'creates a new annotation', :aggregate_failures do
post api("/environments/#{environment.id}/metrics_dashboard/annotations", user), params: params
expect(response).to have_gitlab_http_status(:created)
expect(json_response['environment_id']).to eq(environment.id)
expect(json_response['starting_at'].to_time).to eq(starting_at.to_time)
expect(json_response['ending_at'].to_time).to eq(ending_at.to_time)
expect(json_response['description']).to eq(params[:description])
expect(json_response['dashboard_path']).to eq(dashboard)
end
end
context 'with invalid parameters' do
it 'returns error messsage' do
post api("/environments/#{environment.id}/metrics_dashboard/annotations", user),
params: { dashboard_path: nil, starting_at: nil, description: nil }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to include({ "starting_at" => ["can't be blank"], "description" => ["can't be blank"], "dashboard_path" => ["can't be blank"] })
end
end
context 'with undeclared params' do
before do
params[:undeclared_param] = 'xyz'
end
it 'filters out undeclared params' do
expect(::Metrics::Dashboard::Annotations::CreateService).to receive(:new).with(user, hash_excluding(:undeclared_param))
post api("/environments/#{environment.id}/metrics_dashboard/annotations", user), params: params
end
end
end
context 'without correct permissions' do
let_it_be(:guest) { create(:user) }
before do
project.add_guest(guest)
end
it 'returns error messsage' do
post api("/environments/#{environment.id}/metrics_dashboard/annotations", guest), params: params
expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
context 'is off' do
before do
stub_feature_flags(metrics_dashboard_annotations: { enabled: false, thing: project })
end
it 'returns error messsage' do
post api("/environments/#{environment.id}/metrics_dashboard/annotations", user), params: params
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
end
......@@ -81,6 +81,28 @@ describe JiraImport::StartImportService do
expect(jira_import.jira_project_key).to eq(fake_key)
expect(jira_import.user).to eq(user)
end
it 'creates jira import label' do
expect { subject }.to change { Label.count }.by(1)
end
it 'creates jira label title with correct number' do
jira_import = subject.payload[:import_data]
label_title = "jira-import::#{jira_import.jira_project_key}-1"
expect(jira_import.label.title).to eq(label_title)
end
context 'when multiple jira imports for same jira project' do
let!(:jira_imports) { create_list(:jira_import_state, 3, :finished, project: project, jira_project_key: fake_key)}
it 'creates jira label title with correct number' do
jira_import = subject.payload[:import_data]
label_title = "jira-import::#{jira_import.jira_project_key}-4"
expect(jira_import.label.title).to eq(label_title)
end
end
end
end
end
......
......@@ -91,6 +91,10 @@ RSpec.configure do |config|
match = location.match(%r{/spec/([^/]+)/})
metadata[:type] = match[1].singularize.to_sym if match
end
# Admin controller specs get auto admin mode enabled since they are
# protected by the 'EnforcesAdminAuthentication' concern
metadata[:enable_admin_mode] = true if location =~ %r{(ee)?/spec/controllers/admin/}
end
config.include LicenseHelpers
......@@ -226,7 +230,6 @@ RSpec.configure do |config|
#
# context 'some test in mocked dir', :do_not_mock_admin_mode do ... end
admin_mode_mock_dirs = %w(
./ee/spec/controllers
./ee/spec/elastic_integration
./ee/spec/features
./ee/spec/finders
......@@ -238,7 +241,6 @@ RSpec.configure do |config|
./ee/spec/services
./ee/spec/support/protected_tags
./ee/spec/support/shared_examples
./spec/controllers
./spec/features
./spec/finders
./spec/frontend
......@@ -270,7 +272,7 @@ RSpec.configure do |config|
# context 'some test that requires admin mode', :enable_admin_mode do ... end
#
# See also spec/support/helpers/admin_mode_helpers.rb
if example.metadata[:enable_admin_mode]
if example.metadata[:enable_admin_mode] && !example.metadata[:do_not_mock_admin_mode]
allow_any_instance_of(Gitlab::Auth::CurrentUserMode).to receive(:admin_mode?) do |current_user_mode|
current_user_mode.send(:user)&.admin?
end
......
# frozen_string_literal: true
RSpec.shared_context 'project navbar structure' do
let(:requirements_nav_item) do
{
nav_item: _('Requirements'),
nav_sub_items: [_('List')]
}
end
let(:analytics_nav_item) do
{
nav_item: _('Analytics'),
......@@ -56,7 +49,6 @@ RSpec.shared_context 'project navbar structure' do
nav_item: _('Merge Requests'),
nav_sub_items: []
},
(requirements_nav_item if Gitlab.ee?),
{
nav_item: _('CI / CD'),
nav_sub_items: [
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment