Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-11-08 12:09:27 +00:00
parent e40061efd4
commit 81f062b841
53 changed files with 581 additions and 138 deletions

View File

@ -1913,7 +1913,7 @@ Layout/LineLength:
- 'ee/spec/frontend/fixtures/projects.rb'
- 'ee/spec/graphql/ee/mutations/boards/lists/create_spec.rb'
- 'ee/spec/graphql/ee/resolvers/board_list_issues_resolver_spec.rb'
- 'ee/spec/graphql/ee/resolvers/issues_resolver_spec.rb'
- 'ee/spec/graphql/ee/resolvers/project_issues_resolver_spec.rb'
- 'ee/spec/graphql/ee/types/board_type_spec.rb'
- 'ee/spec/graphql/ee/types/issue_sort_enum_spec.rb'
- 'ee/spec/graphql/ee/types/merge_request_type_spec.rb'
@ -4180,11 +4180,11 @@ Layout/LineLength:
- 'spec/graphql/resolvers/group_issues_resolver_spec.rb'
- 'spec/graphql/resolvers/group_labels_resolver_spec.rb'
- 'spec/graphql/resolvers/issue_status_counts_resolver_spec.rb'
- 'spec/graphql/resolvers/issues_resolver_spec.rb'
- 'spec/graphql/resolvers/merge_requests_resolver_spec.rb'
- 'spec/graphql/resolvers/metrics/dashboard_resolver_spec.rb'
- 'spec/graphql/resolvers/metrics/dashboards/annotation_resolver_spec.rb'
- 'spec/graphql/resolvers/namespace_projects_resolver_spec.rb'
- 'spec/graphql/resolvers/project_issues_resolver_spec.rb'
- 'spec/graphql/resolvers/project_jobs_resolver_spec.rb'
- 'spec/graphql/resolvers/project_resolver_spec.rb'
- 'spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb'

View File

@ -3,8 +3,6 @@ Lint/MissingCopEnableDirective:
Exclude:
- 'app/controllers/admin/users_controller.rb'
- 'app/controllers/projects/forks_controller.rb'
- 'app/graphql/resolvers/group_issues_resolver.rb'
- 'app/graphql/resolvers/issues_resolver.rb'
- 'app/graphql/resolvers/project_members_resolver.rb'
- 'app/graphql/resolvers/project_milestones_resolver.rb'
- 'app/graphql/resolvers/projects/snippets_resolver.rb'

View File

@ -1568,7 +1568,6 @@ RSpec/ContextWording:
- 'spec/graphql/resolvers/group_milestones_resolver_spec.rb'
- 'spec/graphql/resolvers/group_packages_resolver_spec.rb'
- 'spec/graphql/resolvers/issue_status_counts_resolver_spec.rb'
- 'spec/graphql/resolvers/issues_resolver_spec.rb'
- 'spec/graphql/resolvers/kas/agent_configurations_resolver_spec.rb'
- 'spec/graphql/resolvers/kas/agent_connections_resolver_spec.rb'
- 'spec/graphql/resolvers/last_commit_resolver_spec.rb'

View File

@ -1 +1 @@
d4bc56074d6151875943c1b128b89b4f554af68a
7a8f7c377bd013483aba14ced8eafd073c631d4a

View File

@ -1 +1 @@
af0cd47633f6e0a5b8ac349a2584c01164af701a
2a92165653c54fd23ead433e2cb477d6663c607d

View File

@ -20,26 +20,28 @@ export default {
},
},
[
h(
'ul',
{
class: 'gl-p-0 gl-m-0 gl-list-style-none',
},
[
...extensions.map((extension, index) =>
h('li', { attrs: { class: index > 0 && 'mr-widget-border-top' } }, [
h(
{ ...extension },
{
props: {
mr: this.mr,
h('div', { attrs: { class: 'mr-widget-section' } }, [
h(
'ul',
{
class: 'gl-p-0 gl-m-0 gl-list-style-none',
},
[
...extensions.map((extension, index) =>
h('li', { attrs: { class: index > 0 && 'mr-widget-border-top' } }, [
h(
{ ...extension },
{
props: {
mr: this.mr,
},
},
},
),
]),
),
],
),
),
]),
),
],
),
]),
],
);
},

View File

@ -1,6 +1,8 @@
<template>
<div class="mr-widget-heading">
<div class="mr-widget-content"><slot name="default"></slot></div>
<slot name="footer"></slot>
<div class="mr-section-container mr-widget-workflow">
<div class="mr-widget-section">
<div class="mr-widget-content"><slot name="default"></slot></div>
<slot name="footer"></slot>
</div>
</div>
</template>

View File

@ -521,7 +521,7 @@ export default {
<template>
<div
data-testid="ready_to_merge_state"
class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7"
>
<div v-if="loading" class="mr-widget-body">
<div class="gl-w-full mr-ready-to-merge-loader">

View File

@ -550,17 +550,8 @@ export default {
:user-callout-feature-id="mr.suggestPipelineFeatureId"
@dismiss="dismissSuggestPipelines"
/>
<mr-widget-pipeline-container
v-if="shouldRenderPipelines"
class="mr-widget-workflow"
:mr="mr"
/>
<mr-widget-approvals
v-if="shouldRenderApprovals"
class="mr-widget-workflow"
:mr="mr"
:service="service"
/>
<mr-widget-pipeline-container v-if="shouldRenderPipelines" :mr="mr" />
<mr-widget-approvals v-if="shouldRenderApprovals" :mr="mr" :service="service" />
<report-widget-container>
<extensions-container v-if="hasExtensions" :mr="mr" />
<security-reports-app

View File

@ -2,6 +2,7 @@
import { cloneDeep, isEmpty } from 'lodash';
import { GlFormGroup, GlFormInput, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { scrollToElement } from '~/lib/utils/common_utils';
import FormUrlMaskItem from './form_url_mask_item.vue';
@ -31,9 +32,14 @@ export default {
maskEnabled: !isEmpty(this.initialUrlVariables),
url: this.initialUrl,
items: this.getInitialItems(),
isValidated: false,
formEl: null,
};
},
computed: {
urlState() {
return !this.isValidated || !isEmpty(this.url);
},
maskedUrl() {
if (!this.url) {
return null;
@ -46,13 +52,20 @@ export default {
return;
}
const replacementExpression = new RegExp(value, 'g');
maskedUrl = maskedUrl.replace(replacementExpression, `{${key}}`);
maskedUrl = this.maskUrl(maskedUrl, key, value);
});
return maskedUrl;
},
},
mounted() {
this.formEl = document.querySelector('.js-webhook-form');
this.formEl?.addEventListener('submit', this.handleSubmit);
},
destroy() {
this.formEl?.removeEventListener('submit', this.handleSubmit);
},
methods: {
getInitialItems() {
return isEmpty(this.initialUrlVariables) ? [{}] : cloneDeep(this.initialUrlVariables);
@ -64,6 +77,52 @@ export default {
return this.initialUrlVariables.some((item) => item.key === key);
},
keyInvalidFeedback(key) {
if (this.isValidated && isEmpty(key)) {
return this.$options.i18n.inputRequired;
}
return null;
},
valueInvalidFeedback(key, value) {
if (this.isEditingItem(key)) {
return null;
}
if (this.isValidated && isEmpty(value)) {
return this.$options.i18n.inputRequired;
}
return null;
},
isValid() {
this.isValidated = true;
if (!this.urlState) {
return false;
}
if (
this.maskEnabled &&
this.items.some(
({ key, value }) => this.keyInvalidFeedback(key) || this.valueInvalidFeedback(key, value),
)
) {
return false;
}
return true;
},
handleSubmit(e) {
if (!this.isValid()) {
scrollToElement(this.$refs.formUrl.$el);
e.preventDefault();
e.stopPropagation();
}
},
maskUrl(url, key, value) {
return url.split(value).join(`{${key}}`);
},
onItemInput({ index, key, value }) {
this.$set(this.items, index, { key, value });
},
@ -76,6 +135,7 @@ export default {
},
i18n: {
addItem: s__('Webhooks|+ Mask another portion of URL'),
inputRequired: __('This field is required.'),
radioFullUrlText: s__('Webhooks|Show full URL'),
radioMaskUrlText: s__('Webhooks|Mask portions of URL'),
radioMaskUrlHelp: s__('Webhooks|Do not show sensitive data such as tokens in the UI.'),
@ -92,14 +152,18 @@ export default {
<template>
<div>
<gl-form-group
ref="formUrl"
:label="$options.i18n.urlLabel"
label-for="webhook-url"
:description="$options.i18n.urlDescription"
:invalid-feedback="$options.i18n.inputRequired"
:state="urlState"
>
<gl-form-input
id="webhook-url"
v-model="url"
name="hook[url]"
:state="urlState"
:placeholder="$options.i18n.urlPlaceholder"
data-testid="form-url"
/>
@ -123,6 +187,8 @@ export default {
:item-key="key"
:item-value="value"
:is-editing="isEditingItem(key)"
:key-invalid-feedback="keyInvalidFeedback(key)"
:value-invalid-feedback="valueInvalidFeedback(key, value)"
@input="onItemInput"
@remove="removeItem"
/>

View File

@ -1,4 +1,5 @@
<script>
import { isEmpty } from 'lodash';
import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { s__ } from '~/locale';
import { MASK_ITEM_VALUE_HIDDEN } from '../constants';
@ -30,6 +31,16 @@ export default {
required: false,
default: false,
},
keyInvalidFeedback: {
type: String,
required: false,
default: null,
},
valueInvalidFeedback: {
type: String,
required: false,
default: null,
},
},
computed: {
keyInputId() {
@ -38,6 +49,12 @@ export default {
valueInputId() {
return this.inputId('value');
},
keyState() {
return isEmpty(this.keyInvalidFeedback);
},
valueState() {
return isEmpty(this.valueInvalidFeedback);
},
displayValue() {
return this.isEditing ? MASK_ITEM_VALUE_HIDDEN : this.itemValue;
},
@ -67,10 +84,12 @@ export default {
</script>
<template>
<div class="gl-display-flex gl-align-items-flex-end gl-gap-3 gl-mb-3">
<div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-mb-3">
<gl-form-group
:label="$options.i18n.valueLabel"
:label-for="valueInputId"
:invalid-feedback="valueInvalidFeedback"
:state="valueState"
class="gl-flex-grow-1 gl-mb-0"
data-testid="mask-item-value"
>
@ -79,12 +98,15 @@ export default {
:name="inputName('value')"
:value="displayValue"
:disabled="isEditing"
:state="valueState"
@input="onValueInput"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.keyLabel"
:label-for="keyInputId"
:invalid-feedback="keyInvalidFeedback"
:state="keyState"
class="gl-flex-grow-1 gl-mb-0"
data-testid="mask-item-key"
>
@ -93,6 +115,7 @@ export default {
:name="inputName('key')"
:value="itemKey"
:disabled="isEditing"
:state="keyState"
@input="onKeyInput"
/>
</gl-form-group>
@ -100,6 +123,7 @@ export default {
icon="remove"
:aria-label="__('Remove')"
:disabled="isEditing"
class="gl-mt-6"
@click="onRemoveClick"
/>
</div>

View File

@ -2,6 +2,7 @@
import { GlAlert, GlFormGroup, GlForm, GlFormCombobox, GlButton, GlFormInput } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql';
@ -16,6 +17,7 @@ export default {
GlFormGroup,
GlFormInput,
},
mixins: [glFeatureFlagMixin()],
inject: ['projectPath', 'hasIterationsFeature'],
props: {
issuableGid: {
@ -61,6 +63,9 @@ export default {
};
},
computed: {
workItemsMvc2Enabled() {
return this.glFeatures.workItemsMvc2;
},
actionsList() {
return [
{
@ -85,6 +90,9 @@ export default {
parentIterationId() {
return this.parentIteration?.id;
},
associateIteration() {
return this.parentIterationId && this.hasIterationsFeature && this.workItemsMvc2Enabled;
},
},
methods: {
getIdFromGraphQLId,
@ -145,7 +153,7 @@ export default {
* call update mutation only when there is an iteration associated with the issue
*/
// TODO: setting the iteration should be moved to the creation mutation once the backend is done
if (this.parentIterationId && this.hasIterationsFeature) {
if (this.associateIteration) {
this.addIterationToWorkItem(data.workItemCreate.workItem.id);
}
}

View File

@ -481,6 +481,18 @@ $tabs-holder-z-index: 250;
border-radius: $border-radius-default;
background: var(--white, $white);
> .mr-widget-section {
> :first-child {
border-top-left-radius: $border-radius-default - 1px;
border-top-right-radius: $border-radius-default - 1px;
}
> :last-child {
border-bottom-left-radius: $border-radius-default - 1px;
border-bottom-right-radius: $border-radius-default - 1px;
}
}
> .mr-widget-border-top:first-of-type {
border-top: 0;
}
@ -812,6 +824,13 @@ $tabs-holder-z-index: 250;
.mr-widget-border-top {
border-top: 1px solid var(--border-color, $border-color);
&:last-child {
.report-block-container {
border-bottom-left-radius: $border-radius-default - 1px;
border-bottom-right-radius: $border-radius-default - 1px;
}
}
}
.mr-widget-extension {

View File

@ -46,7 +46,8 @@ class IssuableFinder
requires_cross_project_access unless: -> { params.project? }
FULL_TEXT_SEARCH_TERM_REGEX = /\A[\p{ASCII}|\p{Latin}]+\z/.freeze
FULL_TEXT_SEARCH_TERM_PATTERN = '[\u0000-\u02FF\u1E00-\u1EFF\u2070-\u218F]*'
FULL_TEXT_SEARCH_TERM_REGEX = /\A#{FULL_TEXT_SEARCH_TERM_PATTERN}\z/.freeze
NEGATABLE_PARAMS_HELPER_KEYS = %i[project_id scope status include_subgroups].freeze
attr_accessor :current_user, :params

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
# rubocop:disable Graphql/ResolverType (inherited from BaseIssuesResolver)
# rubocop:disable Graphql/ResolverType (inherited from Issues::BaseParentResolver)
module Resolvers
class GroupIssuesResolver < Issues::BaseParentResolver
def self.issuable_collection_name
@ -18,3 +18,4 @@ module Resolvers
end
end
end
# rubocop:enable Graphql/ResolverType

View File

@ -1,8 +0,0 @@
# frozen_string_literal: true
# rubocop:disable Graphql/ResolverType (inherited from BaseIssuesResolver)
module Resolvers
class IssuesResolver < Issues::BaseParentResolver
accept_release_tag
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
# rubocop:disable Graphql/ResolverType (inherited from Issues::BaseParentResolver)
module Resolvers
class ProjectIssuesResolver < Issues::BaseParentResolver
accept_release_tag
end
end
# rubocop:enable Graphql/ResolverType

View File

@ -227,7 +227,7 @@ module Types
null: true,
description: 'Issues of the project.',
extras: [:lookahead],
resolver: Resolvers::IssuesResolver
resolver: Resolvers::ProjectIssuesResolver
field :work_items,
Types::WorkItemType.connection_type,
@ -274,7 +274,7 @@ module Types
Types::IssueType,
null: true,
description: 'A single issue of the project.',
resolver: Resolvers::IssuesResolver.single
resolver: Resolvers::ProjectIssuesResolver.single
field :packages,
description: 'Packages of the project.',

View File

@ -272,6 +272,16 @@ class Issue < ApplicationRecord
def order_upvotes_asc
reorder(upvotes_count: :asc)
end
override :full_search
def full_search(query, matched_columns: nil, use_minimum_char_limit: true)
return super if query.match?(IssuableFinder::FULL_TEXT_SEARCH_TERM_REGEX)
super.where(
'issues.title NOT SIMILAR TO :pattern OR issues.description NOT SIMILAR TO :pattern',
pattern: IssuableFinder::FULL_TEXT_SEARCH_TERM_PATTERN
)
end
end
def self.participant_includes

View File

@ -28,7 +28,7 @@ module Terraform
validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH },
format: { with: HEX_REGEXP, message: 'only allows hex characters' }
default_value_for(:uuid, allows_nil: false) { SecureRandom.hex(UUID_LENGTH / 2) }
attribute :uuid, default: -> { SecureRandom.hex(UUID_LENGTH / 2) }
def latest_file
latest_version&.file

View File

@ -13,7 +13,7 @@ module Terraform
scope :with_files_stored_locally, -> { where(file_store: Terraform::StateUploader::Store::LOCAL) }
scope :preload_state, -> { includes(:terraform_state) }
default_value_for(:file_store) { StateUploader.default_store }
attribute :file_store, default: -> { StateUploader.default_store }
mount_file_store_uploader StateUploader

View File

@ -9,7 +9,7 @@
= render 'shared/web_hooks/title_and_docs', hook: @hook
.col-lg-9.gl-mb-3
= gitlab_ui_form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f|
= gitlab_ui_form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook), html: { class: 'js-webhook-form' } do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
= f.submit _('Save changes'), pajamas_button: true

View File

@ -7,7 +7,7 @@
= render 'shared/web_hooks/title_and_docs', hook: @hook
.col-lg-8.gl-mb-3
= gitlab_ui_form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]) do |f|
= gitlab_ui_form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]), html: { class: 'js-webhook-form' } do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
= f.submit _('Add webhook'), pajamas_button: true, data: { qa_selector: "create_webhook_button" }

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class AddPartialTrigramIndexForIssueTitleAttempt2 < Gitlab::Database::Migration[2.0]
disable_ddl_transaction!
INDEX_NAME = 'index_issues_on_title_trigram_non_latin'
def up
add_concurrent_index :issues, :title,
name: INDEX_NAME,
using: :gin, opclass: { description: :gin_trgm_ops },
where: "title NOT SIMILAR TO '[\\u0000-\\u02FF\\u1E00-\\u1EFF\\u2070-\\u218F]*' " \
"OR description NOT SIMILAR TO '[\\u0000-\\u02FF\\u1E00-\\u1EFF\\u2070-\\u218F]*'"
end
def down
remove_concurrent_index_by_name :issues, INDEX_NAME
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class AddPartialTrigramIndexForIssueDescriptionAttempt2 < Gitlab::Database::Migration[2.0]
disable_ddl_transaction!
INDEX_NAME = 'index_issues_on_description_trigram_non_latin'
def up
add_concurrent_index :issues, :description,
name: INDEX_NAME,
using: :gin, opclass: { description: :gin_trgm_ops },
where: "title NOT SIMILAR TO '[\\u0000-\\u02FF\\u1E00-\\u1EFF\\u2070-\\u218F]*' " \
"OR description NOT SIMILAR TO '[\\u0000-\\u02FF\\u1E00-\\u1EFF\\u2070-\\u218F]*'"
end
def down
remove_concurrent_index_by_name :issues, INDEX_NAME
end
end

View File

@ -0,0 +1 @@
3192407f3034683ba226d651e247385de200a06e26142e87978fa080eecda110

View File

@ -0,0 +1 @@
462fd09ac4c59b9fc3f865e984da4c83c4a75d60e557d634631d5eafd67741cc

View File

@ -29345,6 +29345,8 @@ CREATE INDEX index_issues_on_confidential ON issues USING btree (confidential);
CREATE INDEX index_issues_on_description_trigram ON issues USING gin (description gin_trgm_ops) WITH (fastupdate='false');
CREATE INDEX index_issues_on_description_trigram_non_latin ON issues USING gin (description gin_trgm_ops) WHERE (((title)::text !~ similar_escape('[\u0000-\u02FF\u1E00-\u1EFF\u2070-\u218F]*'::text, NULL::text)) OR (description !~ similar_escape('[\u0000-\u02FF\u1E00-\u1EFF\u2070-\u218F]*'::text, NULL::text)));
CREATE INDEX index_issues_on_duplicated_to_id ON issues USING btree (duplicated_to_id) WHERE (duplicated_to_id IS NOT NULL);
CREATE INDEX index_issues_on_id_and_weight ON issues USING btree (id, weight);
@ -29381,6 +29383,8 @@ CREATE INDEX index_issues_on_sprint_id ON issues USING btree (sprint_id);
CREATE INDEX index_issues_on_title_trigram ON issues USING gin (title gin_trgm_ops) WITH (fastupdate='false');
CREATE INDEX index_issues_on_title_trigram_non_latin ON issues USING gin (title gin_trgm_ops) WHERE (((title)::text !~ similar_escape('[\u0000-\u02FF\u1E00-\u1EFF\u2070-\u218F]*'::text, NULL::text)) OR (description !~ similar_escape('[\u0000-\u02FF\u1E00-\u1EFF\u2070-\u218F]*'::text, NULL::text)));
CREATE INDEX index_issues_on_updated_at ON issues USING btree (updated_at);
CREATE INDEX index_issues_on_updated_by_id ON issues USING btree (updated_by_id) WHERE (updated_by_id IS NOT NULL);

View File

@ -248,7 +248,7 @@ incorrect sort order.
There are times when the [complexity of sorting](#limitations-of-query-complexity)
is more than our keyset pagination can handle.
For example, in [`IssuesResolver`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/graphql/resolvers/issues_resolver.rb),
For example, in [`ProjectIssuesResolver`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/graphql/resolvers/project_issues_resolver.rb),
when sorting by `priority_asc`, we can't use keyset pagination as the ordering is much
too complex. For more information, read [`issuable.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/concerns/issuable.rb).

View File

@ -17,8 +17,8 @@ In case custom inflection logic is needed, custom inflectors are added in the [q
## Link a test to its test case
Every test should have a corresponding test case in the [GitLab project Test Cases](https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases) as well as a results issue in the [Quality Test Cases project](https://gitlab.com/gitlab-org/quality/testcases/-/issues).
If a test case issue does not yet exist you can create one yourself. To do so, create a new
issue in the [Test Cases](https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases) GitLab project
If a test case issue does not yet exist, any GitLab team member can create a new test case in
the [Test Cases](https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases) GitLab project
with a placeholder title. After the test case URL is linked to a test in the code, when the test is
run in a pipeline that has reporting enabled, the `report-results` script automatically updates the
test case and the results issue.

View File

@ -153,11 +153,15 @@ To change the namespace linked to a subscription:
[linked](#change-the-linked-account) GitLab SaaS account.
1. Navigate to the **Manage Purchases** page.
1. Select **Change linked namespace**.
1. Select the desired group from the **This subscription is for** dropdown list. For a group to appear
here, you must have the Owner role
for that group.
1. Select the desired group from the **Select user or group** dropdown list. For a group to appear
here, you must have the Owner role for that group.
1. Select **Proceed to checkout**.
If the group you want to link does not appear in the dropdown list, check:
- You have [linked your Customers Portal account with your GitLab.com account](#change-the-linked-account).
- That the linked account is a member of the group you want to select, and you are assigned the Owner role.
Subscription charges are calculated based on the total number of users in a group, including its subgroups and nested projects. If the [total number of users](gitlab_com/index.md#view-seat-usage) exceeds the number of seats in your subscription, your account is charged for the additional users and you need to pay for the overage before you can change the linked namespace.
Only one namespace can be linked to a subscription.

View File

@ -173,7 +173,7 @@ container_scanning:
before_script:
- ruby -r open-uri -e "IO.copy_stream(URI.open('https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip'), 'awscliv2.zip')"
- unzip awscliv2.zip
- ./aws/install
- sudo ./aws/install
- aws --version
- export AWS_ECR_PASSWORD=$(aws ecr get-login-password --region region)

View File

@ -62,6 +62,10 @@ Contributions per group member are also presented in tabular format. Select a co
![Contribution analytics contributions table](img/group_stats_table.png)
## Contribution analytics GraphQL API
To retrieve metrics for user contributions, use the [GraphQL](../../../api/graphql/reference/index.md#groupcontributions) API.
<!-- ## Troubleshooting
Include any troubleshooting steps that you can foresee. If you know beforehand what issues

View File

@ -196,6 +196,7 @@ module API
mount ::API::MergeRequestDiffs
mount ::API::Metadata
mount ::API::PersonalAccessTokens::SelfInformation
mount ::API::PersonalAccessTokens
mount ::API::ProjectExport
mount ::API::ProjectHooks
mount ::API::ProjectRepositoryStorageMoves
@ -293,7 +294,6 @@ module API
mount ::API::PackageFiles
mount ::API::Pages
mount ::API::PagesDomains
mount ::API::PersonalAccessTokens
mount ::API::ProjectClusters
mount ::API::ProjectContainerRepositories
mount ::API::ProjectDebianDistributions

View File

@ -7,6 +7,9 @@ module API
ci_resource_groups_tags = %w[ci_resource_groups]
RESOURCE_GROUP_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS
.merge(key: API::NO_SLASH_URL_PART_REGEX)
before { authenticate! }
feature_category :continuous_delivery
@ -47,7 +50,7 @@ module API
params do
requires :key, type: String, desc: 'The key of the resource group'
end
get ':id/resource_groups/:key' do
get ':id/resource_groups/:key', requirements: RESOURCE_GROUP_ENDPOINT_REQUIREMENTS do
authorize! :read_resource_group, resource_group
present resource_group, with: Entities::Ci::ResourceGroup
@ -67,7 +70,7 @@ module API
use :pagination
end
get ':id/resource_groups/:key/upcoming_jobs' do
get ':id/resource_groups/:key/upcoming_jobs', requirements: RESOURCE_GROUP_ENDPOINT_REQUIREMENTS do
authorize! :read_resource_group, resource_group
authorize! :read_build, user_project
@ -96,7 +99,7 @@ module API
desc: 'The process mode of the resource group',
values: ::Ci::ResourceGroup.process_modes.keys
end
put ':id/resource_groups/:key' do
put ':id/resource_groups/:key', requirements: RESOURCE_GROUP_ENDPOINT_REQUIREMENTS do
authorize! :update_resource_group, resource_group
if resource_group.update(declared_params(include_missing: false))

View File

@ -6,24 +6,6 @@ module API
feature_category :authentication_and_authorization
desc 'Get all Personal Access Tokens' do
detail 'This feature was added in GitLab 13.3'
success Entities::PersonalAccessToken
end
params do
optional :user_id, type: Integer, desc: 'Filter PATs by User ID'
optional :revoked, type: Boolean, desc: 'Filter PATs where revoked state matches parameter'
optional :state, type: String, desc: 'Filter PATs which are either active or not',
values: %w[active inactive]
optional :created_before, type: DateTime, desc: 'Filter PATs which were created before given datetime'
optional :created_after, type: DateTime, desc: 'Filter PATs which were created after given datetime'
optional :last_used_before, type: DateTime, desc: 'Filter PATs which were used before given datetime'
optional :last_used_after, type: DateTime, desc: 'Filter PATs which were used after given datetime'
optional :search, type: String, desc: 'Filters PATs by its name'
use :pagination
end
before do
authenticate!
restrict_non_admins! unless current_user.can_admin_all_resources?
@ -32,12 +14,47 @@ module API
helpers ::API::Helpers::PersonalAccessTokensHelpers
resources :personal_access_tokens do
desc 'List personal access tokens' do
detail 'Get all personal access tokens the authenticated user has access to.'
is_array true
success Entities::PersonalAccessToken
tags %w[personal_access_tokens]
failure [
{ code: 401, message: 'Unauthorized' }
]
end
params do
optional :user_id, type: Integer, desc: 'Filter PATs by User ID', documentation: { example: 2 }
optional :revoked, type: Boolean, desc: 'Filter PATs where revoked state matches parameter',
documentation: { example: false }
optional :state, type: String, desc: 'Filter PATs which are either active or not',
values: %w[active inactive], documentation: { example: 'active' }
optional :created_before, type: DateTime, desc: 'Filter PATs which were created before given datetime',
documentation: { example: '2022-01-01' }
optional :created_after, type: DateTime, desc: 'Filter PATs which were created after given datetime',
documentation: { example: '2021-01-01' }
optional :last_used_before, type: DateTime, desc: 'Filter PATs which were used before given datetime',
documentation: { example: '2021-01-01' }
optional :last_used_after, type: DateTime, desc: 'Filter PATs which were used after given datetime',
documentation: { example: '2022-01-01' }
optional :search, type: String, desc: 'Filters PATs by its name', documentation: { example: 'token' }
use :pagination
end
get do
tokens = PersonalAccessTokensFinder.new(finder_params(current_user), current_user).execute
present paginate(tokens), with: Entities::PersonalAccessToken
end
desc 'Get single personal access token' do
detail 'Get a personal access token by using the ID of the personal access token.'
success Entities::PersonalAccessToken
failure [
{ code: 401, message: 'Unauthorized' },
{ code: 404, message: 'Not found' }
]
end
get ':id' do
token = PersonalAccessToken.find_by_id(params[:id])
@ -51,6 +68,13 @@ module API
end
end
desc 'Revoke a personal access token' do
detail 'Revoke a personal access token by using the ID of the personal access token.'
success code: 204
failure [
{ code: 400, message: 'Bad Request' }
]
end
delete ':id' do
token = find_token(params[:id])

View File

@ -48,18 +48,16 @@ module QA
end
def verify_protected_branches_import
branches = imported_project.protected_branches.map do |branch|
branch.slice(:name, :allow_force_push, :code_owner_approval_required)
# TODO: Add validation once https://gitlab.com/groups/gitlab-org/-/epics/8585 is closed
# At the moment both options are always set to false regardless of state in github
# allow_force_push: true,
# code_owner_approval_required: true
imported_branches = imported_project.protected_branches.map do |branch|
branch.slice(:name)
end
expect(branches.first).to include(
{
name: 'main'
# TODO: Add validation once https://gitlab.com/groups/gitlab-org/-/epics/8585 is closed
# At the moment both options are always set to false regardless of state in github
# allow_force_push: true,
# code_owner_approval_required: true
}
)
actual_branches = [{ name: 'main' }, { name: 'release' }]
expect(imported_branches).to match_array(actual_branches)
end
def verify_commits_import

View File

@ -74,6 +74,7 @@ else
puts missing_message % missing_testcases.join("\n") unless missing_testcases.empty?
puts format_message % testcase_format_errors.join("\n") unless testcase_format_errors.empty?
puts "\n*** Please link a unique test case from the GitLab project for the errors listed above.\n"
puts " See: https://docs.gitlab.com/ee/development/testing_guide/end_to_end/best_practices.html#link-a-test-to-its-test-case."
puts " See: https://docs.gitlab.com/ee/development/testing_guide/end_to_end/best_practices.html#link-a-test-to-its-test-case"\
" for further details on how to create test cases"
exit 1
end

View File

@ -30,7 +30,7 @@ RSpec.describe 'Subscriptions Content Security Policy' do
p.style_src :self, 'https://some-cdn.test'
end
setup_existing_csp_for_controller(JiraConnect::SubscriptionsController, csp)
setup_csp_for_controller(JiraConnect::SubscriptionsController, csp)
end
it 'appends to CSP directives' do

View File

@ -151,7 +151,7 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js do
# Wait for the `ci_status` and `merge_check` requests
wait_for_requests
page.within('.mr-section-container') do
page.within('.mr-state-widget') do
expect(page).to have_content('Something went wrong. Try again.')
end
end
@ -170,7 +170,7 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js do
# Wait for the `ci_status` and `merge_check` requests
wait_for_requests
page.within('.mr-section-container') do
page.within('.mr-state-widget') do
expect(page).to have_content('Something went wrong. Try again.')
end
end

View File

@ -342,7 +342,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
# Wait for the `ci_status` and `merge_check` requests
wait_for_requests
page.within('.mr-section-container') do
page.within('.mr-state-widget') do
expect(page).to have_content('Something went wrong.')
end
end
@ -363,7 +363,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
# Wait for the `ci_status` and `merge_check` requests
wait_for_requests
page.within('.mr-section-container') do
page.within('.mr-state-widget') do
expect(page).to have_content('Something went wrong.')
end
end

View File

@ -20,7 +20,7 @@ describe('MrWidgetContainer', () => {
it('has layout', () => {
factory();
expect(wrapper.classes()).toContain('mr-widget-heading');
expect(wrapper.classes()).toEqual(['mr-section-container', 'mr-widget-workflow']);
expect(wrapper.find('.mr-widget-content').exists()).toBe(true);
});

View File

@ -1,10 +1,14 @@
import { nextTick } from 'vue';
import { GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
import { GlFormGroup, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
import { scrollToElement } from '~/lib/utils/common_utils';
import FormUrlApp from '~/webhooks/components/form_url_app.vue';
import FormUrlMaskItem from '~/webhooks/components/form_url_mask_item.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
jest.mock('~/lib/utils/common_utils');
describe('FormUrlApp', () => {
let wrapper;
@ -26,8 +30,11 @@ describe('FormUrlApp', () => {
const findAllUrlMaskItems = () => wrapper.findAllComponents(FormUrlMaskItem);
const findAddItem = () => wrapper.findComponent(GlLink);
const findFormUrl = () => wrapper.findByTestId('form-url');
const findFormUrlGroup = () => wrapper.findAllComponents(GlFormGroup).at(0);
const findFormUrlPreview = () => wrapper.findByTestId('form-url-preview');
const findUrlMaskSection = () => wrapper.findByTestId('url-mask-section');
const findFormEl = () => document.querySelector('.js-webhook-form');
const submitForm = () => findFormEl().dispatchEvent(new Event('submit'));
describe('template', () => {
it('renders radio buttons for URL masking', () => {
@ -152,5 +159,82 @@ describe('FormUrlApp', () => {
});
});
});
describe('validations', () => {
const inputRequiredText = FormUrlApp.i18n.inputRequired;
beforeEach(() => {
setHTMLFixture('<form class="js-webhook-form"></form>');
});
afterEach(() => {
resetHTMLFixture();
});
it.each`
url | state | scrollToElementCalls
${null} | ${undefined} | ${1}
${''} | ${undefined} | ${1}
${'https://example.com/'} | ${'true'} | ${0}
`('when URL is `$url`, state is `$state`', async ({ url, state, scrollToElementCalls }) => {
createComponent({
props: { initialUrl: url },
});
submitForm();
await nextTick();
expect(findFormUrlGroup().attributes('state')).toBe(state);
expect(scrollToElement).toHaveBeenCalledTimes(scrollToElementCalls);
expect(findFormUrlGroup().attributes('invalid-feedback')).toBe(inputRequiredText);
});
it.each`
key | value | keyInvalidFeedback | valueInvalidFeedback | scrollToElementCalls
${null} | ${null} | ${inputRequiredText} | ${inputRequiredText} | ${1}
${null} | ${'value'} | ${inputRequiredText} | ${null} | ${1}
${'key'} | ${null} | ${null} | ${inputRequiredText} | ${1}
${'key'} | ${'value'} | ${null} | ${null} | ${0}
`(
'when key is `$key` and value is `$value`',
async ({ key, value, keyInvalidFeedback, valueInvalidFeedback, scrollToElementCalls }) => {
createComponent({
props: { initialUrl: 'url' },
});
findRadioGroup().vm.$emit('input', true);
await nextTick();
const maskItem = findAllUrlMaskItems().at(0);
const mockInput = { index: 0, key, value };
maskItem.vm.$emit('input', mockInput);
submitForm();
await nextTick();
expect(maskItem.props('keyInvalidFeedback')).toBe(keyInvalidFeedback);
expect(maskItem.props('valueInvalidFeedback')).toBe(valueInvalidFeedback);
expect(scrollToElement).toHaveBeenCalledTimes(scrollToElementCalls);
},
);
describe('when initialUrlVariables is passed', () => {
it('does not validate empty values', async () => {
const initialUrlVariables = [{ key: 'key' }];
createComponent({
props: { initialUrl: 'url', initialUrlVariables },
});
submitForm();
await nextTick();
const maskItem = findAllUrlMaskItems().at(0);
expect(maskItem.props('keyInvalidFeedback')).toBeNull();
expect(maskItem.props('valueInvalidFeedback')).toBeNull();
expect(scrollToElement).not.toHaveBeenCalled();
});
});
});
});
});

View File

@ -14,6 +14,7 @@ describe('FormUrlMaskItem', () => {
const mockKey = 'key';
const mockValue = 'value';
const mockInput = 'input';
const mockFeedback = 'feedback';
const createComponent = ({ props } = {}) => {
wrapper = shallowMountExtended(FormUrlMaskItem, {
@ -21,10 +22,6 @@ describe('FormUrlMaskItem', () => {
});
};
afterEach(() => {
wrapper.destroy();
});
const findMaskItemKey = () => wrapper.findByTestId('mask-item-key');
const findMaskItemValue = () => wrapper.findByTestId('mask-item-value');
const findRemoveButton = () => wrapper.findComponent(GlButton);
@ -34,14 +31,20 @@ describe('FormUrlMaskItem', () => {
createComponent({ props: { itemKey: mockKey, itemValue: mockValue } });
const keyInput = findMaskItemKey();
expect(keyInput.attributes('label')).toBe(FormUrlMaskItem.i18n.keyLabel);
expect(keyInput.attributes()).toMatchObject({
label: FormUrlMaskItem.i18n.keyLabel,
state: 'true',
});
expect(keyInput.findComponent(GlFormInput).attributes()).toMatchObject({
name: 'hook[url_variables][][key]',
value: mockKey,
});
const valueInput = findMaskItemValue();
expect(valueInput.attributes('label')).toBe(FormUrlMaskItem.i18n.valueLabel);
expect(valueInput.attributes()).toMatchObject({
label: FormUrlMaskItem.i18n.valueLabel,
state: 'true',
});
expect(valueInput.findComponent(GlFormInput).attributes()).toMatchObject({
name: 'hook[url_variables][][value]',
value: mockValue,
@ -69,6 +72,32 @@ describe('FormUrlMaskItem', () => {
});
});
describe('when keyInvalidFeedback is passed', () => {
beforeEach(() => {
createComponent({
props: { keyInvalidFeedback: mockFeedback },
});
});
it('sets validation message on key', () => {
expect(findMaskItemKey().attributes('invalid-feedback')).toBe(mockFeedback);
expect(findMaskItemKey().attributes('state')).toBeUndefined();
});
});
describe('when valueInvalidFeedback is passed', () => {
beforeEach(() => {
createComponent({
props: { valueInvalidFeedback: mockFeedback },
});
});
it('sets validation message on value', () => {
expect(findMaskItemValue().attributes('invalid-feedback')).toBe(mockFeedback);
expect(findMaskItemValue().attributes('state')).toBeUndefined();
});
});
describe('on key input', () => {
beforeEach(async () => {
createComponent({ props: { itemKey: mockKey, itemValue: mockValue } });

View File

@ -14,6 +14,7 @@ import {
projectWorkItemTypesQueryResponse,
createWorkItemMutationResponse,
updateWorkItemMutationResponse,
mockIterationWidgetResponse,
} from '../../mock_data';
Vue.use(VueApollo);
@ -24,11 +25,15 @@ describe('WorkItemLinksForm', () => {
const updateMutationResolver = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
const createMutationResolver = jest.fn().mockResolvedValue(createWorkItemMutationResponse);
const mockParentIteration = mockIterationWidgetResponse;
const createComponent = async ({
listResponse = availableWorkItemsResponse,
typesResponse = projectWorkItemTypesQueryResponse,
parentConfidential = false,
hasIterationsFeature = false,
workItemsMvc2Enabled = false,
parentIteration = null,
} = {}) => {
wrapper = shallowMountExtended(WorkItemLinksForm, {
apolloProvider: createMockApollo([
@ -37,8 +42,11 @@ describe('WorkItemLinksForm', () => {
[updateWorkItemMutation, updateMutationResolver],
[createWorkItemMutation, createMutationResolver],
]),
propsData: { issuableGid: 'gid://gitlab/WorkItem/1', parentConfidential },
propsData: { issuableGid: 'gid://gitlab/WorkItem/1', parentConfidential, parentIteration },
provide: {
glFeatures: {
workItemsMvc2: workItemsMvc2Enabled,
},
projectPath: 'project/path',
hasIterationsFeature,
},
@ -133,4 +141,55 @@ describe('WorkItemLinksForm', () => {
expect(findCombobox().props('actionList').length).toBe(1);
});
});
describe('associate iteration with task', () => {
it('does not update iteration when mvc2 feature flag is not enabled', async () => {
await createComponent({
hasIterationsFeature: true,
parentIteration: mockParentIteration,
});
findInput().vm.$emit('input', 'Create task test');
findForm().vm.$emit('submit', {
preventDefault: jest.fn(),
});
await waitForPromises();
expect(updateMutationResolver).not.toHaveBeenCalled();
});
it('updates when parent has an iteration associated', async () => {
await createComponent({
workItemsMvc2Enabled: true,
hasIterationsFeature: true,
parentIteration: mockParentIteration,
});
findInput().vm.$emit('input', 'Create task test');
findForm().vm.$emit('submit', {
preventDefault: jest.fn(),
});
await waitForPromises();
expect(updateMutationResolver).toHaveBeenCalledWith({
input: {
id: 'gid://gitlab/WorkItem/1',
iterationWidget: {
iterationId: mockParentIteration.id,
},
},
});
});
it('does not update when parent has no iteration associated', async () => {
await createComponent({
workItemsMvc2Enabled: true,
hasIterationsFeature: true,
});
findInput().vm.$emit('input', 'Create task test');
findForm().vm.$emit('submit', {
preventDefault: jest.fn(),
});
await waitForPromises();
expect(updateMutationResolver).not.toHaveBeenCalled();
});
});
});

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Resolvers::IssuesResolver do
RSpec.describe Resolvers::ProjectIssuesResolver do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
@ -87,7 +87,7 @@ RSpec.describe Resolvers::IssuesResolver do
end
end
context 'negated filtering' do
context 'when using negated filters' do
it 'returns issues matching the searched title after applying a negated filter' do
expect(resolve_issues(milestone_title: ['past milestone'], not: { milestone_wildcard_id: wildcard_upcoming })).to contain_exactly(issue6)
end
@ -252,7 +252,7 @@ RSpec.describe Resolvers::IssuesResolver do
end
end
context 'filtering by reaction emoji' do
context 'when filtering by reaction emoji' do
let_it_be(:downvoted_issue) { create(:issue, project: project) }
let_it_be(:downvote_award) { create(:award_emoji, :downvote, user: current_user, awardable: downvoted_issue) }
@ -273,7 +273,7 @@ RSpec.describe Resolvers::IssuesResolver do
end
end
context 'confidential issues' do
context 'when listing confidential issues' do
let_it_be(:confidential_issue1) { create(:issue, project: project, confidential: true) }
let_it_be(:confidential_issue2) { create(:issue, project: other_project, confidential: true) }
@ -375,13 +375,13 @@ RSpec.describe Resolvers::IssuesResolver do
create(:issue_customer_relations_contact, issue: crm_issue3, contact: contact3)
end
context 'contact' do
context 'when filtering by contact' do
it 'returns only the issues for the contact' do
expect(resolve_issues({ crm_contact_id: contact1.id })).to contain_exactly(crm_issue1)
end
end
context 'organization' do
context 'when filtering by organization' do
it 'returns only the issues for the contact' do
expect(resolve_issues({ crm_organization_id: organization.id })).to contain_exactly(crm_issue1, crm_issue2)
end

View File

@ -290,14 +290,14 @@ RSpec.describe GitlabSchema.types['Project'] do
subject { described_class.fields['issue'] }
it { is_expected.to have_graphql_type(Types::IssueType) }
it { is_expected.to have_graphql_resolver(Resolvers::IssuesResolver.single) }
it { is_expected.to have_graphql_resolver(Resolvers::ProjectIssuesResolver.single) }
end
describe 'issues field' do
subject { described_class.fields['issues'] }
it { is_expected.to have_graphql_type(Types::IssueType.connection_type) }
it { is_expected.to have_graphql_resolver(Resolvers::IssuesResolver) }
it { is_expected.to have_graphql_resolver(Resolvers::ProjectIssuesResolver) }
end
describe 'merge_request field' do

View File

@ -1793,4 +1793,22 @@ RSpec.describe Issue do
end
end
end
describe '#full_search' do
context 'when searching non-english terms' do
[
'abc 中文語',
'中文語cn',
'中文語',
'Привет'
].each do |term|
it 'adds extra where clause to match partial index' do
expect(described_class.full_search(term).to_sql).to include(
"AND (issues.title NOT SIMILAR TO '[\\u0000-\\u02FF\\u1E00-\\u1EFF\\u2070-\\u218F]*' " \
"OR issues.description NOT SIMILAR TO '[\\u0000-\\u02FF\\u1E00-\\u1EFF\\u2070-\\u218F]*')"
)
end
end
end
end
end

View File

@ -10,6 +10,12 @@ RSpec.describe Terraform::State do
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:project_id) }
it { is_expected.to validate_presence_of(:uuid) }
describe 'default values' do
it { expect(described_class.new.uuid).to be_present }
it { expect(described_class.new(uuid: 'test').uuid).to eq('test') }
end
describe 'scopes' do
describe '.ordered_by_name' do

View File

@ -10,6 +10,15 @@ RSpec.describe Terraform::StateVersion do
it { is_expected.to belong_to(:created_by_user).class_name('User').optional }
it { is_expected.to belong_to(:build).class_name('Ci::Build').optional }
describe 'default attributes' do
before do
allow(Terraform::StateUploader).to receive(:default_store).and_return(5)
end
it { expect(described_class.new.file_store).to eq(5) }
it { expect(described_class.new(file_store: 3).file_store).to eq(3) }
end
describe 'scopes' do
describe '.ordered_by_version_desc' do
let(:terraform_state) { create(:terraform_state) }

View File

@ -56,6 +56,31 @@ RSpec.describe API::Ci::ResourceGroups do
expect(Time.parse(json_response['updated_at'])).to be_like_time(resource_group.updated_at)
end
context 'when resource group key contains multiple dots' do
let!(:resource_group) { create(:ci_resource_group, project: project, key: 'test..test') }
it 'returns the resource group', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq(resource_group.id)
expect(json_response['key']).to eq(resource_group.key)
end
end
context 'when resource group key contains a slash' do
let!(:resource_group) { create(:ci_resource_group, project: project, key: 'test/test') }
let(:key) { 'test%2Ftest' }
it 'returns the resource group', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq(resource_group.id)
expect(json_response['key']).to eq(resource_group.key)
end
end
context 'when user is reporter' do
let(:user) { reporter }
@ -98,6 +123,25 @@ RSpec.describe API::Ci::ResourceGroups do
expect(json_response[0]['status']).to eq(upcoming_processable.status)
end
context 'when resource group key contains a slash' do
let_it_be(:resource_group) { create(:ci_resource_group, project: project, key: 'test/test') }
let_it_be(:upcoming_processable) do
create(:ci_processable,
:waiting_for_resource,
resource_group: resource_group)
end
let(:key) { 'test%2Ftest' }
it 'returns the resource group', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response[0]['id']).to eq(upcoming_processable.id)
expect(json_response[0]['name']).to eq(upcoming_processable.name)
end
end
context 'when user is reporter' do
let(:user) { reporter }

View File

@ -81,7 +81,7 @@ RSpec.describe Groups::ObservabilityController do
describe 'CSP' do
before do
setup_existing_csp_for_controller(described_class, csp)
setup_csp_for_controller(described_class, csp)
end
subject do

View File

@ -1,20 +1,14 @@
# frozen_string_literal: true
module ContentSecurityPolicyHelpers
# Expecting 2 calls to current_content_security_policy by default, once for
# the call that's being tested and once for the call in ApplicationController
def setup_csp_for_controller(controller_class, times = 2)
# Expecting 2 calls to current_content_security_policy by default:
# 1. call that's being tested
# 2. call in ApplicationController
def setup_csp_for_controller(controller_class, csp = ActionDispatch::ContentSecurityPolicy.new, times: 2)
expect_next_instance_of(controller_class) do |controller|
expect(controller).to receive(:current_content_security_policy)
.and_return(ActionDispatch::ContentSecurityPolicy.new).exactly(times).times
end
end
# Expecting 2 calls to current_content_security_policy by default, once for
# the call that's being tested and once for the call in ApplicationController
def setup_existing_csp_for_controller(controller_class, csp, times = 2)
expect_next_instance_of(controller_class) do |controller|
expect(controller).to receive(:current_content_security_policy).and_return(csp).exactly(times).times
expect(controller)
.to receive(:current_content_security_policy).exactly(times).times
.and_return(csp)
end
end
end