Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-06-27 15:09:33 +00:00
parent bbd945a9ea
commit ab421e159d
70 changed files with 1151 additions and 1070 deletions

View File

@ -436,7 +436,7 @@ group :test do
gem 'capybara-screenshot', '~> 1.0.22'
gem 'selenium-webdriver', '~> 3.142'
gem 'shoulda-matchers', '~> 4.0.1', require: false
gem 'shoulda-matchers', '~> 5.1.0', require: false
gem 'email_spec', '~> 2.2.0'
gem 'webmock', '~> 3.9.1'
gem 'rails-controller-testing'

View File

@ -1225,8 +1225,8 @@ GEM
settingslogic (2.0.9)
sexp_processor (4.15.1)
shellany (0.0.1)
shoulda-matchers (4.0.1)
activesupport (>= 4.2.0)
shoulda-matchers (5.1.0)
activesupport (>= 5.2.0)
sidekiq (6.4.0)
connection_pool (>= 2.2.2)
rack (~> 2.0)
@ -1690,7 +1690,7 @@ DEPENDENCIES
sentry-ruby (~> 5.1.1)
sentry-sidekiq (~> 5.1.1)
settingslogic (~> 2.0.9)
shoulda-matchers (~> 4.0.1)
shoulda-matchers (~> 5.1.0)
sidekiq (~> 6.4)
sidekiq-cron (~> 1.2)
sigdump (~> 0.2.4)

View File

@ -89,7 +89,7 @@ export default {
<div class="blocks-container">
<div class="gl-py-5 gl-display-flex gl-align-items-center">
<tooltip-on-truncate :title="job.name" truncate-target="child"
><h4 class="my-0 mr-2 gl-text-truncate">
><h4 class="gl-my-0 gl-mr-3 gl-text-truncate">
{{ job.name }}
</h4>
</tooltip-on-truncate>

View File

@ -170,7 +170,7 @@ export default {
<template>
<div ref="prometheusGraphsHeader">
<div class="mb-2 mr-2 d-flex d-sm-block">
<div class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block">
<dashboards-dropdown
id="monitor-dashboards-dropdown"
data-qa-selector="dashboards_filter_dropdown"
@ -240,7 +240,7 @@ export default {
<div class="flex-grow-1"></div>
<div class="d-sm-flex">
<div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex">
<div v-if="showRearrangePanelsBtn" class="gl-mb-3 gl-mr-3 gl-display-flex">
<gl-button
:pressed="isRearrangingPanels"
variant="default"
@ -253,7 +253,7 @@ export default {
<div
v-if="externalDashboardUrl && externalDashboardUrl.length"
class="mb-2 mr-2 d-flex d-sm-block"
class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block"
>
<gl-button
class="flex-grow-1 js-external-dashboard-link"
@ -280,7 +280,7 @@ export default {
<template v-if="shouldShowSettingsButton">
<span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span>
<div class="mb-2 mr-2 d-flex d-sm-block">
<div class="gl-mb-3 gl-mr-3 d-flex d-sm-block">
<gl-button
v-gl-tooltip
data-testid="metrics-settings-button"

View File

@ -9,6 +9,7 @@ import initSourcegraph from '~/sourcegraph';
import ZenMode from '~/zen_mode';
import initAwardsApp from '~/emoji/awards_app';
import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue';
import { initMrExperienceSurvey } from '~/surveys/merge_request_experience';
import getStateQuery from './queries/get_state.query.graphql';
export default function initMergeRequestShow() {
@ -18,6 +19,7 @@ export default function initMergeRequestShow() {
initSourcegraph();
initIssuableSidebar();
initAwardsApp(document.getElementById('js-vue-awards-block'));
initMrExperienceSurvey();
const el = document.querySelector('.js-mr-status-box');
const { iid, issuableType, projectPath } = el.dataset;

View File

@ -0,0 +1,71 @@
<script>
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
name: 'SatisfactionRate',
components: {
GlButton,
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
i18n: {
unhappy: s__('Surveys|Unhappy'),
delighted: s__('Surveys|Delighted'),
},
grades: [
{
title: s__('Surveys|Unhappy'),
icon: 'face-unhappy',
value: 1,
},
{
title: s__('Surveys|Sad'),
icon: 'slight-frown',
value: 2,
},
{
title: s__('Surveys|Neutral'),
icon: 'face-neutral',
value: 3,
},
{
title: s__('Surveys|Happy'),
icon: 'slight-smile',
value: 4,
},
{
title: s__('Surveys|Delighted'),
icon: 'smiley',
value: 5,
},
],
};
</script>
<template>
<div>
<ul class="gl-list-style-none gl-display-flex gl-p-0 gl-m-0 gl-justify-content-space-between">
<li v-for="grade in $options.grades" :key="grade.value">
<gl-button
v-gl-tooltip="grade.title"
class="gl-p-2!"
variant="default"
category="tertiary"
:aria-label="grade.title"
@click="$emit('rate', grade.value)"
>
<gl-icon class="gl-vertical-align-top" :name="grade.icon" :size="24" />
</gl-button>
</li>
</ul>
<div
class="gl-display-flex gl-justify-content-space-between gl-pt-3 gl-text-gray-500 gl-font-sm"
>
<div>{{ $options.i18n.unhappy }}</div>
<div>{{ $options.i18n.delighted }}</div>
</div>
</div>
</template>

View File

@ -0,0 +1,52 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import MergeRequestExperienceSurveyApp from '~/surveys/merge_request_experience/app.vue';
import createDefaultClient from '~/lib/graphql';
import Translate from '~/vue_shared/translate';
Vue.use(Translate);
Vue.use(VueApollo);
export const startMrSurveyApp = () => {
let channel = null;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const app = new Vue({
apolloProvider,
data() {
return {
hidden: false,
};
},
render(h) {
if (this.hidden) return null;
return h(MergeRequestExperienceSurveyApp, {
on: {
close: () => {
channel?.postMessage('close');
app.hidden = true;
},
rate: () => {
channel?.postMessage('close');
},
},
});
},
});
app.$mount('#js-mr-experience-survey');
if (window.BroadcastChannel) {
channel = new BroadcastChannel('mr_survey');
channel.addEventListener('message', ({ data }) => {
if (data === 'close') {
app.hidden = true;
channel.close();
channel = null;
}
});
}
};

View File

@ -0,0 +1,167 @@
<script>
import { GlButton, GlSprintf, GlSafeHtmlDirective } from '@gitlab/ui';
import gitlabLogo from '@gitlab/svgs/dist/illustrations/gitlab_logo.svg';
import { s__, __ } from '~/locale';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue';
import Tracking from '~/tracking';
const steps = [
{
label: 'overall',
question: s__('MrSurvey|Overall, how satisfied are you with merge requests?'),
},
{
label: 'performance',
question: s__(
'MrSurvey|How satisfied are you with %{strongStart}speed/performance%{strongEnd} of merge requests?',
),
},
];
export default {
name: 'MergeRequestExperienceSurveyApp',
components: {
UserCalloutDismisser,
GlSprintf,
GlButton,
SatisfactionRate,
},
directives: {
safeHtml: GlSafeHtmlDirective,
},
mixins: [Tracking.mixin()],
i18n: {
survey: s__('MrSurvey|Merge request experience survey'),
close: __('Close'),
legal: s__(
'MrSurvey|By continuing, you acknowledge that responses will be used to improve GitLab and in accordance with the %{linkStart}GitLab Privacy Policy%{linkEnd}.',
),
thanks: s__('MrSurvey|Thank you for your feedback!'),
},
gitlabLogo,
data() {
return {
visible: false,
stepIndex: 0,
};
},
computed: {
step() {
return steps[this.stepIndex];
},
},
mounted() {
document.addEventListener('keyup', this.handleKeyup);
},
destroyed() {
document.removeEventListener('keyup', this.handleKeyup);
},
methods: {
onQueryLoaded({ shouldShowCallout }) {
this.visible = shouldShowCallout;
if (!this.visible) this.$emit('close');
},
onRate(event) {
this.$emit('rate');
this.track('survey:mr_experience', {
label: this.step.label,
value: event,
});
this.stepIndex += 1;
if (!this.step) {
setTimeout(() => {
this.$emit('close');
}, 5000);
}
},
handleKeyup(e) {
if (e.key !== 'Escape') return;
this.$emit('close');
this.$refs.dismisser?.dismiss();
},
},
};
</script>
<template>
<user-callout-dismisser
ref="dismisser"
feature-name="mr_experience_survey"
@queryResult.once="onQueryLoaded"
>
<template #default="{ dismiss }">
<aside
class="gl-fixed gl-bottom-0 gl-right-0 gl-z-index-200 gl-p-5"
:aria-label="$options.i18n.survey"
>
<transition name="survey-slide-up">
<div
v-if="visible"
class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base"
>
<gl-button
:aria-label="$options.i18n.close"
variant="default"
category="tertiary"
class="gl-top-4 gl-right-3 gl-absolute"
icon="close"
@click="
dismiss();
$emit('close');
"
/>
<div
v-if="stepIndex === 0"
class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm"
role="note"
>
<p class="gl-m-0">
<gl-sprintf :message="$options.i18n.legal">
<template #link="{ content }">
<a
class="gl-text-decoration-underline gl-text-gray-500"
href="https://about.gitlab.com/privacy/"
target="_blank"
rel="noreferrer nofollow"
v-text="content"
></a>
</template>
</gl-sprintf>
</p>
</div>
<div class="gl-relative">
<div class="gl-absolute">
<div
v-safe-html="$options.gitlabLogo"
aria-hidden="true"
class="mr-experience-survey-logo"
></div>
</div>
</div>
<section v-if="step">
<p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7">
<gl-sprintf :message="step.question">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<satisfaction-rate
aria-labelledby="mr_survey_question"
class="gl-mt-5"
@rate="
dismiss();
onRate($event);
"
/>
</section>
<section v-else class="gl-px-7">
{{ $options.i18n.thanks }}
</section>
</div>
</transition>
</aside>
</template>
</user-callout-dismisser>
</template>

View File

@ -0,0 +1,23 @@
import { Tracker } from '~/tracking/tracker';
const MR_SURVEY_WAIT_DURATION = 10000;
const broadcastNotificationVisible = () => {
// We don't want to clutter up the UI by displaying the survey when broadcast message(s)
// are visible as well.
return Boolean(document.querySelector('.broadcast-notification-message'));
};
export const initMrExperienceSurvey = () => {
if (!gon.features?.mrExperienceSurvey) return;
if (!gon.current_user_id) return;
if (!Tracker.enabled()) return;
if (broadcastNotificationVisible()) return;
setTimeout(() => {
// eslint-disable-next-line promise/catch-or-return
import('./app').then(({ startMrSurveyApp }) => {
startMrSurveyApp();
});
}, MR_SURVEY_WAIT_DURATION);
};

View File

@ -56,7 +56,13 @@ import getUserCalloutsQuery from '~/graphql_shared/queries/get_user_callouts.que
* - shouldShowCallout: boolean
* - A combination of the above which should cover 95% of use cases: `true`
* if the query has loaded without error, and the user is logged in, and
* the callout has not been dismissed yet; `false` otherwise.
* the callout has not been dismissed yet; `false` otherwise
*
* The component emits a `queryResult` event when the GraphQL query
* completes. The payload is a combination of the ApolloQueryResult object and
* this component's `slotProps` computed property. This is useful for things
* like cleaning up/unmounting the component if the callout shouldn't be
* displayed.
*/
export default {
name: 'UserCalloutDismisser',
@ -86,6 +92,9 @@ export default {
update(data) {
return data?.currentUser;
},
result(data) {
this.$emit('queryResult', { ...data, ...this.slotProps });
},
error(err) {
this.queryError = err;
},

View File

@ -349,3 +349,29 @@ $comparison-empty-state-height: 62px;
}
}
}
.mr-experience-survey-body {
width: 300px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.mr-experience-survey-legal {
order: 1;
}
.mr-experience-survey-logo {
width: 16px;
svg {
max-width: 100%;
}
}
.survey-slide-up-enter {
transform: translateY(10px);
opacity: 0;
}
.survey-slide-up-enter-active {
@include gl-transition-slow;
}

View File

@ -47,6 +47,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:moved_mr_sidebar, project)
push_frontend_feature_flag(:paginated_mr_discussions, project)
push_frontend_feature_flag(:mr_review_submit_comment, project)
push_frontend_feature_flag(:mr_experience_survey, project)
end
before_action do

View File

@ -29,7 +29,7 @@ module Ci
private
attr_reader :token, :require_running, :raise_on_missing
attr_reader :token
def find_job_by_token
::Ci::Build.find_by_token(token)

View File

@ -8,5 +8,9 @@ module Types
::WorkItems::Type.allowed_types_for_issues.each do |issue_type|
value issue_type.upcase, value: issue_type, description: "#{issue_type.titleize} issue type"
end
value 'TASK', value: 'task',
description: 'Task issue type. Available only when feature flag `work_items` is enabled.',
deprecated: { milestone: '15.2', reason: :alpha }
end
end

View File

@ -14,8 +14,6 @@ module Ci
extend ::Gitlab::Utils::Override
BuildArchivedError = Class.new(StandardError)
belongs_to :project, inverse_of: :builds
belongs_to :runner
belongs_to :trigger_request
@ -172,7 +170,6 @@ module Ci
end
scope :with_artifacts_not_expired, -> { with_downloadable_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.current) }
scope :with_expired_artifacts, -> { with_downloadable_artifacts.where('artifacts_expire_at < ?', Time.current) }
scope :with_pipeline_locked_artifacts, -> { joins(:pipeline).where('pipeline.locked': Ci::Pipeline.lockeds[:artifacts_locked]) }
scope :last_month, -> { where('created_at > ?', Date.today - 1.month) }
scope :manual_actions, -> { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) }
@ -189,11 +186,6 @@ module Ci
scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) }
scope :preload_project_and_pipeline_project, -> do
preload(Ci::Pipeline::PROJECT_ROUTE_AND_NAMESPACE_ROUTE,
pipeline: Ci::Pipeline::PROJECT_ROUTE_AND_NAMESPACE_ROUTE)
end
scope :with_coverage, -> { where.not(coverage: nil) }
scope :without_coverage, -> { where(coverage: nil) }
scope :with_coverage_regex, -> { where.not(coverage_regex: nil) }
@ -217,10 +209,6 @@ module Ci
ActiveModel::Name.new(self, nil, 'job')
end
def first_pending
pending.unstarted.order('created_at ASC').first
end
def with_preloads
preload(:job_artifacts_archive, :job_artifacts, :tags, project: [:namespace])
end
@ -556,10 +544,6 @@ module Ci
self.options.dig(:environment, :deployment_tier) if self.options
end
def outdated_deployment?
success? && !deployment.try(:last?)
end
def triggered_by?(current_user)
user == current_user
end

View File

@ -7,7 +7,7 @@ module IncidentManagement
self.table_name = 'incident_management_issuable_escalation_statuses'
belongs_to :issue
has_one :project, through: :issue, inverse_of: :incident_management_issuable_escalation_status
has_one :project, through: :issue, inverse_of: :incident_management_issuable_escalation_statuses
validates :issue, presence: true, uniqueness: true

View File

@ -260,6 +260,7 @@ class Project < ApplicationRecord
has_many :merge_request_metrics, foreign_key: 'target_project', class_name: 'MergeRequest::Metrics', inverse_of: :target_project
has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest'
has_many :issues
has_many :incident_management_issuable_escalation_statuses, through: :issues, inverse_of: :project, class_name: 'IncidentManagement::IssuableEscalationStatus'
has_many :labels, class_name: 'ProjectLabel'
has_many :integrations
has_many :events

View File

@ -53,7 +53,8 @@ module Users
preview_user_over_limit_free_plan_alert: 50, # EE-only
user_reached_limit_free_plan_alert: 51, # EE-only
submit_license_usage_data_banner: 52, # EE-only
personal_project_limitations_banner: 53 # EE-only
personal_project_limitations_banner: 53, # EE-only
mr_experience_survey: 54
}
validates :feature_name,

View File

@ -179,6 +179,8 @@ class ProjectPolicy < BasePolicy
with_scope :subject
condition(:packages_disabled) { !@subject.packages_enabled }
condition(:work_items_enabled, scope: :subject) { project&.work_items_feature_flag_enabled? }
features = %w[
merge_requests
issues
@ -274,10 +276,9 @@ class ProjectPolicy < BasePolicy
rule { can?(:reporter_access) & can?(:create_issue) }.enable :create_incident
rule { can?(:create_issue) }.policy do
enable :create_task
enable :create_work_item
end
rule { can?(:create_issue) }.enable :create_work_item
rule { can?(:create_issue) & work_items_enabled }.enable :create_task
# These abilities are not allowed to admins that are not members of the project,
# that's why they are defined separately.

View File

@ -16,6 +16,8 @@ module Groups
groups_to_refresh = links.map(&:shared_with_group)
groups_to_refresh.uniq.each do |group|
next if Feature.enabled?(:skip_group_share_unlink_auth_refresh, group.root_ancestor)
group.refresh_members_authorized_projects(blocking: false, direct_members_only: true)
end
else

View File

@ -99,6 +99,9 @@
#js-review-bar
- if Feature.enabled?(:mr_experience_survey, @project)
#js-mr-experience-survey
- if current_user&.mr_attention_requests_enabled?
#js-need-attention-sidebar-onboarding

View File

@ -0,0 +1,8 @@
---
name: auto_ban_user_on_excessive_projects_download
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87872
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364782
milestone: '15.2'
type: development
group: group::anti-abuse
default_enabled: false

View File

@ -1,7 +1,7 @@
---
name: git_abuse_rate_limit_feature_flag
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87872
rollout_issue_url:
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364782
milestone: '15.1'
type: development
group: group::anti-abuse

View File

@ -0,0 +1,8 @@
---
name: mr_experience_survey
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/90036
rollout_issue_url:
milestone: '15.2'
type: development
group: group::code review
default_enabled: false

View File

@ -1,8 +1,8 @@
---
name: closed_as_duplicate_of_issues_api
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89375
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364749
milestone: '15.1'
name: skip_group_share_unlink_auth_refresh
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/90871
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/366086
milestone: '15.2'
type: development
group: group::respond
group: group::workspace
default_enabled: false

View File

@ -21,14 +21,15 @@ Get project-level DORA metrics.
GET /projects/:id/dora/metrics
```
| Attribute | Type | Required | Description |
|-------------- |-------- |----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](../index.md#namespaced-path-encoding) can be accessed by the authenticated user. |
| `metric` | string | yes | The metric name: `deployment_frequency`, `lead_time_for_changes` or `time_to_restore_service`.|
| `start_date` | string | no | Date range to start from. ISO 8601 Date format, for example `2021-03-01`. Default is 3 months ago. |
| `end_date` | string | no | Date range to end at. ISO 8601 Date format, for example `2021-03-01`. Default is the current date. |
| `interval` | string | no | The bucketing interval. One of `all`, `monthly` or `daily`. Default is `daily`. |
| `environment_tier` | string | no | The [tier of the environment](../../ci/environments/index.md#deployment-tier-of-environments). Default is `production`. |
| Attribute | Type | Required | Description |
|----------------------|------------------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](../index.md#namespaced-path-encoding) can be accessed by the authenticated user. |
| `metric` | string | yes | The metric name: `deployment_frequency`, `lead_time_for_changes` or `time_to_restore_service`. |
| `start_date` | string | no | Date range to start from. ISO 8601 Date format, for example `2021-03-01`. Default is 3 months ago. |
| `end_date` | string | no | Date range to end at. ISO 8601 Date format, for example `2021-03-01`. Default is the current date. |
| `interval` | string | no | The bucketing interval. One of `all`, `monthly` or `daily`. Default is `daily`. |
| `environment_tier` | string | no | The [tier of the environment](../../ci/environments/index.md#deployment-tier-of-environments). Default is `production`. Planned for deprecation, please use `environment_tiers`. |
| `environment_tiers` | array of strings | no | The [tiers of the environments](../../ci/environments/index.md#deployment-tier-of-environments). Default is `production`. |
Example request:
@ -61,14 +62,15 @@ Get group-level DORA metrics.
GET /groups/:id/dora/metrics
```
| Attribute | Type | Required | Description |
|-------------- |-------- |----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](../index.md#namespaced-path-encoding) can be accessed by the authenticated user. |
| `metric` | string | yes | One of `deployment_frequency`, `lead_time_for_changes`, `time_to_restore_service` or `change_failure_rate`. |
| `start_date` | string | no | Date range to start from. ISO 8601 Date format, for example `2021-03-01`. Default is 3 months ago. |
| `end_date` | string | no | Date range to end at. ISO 8601 Date format, for example `2021-03-01`. Default is the current date. |
| `interval` | string | no | The bucketing interval. One of `all`, `monthly` or `daily`. Default is `daily`. |
| `environment_tier` | string | no | The [tier of the environment](../../ci/environments/index.md#deployment-tier-of-environments). Default is `production`. |
| Attribute | Type | Required | Description |
|---------------------|------------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](../index.md#namespaced-path-encoding) can be accessed by the authenticated user. |
| `metric` | string | yes | One of `deployment_frequency`, `lead_time_for_changes`, `time_to_restore_service` or `change_failure_rate`. |
| `start_date` | string | no | Date range to start from. ISO 8601 Date format, for example `2021-03-01`. Default is 3 months ago. |
| `end_date` | string | no | Date range to end at. ISO 8601 Date format, for example `2021-03-01`. Default is the current date. |
| `interval` | string | no | The bucketing interval. One of `all`, `monthly` or `daily`. Default is `daily`. |
| `environment_tier` | string | no | The [tier of the environment](../../ci/environments/index.md#deployment-tier-of-environments). Default is `production`. Planned for deprecation, please use `environment_tiers`. |
| `environment_tiers` | array of strings | no | The [tiers of the environments](../../ci/environments/index.md#deployment-tier-of-environments). Default is `production`. |
Example request:

View File

@ -10969,7 +10969,8 @@ Returns [`[DoraMetric!]`](#dorametric).
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="dorametricsenddate"></a>`endDate` | [`Date`](#date) | Date range to end at. Default is the current date. |
| <a id="dorametricsenvironmenttier"></a>`environmentTier` | [`DeploymentTier`](#deploymenttier) | Deployment tier of the environments to return. Defaults to `PRODUCTION`. |
| <a id="dorametricsenvironmenttier"></a>`environmentTier` | [`DeploymentTier`](#deploymenttier) | Deployment tier of the environments to return. Planned for deprecation: please update to `environment_tiers` param. |
| <a id="dorametricsenvironmenttiers"></a>`environmentTiers` | [`[DeploymentTier!]`](#deploymenttier) | Deployment tiers of the environments to return. Defaults to [`PRODUCTION`]. |
| <a id="dorametricsinterval"></a>`interval` | [`DoraMetricBucketingInterval`](#dorametricbucketinginterval) | How the metric should be aggregrated. Defaults to `DAILY`. In the case of `ALL`, the `date` field in the response will be `null`. |
| <a id="dorametricsmetric"></a>`metric` | [`DoraMetricType!`](#dorametrictype) | Type of metric to return. |
| <a id="dorametricsstartdate"></a>`startDate` | [`Date`](#date) | Date range to start from. Default is 3 months ago. |
@ -19169,6 +19170,7 @@ Issue type.
| <a id="issuetypeincident"></a>`INCIDENT` | Incident issue type. |
| <a id="issuetypeissue"></a>`ISSUE` | Issue issue type. |
| <a id="issuetyperequirement"></a>`REQUIREMENT` | Requirement issue type. |
| <a id="issuetypetask"></a>`TASK` **{warning-solid}** | **Introduced** in 15.2. This feature is in Alpha. It can be changed or removed at any time. |
| <a id="issuetypetest_case"></a>`TEST_CASE` | Test Case issue type. |
### `IterationSearchableField`
@ -19934,6 +19936,7 @@ Name of the feature that the callout is for.
| <a id="usercalloutfeaturenameenumgke_cluster_integration"></a>`GKE_CLUSTER_INTEGRATION` | Callout feature name for gke_cluster_integration. |
| <a id="usercalloutfeaturenameenumgold_trial_billings"></a>`GOLD_TRIAL_BILLINGS` | Callout feature name for gold_trial_billings. |
| <a id="usercalloutfeaturenameenumminute_limit_banner"></a>`MINUTE_LIMIT_BANNER` | Callout feature name for minute_limit_banner. |
| <a id="usercalloutfeaturenameenummr_experience_survey"></a>`MR_EXPERIENCE_SURVEY` | Callout feature name for mr_experience_survey. |
| <a id="usercalloutfeaturenameenumnew_user_signups_cap_reached"></a>`NEW_USER_SIGNUPS_CAP_REACHED` | Callout feature name for new_user_signups_cap_reached. |
| <a id="usercalloutfeaturenameenumpersonal_access_token_expiry"></a>`PERSONAL_ACCESS_TOKEN_EXPIRY` | Callout feature name for personal_access_token_expiry. |
| <a id="usercalloutfeaturenameenumpersonal_project_limitations_banner"></a>`PERSONAL_PROJECT_LIMITATIONS_BANNER` | Callout feature name for personal_project_limitations_banner. |

View File

@ -2516,8 +2516,8 @@ job2:
job2:
script: echo "test"
only:
- branches
- tags
- branches
- tags
```
#### `only:variables` / `except:variables`

View File

@ -63,18 +63,19 @@ possible, we encourage you to use all of our security scanning tools:
The following table summarizes which types of dependencies each scanning tool can detect:
| Feature | Dependency Scanning | Container Scanning |
| ----------------------------------------------------------- | ------------------- | ------------------ |
| Identify the manifest, lock file, or static file that introduced the dependency | **{check-circle}** | **{dotted-circle}** |
| Development dependencies | **{check-circle}** | **{dotted-circle}** |
| Dependencies in a lock file committed to your repository | **{check-circle}** | **{check-circle}** <sup>1</sup> |
| Binaries built by Go | **{dotted-circle}** | **{check-circle}** <sup>2</sup> |
| Dynamically-linked language-specific dependencies installed by the Operating System | **{dotted-circle}** | **{check-circle}** |
| Operating system dependencies | **{dotted-circle}** | **{check-circle}** |
| Language-specific dependencies installed on the operating system (not built by your project) | **{dotted-circle}** | **{check-circle}** |
| Feature | Dependency Scanning | Container Scanning |
| ----------------------------------------------------------- | ------------------- | ------------------- |
| Identify the manifest, lock file, or static file that introduced the dependency | **{check-circle}** | **{dotted-circle}** |
| Development dependencies | **{check-circle}** | **{dotted-circle}** |
| Dependencies in a lock file committed to your repository | **{check-circle}** | **{check-circle}** <sup>1</sup> |
| Binaries built by Go | **{dotted-circle}** | **{check-circle}** <sup>2</sup> <sup>3</sup> |
| Dynamically-linked language-specific dependencies installed by the Operating System | **{dotted-circle}** | **{check-circle}** <sup>3</sup> |
| Operating system dependencies | **{dotted-circle}** | **{check-circle}** |
| Language-specific dependencies installed on the operating system (not built by your project) | **{dotted-circle}** | **{check-circle}** |
1. Lock file must be present in the image to be detected.
1. Binary file must be present in the image to be detected.
1. Only when using Trivy
## Requirements

View File

@ -99,45 +99,54 @@ Hello from MyPyPiPackage
After you create a project, you can create a package.
1. In your terminal, go to the `MyPyPiPackage` directory.
1. Create a `setup.py` file:
1. Create a `pyproject.toml` file:
```shell
touch setup.py
touch pyproject.toml
```
This file contains all the information about the package. For more information
about this file, see [creating setup.py](https://packaging.python.org/tutorials/packaging-projects/#creating-setup-py).
about this file, see [creating `pyproject.toml`](https://packaging.python.org/en/latest/tutorials/packaging-projects/#creating-pyproject-toml).
Because GitLab identifies packages based on
[Python normalized names (PEP-503)](https://www.python.org/dev/peps/pep-0503/#normalized-names),
ensure your package name meets these requirements. See the [installation section](#authenticate-with-a-ci-job-token)
for details.
1. Open the `setup.py` file, and then add basic information:
1. Open the `pyproject.toml` file, and then add basic information:
```python
import setuptools
```toml
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
setuptools.setup(
name="mypypipackage",
version="0.0.1",
author="Example Author",
author_email="author@example.com",
description="A small example package",
packages=setuptools.find_packages(),
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
python_requires='>=3.6',
)
[project]
name = "mypypipackage"
version = "0.0.1"
authors = [
{ name="Example Author", email="author@example.com" },
]
description = "A small example package"
requires-python = ">=3.7"
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
]
[tool.setuptools.packages]
find = {}
```
1. Save the file.
1. Execute the setup:
1. Install the package build library:
```shell
python3 setup.py sdist bdist_wheel
pip install build
```
1. Build the package:
```shell
python -m build
```
The output should be visible in a newly-created `dist` folder:
@ -218,8 +227,8 @@ image: python:latest
run:
script:
- pip install twine
- python setup.py sdist bdist_wheel
- pip install build twine
- python -m build
- TWINE_PASSWORD=${CI_JOB_TOKEN} TWINE_USERNAME=gitlab-ci-token python -m twine upload --repository-url ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi dist/*
```

View File

@ -31,9 +31,7 @@ module API
end
expose :closed_as_duplicate_of do |issue|
if ::Feature.enabled?(:closed_as_duplicate_of_issues_api, issue.project) &&
issue.duplicated? &&
options[:current_user]&.can?(:read_issue, issue.duplicated_to)
if issue.duplicated? && options[:current_user]&.can?(:read_issue, issue.duplicated_to)
expose_url(
api_v4_project_issue_path(id: issue.duplicated_to.project_id, issue_iid: issue.duplicated_to.iid)
)

View File

@ -54,15 +54,26 @@ module Gitlab
# be throttled.
#
# @param key [Symbol] Key attribute registered in `.rate_limits`
# @param scope [Array<ActiveRecord>] Array of ActiveRecord models, Strings or Symbols to scope throttling to a specific request (e.g. per user per project)
# @param threshold [Integer] Optional threshold value to override default one registered in `.rate_limits`
# @param users_allowlist [Array<String>] Optional list of usernames to exclude from the limit. This param will only be functional if Scope includes a current user.
# @param peek [Boolean] Optional. When true the key will not be incremented but the current throttled state will be returned.
# @param scope [Array<ActiveRecord>] Array of ActiveRecord models, Strings
# or Symbols to scope throttling to a specific request (e.g. per user
# per project)
# @param resource [ActiveRecord] An ActiveRecord model to count an action
# for (e.g. limit unique project (resource) downloads (action) to five
# per user (scope))
# @param threshold [Integer] Optional threshold value to override default
# one registered in `.rate_limits`
# @param users_allowlist [Array<String>] Optional list of usernames to
# exclude from the limit. This param will only be functional if Scope
# includes a current user.
# @param peek [Boolean] Optional. When true the key will not be
# incremented but the current throttled state will be returned.
#
# @return [Boolean] Whether or not a request should be throttled
def throttled?(key, scope:, threshold: nil, users_allowlist: nil, peek: false)
def throttled?(key, scope:, resource: nil, threshold: nil, users_allowlist: nil, peek: false)
raise InvalidKeyError unless rate_limits[key]
strategy = resource.present? ? IncrementPerActionedResource.new(resource.id) : IncrementPerAction.new
::Gitlab::Instrumentation::RateLimitingGates.track(key)
return false if scoped_user_in_allowlist?(scope, users_allowlist)
@ -72,6 +83,9 @@ module Gitlab
return false if threshold_value == 0
interval_value = interval(key)
return false if interval_value == 0
# `period_key` is based on the current time and interval so when time passes to the next interval
# the key changes and the rate limit count starts again from 0.
# Based on https://github.com/rack/rack-attack/blob/886ba3a18d13c6484cd511a4dc9b76c0d14e5e96/lib/rack/attack/cache.rb#L63-L68
@ -79,9 +93,12 @@ module Gitlab
cache_key = cache_key(key, scope, period_key)
value = if peek
read(cache_key)
strategy.read(cache_key)
else
increment(cache_key, interval_value, time_elapsed_in_period)
# We add a 1 second buffer to avoid timing issues when we're at the end of a period
expiry = interval_value - time_elapsed_in_period + 1
strategy.increment(cache_key, expiry)
end
value > threshold_value
@ -129,13 +146,19 @@ module Gitlab
def threshold(key)
value = rate_limit_value_by_key(key, :threshold)
return value.call if value.is_a?(Proc)
value.to_i
rate_limit_value(value)
end
def interval(key)
rate_limit_value_by_key(key, :interval).to_i
value = rate_limit_value_by_key(key, :interval)
rate_limit_value(value)
end
def rate_limit_value(value)
value = value.call if value.is_a?(Proc)
value.to_i
end
def rate_limit_value_by_key(key, setting)
@ -144,27 +167,6 @@ module Gitlab
action[setting] if action
end
# Increments the rate limit count and returns the new count value.
def increment(cache_key, interval_value, time_elapsed_in_period)
# We add a 1 second buffer to avoid timing issues when we're at the end of a period
expiry = interval_value - time_elapsed_in_period + 1
::Gitlab::Redis::RateLimiting.with do |redis|
redis.pipelined do
redis.incr(cache_key)
redis.expire(cache_key, expiry)
end.first
end
end
# Returns the rate limit count.
# Will be 0 if there is no data in the cache.
def read(cache_key)
::Gitlab::Redis::RateLimiting.with do |redis|
redis.get(cache_key).to_i
end
end
def cache_key(key, scope, period_key)
composed_key = [key, scope].flatten.compact

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
module Gitlab
module ApplicationRateLimiter
class BaseStrategy
# Increment the rate limit count and return the new count value
def increment(cache_key, expiry)
raise NotImplementedError
end
# Return the rate limit count.
# Should be 0 if there is no data in the cache.
def read(cache_key)
raise NotImplementedError
end
private
def with_redis(&block)
::Gitlab::Redis::RateLimiting.with(&block) # rubocop: disable CodeReuse/ActiveRecord
end
end
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
module Gitlab
module ApplicationRateLimiter
class IncrementPerAction < BaseStrategy
def increment(cache_key, expiry)
with_redis do |redis|
redis.pipelined do
redis.incr(cache_key)
redis.expire(cache_key, expiry)
end.first
end
end
def read(cache_key)
with_redis do |redis|
redis.get(cache_key).to_i
end
end
end
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
module Gitlab
module ApplicationRateLimiter
class IncrementPerActionedResource < BaseStrategy
def initialize(resource_key)
@resource_key = resource_key
end
def increment(cache_key, expiry)
with_redis do |redis|
redis.pipelined do
redis.sadd(cache_key, resource_key)
redis.expire(cache_key, expiry)
redis.scard(cache_key)
end.last
end
end
def read(cache_key)
with_redis do |redis|
redis.scard(cache_key)
end
end
private
attr_accessor :resource_key
end
end
end

View File

@ -761,6 +761,7 @@ excluded_attributes:
- :exported_protected_branches
- :repository_size_limit
- :external_webhook_token
- :incident_management_issuable_escalation_statuses
namespaces:
- :runners_token
- :runners_token_encrypted
@ -819,6 +820,7 @@ excluded_attributes:
- :upvotes_count
- :work_item_type_id
- :email_message_id
- :incident_management_issuable_escalation_status
merge_request: &merge_request_excluded_definition
- :milestone_id
- :sprint_id

View File

@ -25129,6 +25129,21 @@ msgstr ""
msgid "MrList|Review requested from %{name}"
msgstr ""
msgid "MrSurvey|By continuing, you acknowledge that responses will be used to improve GitLab and in accordance with the %{linkStart}GitLab Privacy Policy%{linkEnd}."
msgstr ""
msgid "MrSurvey|How satisfied are you with %{strongStart}speed/performance%{strongEnd} of merge requests?"
msgstr ""
msgid "MrSurvey|Merge request experience survey"
msgstr ""
msgid "MrSurvey|Overall, how satisfied are you with merge requests?"
msgstr ""
msgid "MrSurvey|Thank you for your feedback!"
msgstr ""
msgid "Multi-project"
msgstr ""
@ -37415,6 +37430,21 @@ msgstr ""
msgid "Survey Response"
msgstr ""
msgid "Surveys|Delighted"
msgstr ""
msgid "Surveys|Happy"
msgstr ""
msgid "Surveys|Neutral"
msgstr ""
msgid "Surveys|Sad"
msgstr ""
msgid "Surveys|Unhappy"
msgstr ""
msgid "Switch Branches"
msgstr ""
@ -38266,7 +38296,7 @@ msgstr ""
msgid "The download link will expire in 24 hours."
msgstr ""
msgid "The environment tier must be one of %{environment_tiers}."
msgid "The environment tiers must be from %{environment_tiers}."
msgstr ""
msgid "The errors we encountered were:"

View File

@ -131,8 +131,24 @@ RSpec.describe Groups::GroupLinksController do
expect { subject }.to change(GroupGroupLink, :count).by(-1)
end
it 'updates project permissions', :sidekiq_inline do
expect { subject }.to change { group_member.can?(:create_release, project) }.from(true).to(false)
context 'with skip_group_share_unlink_auth_refresh feature flag disabled' do
before do
stub_feature_flags(skip_group_share_unlink_auth_refresh: false)
end
it 'updates project permissions', :sidekiq_inline do
expect { subject }.to change { group_member.can?(:create_release, project) }.from(true).to(false)
end
end
context 'with skip_group_share_unlink_auth_refresh feature flag enabled' do
before do
stub_feature_flags(skip_group_share_unlink_auth_refresh: true)
end
it 'maintains project authorization', :sidekiq_inline do
expect(Ability.allowed?(user, :read_project, project)).to be_truthy
end
end
end

View File

@ -1112,9 +1112,11 @@ RSpec.describe GroupsController, factory_default: :keep do
before do
sign_in(admin)
allow(Gitlab::ApplicationRateLimiter)
.to receive(:increment)
.and_return(Gitlab::ApplicationRateLimiter.rate_limits[:group_export][:threshold].call + 1)
allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
allow(strategy)
.to receive(:increment)
.and_return(Gitlab::ApplicationRateLimiter.rate_limits[:group_export][:threshold].call + 1)
end
end
it 'throttles the endpoint' do
@ -1194,9 +1196,11 @@ RSpec.describe GroupsController, factory_default: :keep do
before do
sign_in(admin)
allow(Gitlab::ApplicationRateLimiter)
allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
allow(strategy)
.to receive(:increment)
.and_return(Gitlab::ApplicationRateLimiter.rate_limits[:group_download_export][:threshold].call + 1)
end
end
it 'throttles the endpoint' do

View File

@ -20,9 +20,9 @@ RSpec.describe Profiles::EmailsController do
before do
allowed_threshold = Gitlab::ApplicationRateLimiter.rate_limits[action][:threshold]
allow(Gitlab::ApplicationRateLimiter)
.to receive(:increment)
.and_return(allowed_threshold + 1)
allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
allow(strategy).to receive(:increment).and_return(allowed_threshold + 1)
end
end
it 'does not send any email' do

View File

@ -1432,9 +1432,11 @@ RSpec.describe ProjectsController do
shared_examples 'rate limits project export endpoint' do
before do
allow(Gitlab::ApplicationRateLimiter)
.to receive(:increment)
.and_return(Gitlab::ApplicationRateLimiter.rate_limits["project_#{action}".to_sym][:threshold].call + 1)
allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
allow(strategy)
.to receive(:increment)
.and_return(Gitlab::ApplicationRateLimiter.rate_limits["project_#{action}".to_sym][:threshold].call + 1)
end
end
it 'prevents requesting project export' do
@ -1546,9 +1548,11 @@ RSpec.describe ProjectsController do
context 'when the endpoint receives requests above the limit', :clean_gitlab_redis_rate_limiting do
before do
allow(Gitlab::ApplicationRateLimiter)
.to receive(:increment)
.and_return(Gitlab::ApplicationRateLimiter.rate_limits[:project_download_export][:threshold].call + 1)
allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
allow(strategy)
.to receive(:increment)
.and_return(Gitlab::ApplicationRateLimiter.rate_limits[:project_download_export][:threshold].call + 1)
end
end
it 'prevents requesting project export' do

View File

@ -16,7 +16,6 @@ RSpec.describe 'Environment > Pod Logs', :js, :kubeclient do
create(:deployment, :success, environment: environment)
stub_kubeclient_pods(environment.deployment_namespace)
stub_kubeclient_logs(pod_name, environment.deployment_namespace, container: 'container-0')
stub_kubeclient_deployments(environment.deployment_namespace)
stub_kubeclient_ingresses(environment.deployment_namespace)
stub_kubeclient_nodes_and_nodes_metrics(cluster.platform.api_url)
@ -43,26 +42,4 @@ RSpec.describe 'Environment > Pod Logs', :js, :kubeclient do
expect(dropdown_items.size).to eq(2)
end
end
context 'with logs', :use_clean_rails_memory_store_caching do
it "shows pod logs", :sidekiq_might_not_need_inline do
visit project_logs_path(environment.project, environment_name: environment.name, pod_name: pod_name)
wait_for_requests
page.within('.qa-pods-dropdown') do # rubocop:disable QA/SelectorUsage
find(".dropdown-toggle:not([disabled])").click
dropdown_items = find(".dropdown-menu").all(".dropdown-item:not([disabled])")
expect(dropdown_items.size).to eq(1)
dropdown_items.each_with_index do |item, i|
expect(item.text).to eq(pod_names[i])
end
end
expect(page).to have_content("kube-pod | Log 1")
expect(page).to have_content("kube-pod | Log 2")
expect(page).to have_content("kube-pod | Log 3")
end
end
end

View File

@ -1,3 +1,5 @@
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
/**
* Mock factory for the UserCalloutDismisser component.
* @param {slotProps} The slot props to pass to the default slot content.
@ -6,11 +8,24 @@
export const makeMockUserCalloutDismisser = ({
dismiss = () => {},
shouldShowCallout = true,
isLoadingQuery = false,
} = {}) => ({
props: UserCalloutDismisser.props,
data() {
return {
isLoadingQuery,
shouldShowCallout,
dismiss,
};
},
mounted() {
this.$emit('queryResult', { shouldShowCallout });
},
render() {
return this.$scopedSlots.default({
dismiss,
shouldShowCallout,
isLoadingQuery,
});
},
});

View File

@ -35,7 +35,7 @@ exports[`Dashboard template matches the default snapshot 1`] = `
class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light"
>
<div
class="mb-2 mr-2 d-flex d-sm-block"
class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block"
>
<dashboards-dropdown-stub
class="flex-grow-1"

View File

@ -0,0 +1,143 @@
import { nextTick } from 'vue';
import { GlButton, GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking } from 'helpers/tracking_helper';
import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser';
import MergeRequestExperienceSurveyApp from '~/surveys/merge_request_experience/app.vue';
import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue';
describe('MergeRequestExperienceSurveyApp', () => {
let trackingSpy;
let wrapper;
let dismiss;
let dismisserComponent;
const findCloseButton = () =>
wrapper
.findAllComponents(GlButton)
.filter((button) => button.attributes('aria-label') === 'Close')
.at(0);
const createWrapper = ({ shouldShowCallout = true } = {}) => {
dismiss = jest.fn();
dismisserComponent = makeMockUserCalloutDismisser({
dismiss,
shouldShowCallout,
});
wrapper = shallowMountExtended(MergeRequestExperienceSurveyApp, {
stubs: {
UserCalloutDismisser: dismisserComponent,
GlSprintf,
},
});
};
describe('when user callout is visible', () => {
beforeEach(() => {
createWrapper();
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
it('shows survey', async () => {
expect(wrapper.html()).toContain('Overall, how satisfied are you with merge requests?');
expect(wrapper.findComponent(SatisfactionRate).exists()).toBe(true);
expect(wrapper.emitted().close).toBe(undefined);
});
it('triggers user callout on close', async () => {
findCloseButton().vm.$emit('click');
expect(dismiss).toHaveBeenCalledTimes(1);
});
it('emits close event on close button click', async () => {
findCloseButton().vm.$emit('click');
expect(wrapper.emitted()).toMatchObject({ close: [[]] });
});
it('applies correct feature name for user callout', () => {
expect(wrapper.findComponent(dismisserComponent).props('featureName')).toBe(
'mr_experience_survey',
);
});
it('dismisses user callout on survey rate', async () => {
const rate = wrapper.findComponent(SatisfactionRate);
expect(dismiss).not.toHaveBeenCalled();
rate.vm.$emit('rate', 5);
expect(dismiss).toHaveBeenCalledTimes(1);
});
it('steps through survey steps', async () => {
const rate = wrapper.findComponent(SatisfactionRate);
rate.vm.$emit('rate', 5);
await nextTick();
expect(wrapper.text()).toContain(
'How satisfied are you with speed/performance of merge requests?',
);
});
it('tracks survey rates', async () => {
const rate = wrapper.findComponent(SatisfactionRate);
rate.vm.$emit('rate', 5);
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'survey:mr_experience', {
value: 5,
label: 'overall',
});
rate.vm.$emit('rate', 4);
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'survey:mr_experience', {
value: 4,
label: 'performance',
});
});
it('shows legal note', async () => {
expect(wrapper.text()).toContain(
'By continuing, you acknowledge that responses will be used to improve GitLab and in accordance with the GitLab Privacy Policy.',
);
});
it('hides legal note after first step', async () => {
const rate = wrapper.findComponent(SatisfactionRate);
rate.vm.$emit('rate', 5);
await nextTick();
expect(wrapper.text()).not.toContain(
'By continuing, you acknowledge that responses will be used to improve GitLab and in accordance with the GitLab Privacy Policy.',
);
});
it('shows disappearing thanks message', async () => {
const rate = wrapper.findComponent(SatisfactionRate);
rate.vm.$emit('rate', 5);
await nextTick();
rate.vm.$emit('rate', 5);
await nextTick();
expect(wrapper.text()).toContain('Thank you for your feedback!');
expect(wrapper.emitted()).toMatchObject({});
jest.runOnlyPendingTimers();
expect(wrapper.emitted()).toMatchObject({ close: [[]] });
});
});
describe('when user callout is hidden', () => {
beforeEach(() => {
createWrapper({ shouldShowCallout: false });
});
it('emits close event', async () => {
expect(wrapper.emitted()).toMatchObject({ close: [[]] });
});
});
describe('when Escape key is pressed', () => {
beforeEach(() => {
createWrapper();
const event = new KeyboardEvent('keyup', { key: 'Escape' });
document.dispatchEvent(event);
});
it('emits close event', async () => {
expect(wrapper.emitted()).toMatchObject({ close: [[]] });
expect(dismiss).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -5,9 +5,9 @@ require 'spec_helper'
RSpec.describe Types::IssueTypeEnum do
specify { expect(described_class.graphql_name).to eq('IssueType') }
it 'exposes all the existing issue type values except for task' do
it 'exposes all the existing issue type values' do
expect(described_class.values.keys).to match_array(
%w[ISSUE INCIDENT TEST_CASE REQUIREMENT]
%w[ISSUE INCIDENT TEST_CASE REQUIREMENT TASK]
)
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::ApplicationRateLimiter::BaseStrategy do
describe '#increment' do
it 'raises NotImplementedError' do
expect { subject.increment('cache_key', 0) }.to raise_error(NotImplementedError)
end
end
describe '#read' do
it 'raises NotImplementedError' do
expect { subject.read('cache_key') }.to raise_error(NotImplementedError)
end
end
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::ApplicationRateLimiter::IncrementPerAction, :freeze_time, :clean_gitlab_redis_rate_limiting do
let(:cache_key) { 'test' }
let(:expiry) { 60 }
subject(:counter) { described_class.new }
def increment
counter.increment(cache_key, expiry)
end
describe '#increment' do
it 'increments per call' do
expect(increment).to eq 1
expect(increment).to eq 2
expect(increment).to eq 3
end
it 'sets time to live (TTL) for the key' do
def ttl
Gitlab::Redis::RateLimiting.with { |r| r.ttl(cache_key) }
end
key_does_not_exist = -2
expect(ttl).to eq key_does_not_exist
expect { increment }.to change { ttl }.by(a_value > 0)
end
end
describe '#read' do
def read
counter.read(cache_key)
end
it 'returns 0 when there is no data' do
expect(read).to eq 0
end
it 'returns the correct value', :aggregate_failures do
increment
expect(read).to eq 1
increment
expect(read).to eq 2
end
end
end

View File

@ -0,0 +1,52 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::ApplicationRateLimiter::IncrementPerActionedResource,
:freeze_time, :clean_gitlab_redis_rate_limiting do
let(:cache_key) { 'test' }
let(:expiry) { 60 }
def increment(resource_key)
described_class.new(resource_key).increment(cache_key, expiry)
end
describe '#increment' do
it 'increments per resource', :aggregate_failures do
expect(increment('resource_1')).to eq(1)
expect(increment('resource_1')).to eq(1)
expect(increment('resource_2')).to eq(2)
expect(increment('resource_2')).to eq(2)
expect(increment('resource_3')).to eq(3)
end
it 'sets time to live (TTL) for the key' do
def ttl
Gitlab::Redis::RateLimiting.with { |r| r.ttl(cache_key) }
end
key_does_not_exist = -2
expect(ttl).to eq key_does_not_exist
expect { increment('resource_1') }.to change { ttl }.by(a_value > 0)
end
end
describe '#read' do
def read
described_class.new(nil).read(cache_key)
end
it 'returns 0 when there is no data' do
expect(read).to eq 0
end
it 'returns the correct value', :aggregate_failures do
increment 'r1'
expect(read).to eq 1
increment 'r2'
expect(read).to eq 2
end
end
end

View File

@ -13,8 +13,8 @@ RSpec.describe Gitlab::ApplicationRateLimiter, :clean_gitlab_redis_rate_limiting
interval: 2.minutes
},
another_action: {
threshold: 2,
interval: 3.minutes
threshold: -> { 2 },
interval: -> { 3.minutes }
}
}
end
@ -70,6 +70,44 @@ RSpec.describe Gitlab::ApplicationRateLimiter, :clean_gitlab_redis_rate_limiting
end
end
describe 'counting actions once per unique resource' do
let(:scope) { [user, project] }
let(:start_time) { Time.current.beginning_of_hour }
let(:project1) { instance_double(Project, id: '1') }
let(:project2) { instance_double(Project, id: '2') }
it 'returns true when unique actioned resources count exceeds threshold' do
travel_to(start_time) do
expect(subject.throttled?(:test_action, scope: scope, resource: project1)).to eq(false)
end
travel_to(start_time + 1.minute) do
expect(subject.throttled?(:test_action, scope: scope, resource: project2)).to eq(true)
end
end
it 'returns false when unique actioned resource count does not exceed threshold' do
travel_to(start_time) do
expect(subject.throttled?(:test_action, scope: scope, resource: project1)).to eq(false)
end
travel_to(start_time + 1.minute) do
expect(subject.throttled?(:test_action, scope: scope, resource: project1)).to eq(false)
end
end
it 'returns false when interval has elapsed' do
travel_to(start_time) do
expect(subject.throttled?(:test_action, scope: scope, resource: project1)).to eq(false)
end
travel_to(start_time + 2.minutes) do
expect(subject.throttled?(:test_action, scope: scope, resource: project2)).to eq(false)
end
end
end
shared_examples 'throttles based on key and scope' do
let(:start_time) { Time.current.beginning_of_hour }
@ -91,7 +129,7 @@ RSpec.describe Gitlab::ApplicationRateLimiter, :clean_gitlab_redis_rate_limiting
travel_to(start_time) do
expect(subject.throttled?(:test_action, scope: scope)).to eq(false)
# another_action has a threshold of 3 so we simulate 2 requests
# another_action has a threshold of 2 so we simulate 2 requests
expect(subject.throttled?(:another_action, scope: scope)).to eq(false)
expect(subject.throttled?(:another_action, scope: scope)).to eq(false)
end
@ -189,4 +227,20 @@ RSpec.describe Gitlab::ApplicationRateLimiter, :clean_gitlab_redis_rate_limiting
end
end
end
context 'when interval is 0' do
let(:rate_limits) { { test_action: { threshold: 1, interval: 0 } } }
let(:scope) { user }
let(:start_time) { Time.current.beginning_of_hour }
it 'returns false' do
travel_to(start_time) do
expect(subject.throttled?(:test_action, scope: scope)).to eq(false)
end
travel_to(start_time + 1.minute) do
expect(subject.throttled?(:test_action, scope: scope)).to eq(false)
end
end
end
end

View File

@ -603,6 +603,7 @@ project:
- incident_management_oncall_schedules
- incident_management_oncall_rotations
- incident_management_escalation_policies
- incident_management_issuable_escalation_statuses
- debian_distributions
- merge_request_metrics
- security_orchestration_policy_configuration

View File

@ -68,20 +68,6 @@ RSpec.describe Sidebars::Projects::Menus::MonitorMenu do
it_behaves_like 'access rights checks'
end
describe 'Logs' do
let(:item_id) { :logs }
it_behaves_like 'access rights checks'
context 'when feature disabled' do
before do
stub_feature_flags(monitor_logging: false)
end
specify { is_expected.to be_nil }
end
end
describe 'Error Tracking' do
let(:item_id) { :error_tracking }

View File

@ -1553,33 +1553,6 @@ RSpec.describe Ci::Build do
end
end
describe 'deployment' do
describe '#outdated_deployment?' do
subject { build.outdated_deployment? }
context 'when build succeeded' do
let(:build) { create(:ci_build, :success) }
let!(:deployment) { create(:deployment, :success, deployable: build) }
context 'current deployment is latest' do
it { is_expected.to be_falsey }
end
context 'current deployment is not latest on environment' do
let!(:deployment2) { create(:deployment, :success, environment: deployment.environment) }
it { is_expected.to be_truthy }
end
end
context 'when build failed' do
let(:build) { create(:ci_build, :failed) }
it { is_expected.to be_falsey }
end
end
end
describe 'environment' do
describe '#has_environment?' do
subject { build.has_environment? }
@ -1961,16 +1934,6 @@ RSpec.describe Ci::Build do
end
end
describe '#first_pending' do
let!(:first) { create(:ci_build, pipeline: pipeline, status: 'pending', created_at: Date.yesterday) }
let!(:second) { create(:ci_build, pipeline: pipeline, status: 'pending') }
subject { described_class.first_pending }
it { is_expected.to be_a(described_class) }
it('returns with the first pending build') { is_expected.to eq(first) }
end
describe '#failed_but_allowed?' do
subject { build.failed_but_allowed? }

View File

@ -11,7 +11,9 @@ RSpec.describe IncidentManagement::IssuableEscalationStatus do
describe 'associations' do
it { is_expected.to belong_to(:issue) }
it { is_expected.to have_one(:project).through(:issue) }
it do
is_expected.to have_one(:project).through(:issue).inverse_of(:incident_management_issuable_escalation_statuses)
end
end
describe 'validatons' do

View File

@ -27,6 +27,7 @@ RSpec.describe Project, factory_default: :keep do
it { is_expected.to have_many(:merge_requests) }
it { is_expected.to have_many(:merge_request_metrics).class_name('MergeRequest::Metrics') }
it { is_expected.to have_many(:issues) }
it { is_expected.to have_many(:incident_management_issuable_escalation_statuses).through(:issues).inverse_of(:project).class_name('IncidentManagement::IssuableEscalationStatus') }
it { is_expected.to have_many(:milestones) }
it { is_expected.to have_many(:iterations) }
it { is_expected.to have_many(:project_members).dependent(:delete_all) }

View File

@ -612,6 +612,24 @@ RSpec.describe ProjectPolicy do
end
end
describe 'create_task' do
context 'when user is member of the project' do
let(:current_user) { developer }
context 'when work_items feature flag is enabled' do
it { expect_allowed(:create_task) }
end
context 'when work_items feature flag is disabled' do
before do
stub_feature_flags(work_items: false)
end
it { expect_disallowed(:create_task) }
end
end
end
describe 'update_max_artifacts_size' do
context 'when no user' do
let(:current_user) { anonymous }

View File

@ -53,6 +53,42 @@ RSpec.describe 'Create an issue' do
let(:mutation_class) { ::Mutations::Issues::Create }
end
context 'when creating an issue of type TASK' do
before do
input['type'] = 'TASK'
end
context 'when work_items feature flag is disabled' do
before do
stub_feature_flags(work_items: false)
end
it 'creates an issue with the default ISSUE type' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.to change(Issue, :count).by(1)
created_issue = Issue.last
expect(created_issue.work_item_type.base_type).to eq('issue')
expect(created_issue.issue_type).to eq('issue')
end
end
context 'when work_items feature flag is enabled' do
it 'creates an issue with TASK type' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
end.to change(Issue, :count).by(1)
created_issue = Issue.last
expect(created_issue.work_item_type.base_type).to eq('task')
expect(created_issue.issue_type).to eq('task')
end
end
end
context 'when position params are provided' do
let(:existing_issue) { create(:issue, project: project, relative_position: 50) }

View File

@ -32,9 +32,9 @@ RSpec.describe API::GroupExport do
context 'when export file exists' do
before do
allow(Gitlab::ApplicationRateLimiter)
.to receive(:increment)
.and_return(0)
allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
allow(strategy).to receive(:increment).and_return(0)
end
upload.export_file = fixture_file_upload('spec/fixtures/group_export.tar.gz', "`/tar.gz")
upload.save!
@ -149,9 +149,11 @@ RSpec.describe API::GroupExport do
before do
group.add_owner(user)
allow(Gitlab::ApplicationRateLimiter)
.to receive(:increment)
.and_return(Gitlab::ApplicationRateLimiter.rate_limits[:group_export][:threshold].call + 1)
allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
allow(strategy)
.to receive(:increment)
.and_return(Gitlab::ApplicationRateLimiter.rate_limits[:group_export][:threshold].call + 1)
end
end
it 'throttles the endpoint' do

View File

@ -1151,19 +1151,6 @@ RSpec.describe API::Issues do
expected_url = expose_url(api_v4_project_issue_path(id: new_issue.project_id, issue_iid: new_issue.iid))
expect(json_response.dig('_links', 'closed_as_duplicate_of')).to eq(expected_url)
end
context 'feature flag is disabled' do
before do
stub_feature_flags(closed_as_duplicate_of_issues_api: false)
end
it 'does not return the issue as closed_as_duplicate_of' do
get api("/projects/#{project.id}/issues/#{issue_closed_as_dup.iid}", user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.dig('_links', 'closed_as_duplicate_of')).to eq(nil)
end
end
end
end
end

View File

@ -248,9 +248,10 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache do
let(:request) { get api(download_path, admin) }
before do
allow(Gitlab::ApplicationRateLimiter)
.to receive(:increment)
.and_return(Gitlab::ApplicationRateLimiter.rate_limits[:project_download_export][:threshold].call + 1)
allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
threshold = Gitlab::ApplicationRateLimiter.rate_limits[:project_download_export][:threshold].call
allow(strategy).to receive(:increment).and_return(threshold + 1)
end
end
it 'prevents requesting project export' do
@ -433,9 +434,10 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache do
context 'when rate limit is exceeded across projects' do
before do
allow(Gitlab::ApplicationRateLimiter)
.to receive(:increment)
.and_return(Gitlab::ApplicationRateLimiter.rate_limits[:project_export][:threshold].call + 1)
allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
threshold = Gitlab::ApplicationRateLimiter.rate_limits[:project_export][:threshold].call
allow(strategy).to receive(:increment).and_return(threshold + 1)
end
end
it 'prevents requesting project export' do

View File

@ -24,11 +24,29 @@ RSpec.describe Groups::GroupLinks::DestroyService, '#execute' do
expect { subject.execute(link) }.to change { shared_group.shared_with_group_links.count }.from(1).to(0)
end
it 'revokes project authorization', :sidekiq_inline do
group.add_developer(user)
context 'with skip_group_share_unlink_auth_refresh feature flag disabled' do
before do
stub_feature_flags(skip_group_share_unlink_auth_refresh: false)
end
expect { subject.execute(link) }.to(
change { Ability.allowed?(user, :read_project, project) }.from(true).to(false))
it 'revokes project authorization', :sidekiq_inline do
group.add_developer(user)
expect { subject.execute(link) }.to(
change { Ability.allowed?(user, :read_project, project) }.from(true).to(false))
end
end
context 'with skip_group_share_unlink_auth_refresh feature flag enabled' do
before do
stub_feature_flags(skip_group_share_unlink_auth_refresh: true)
end
it 'maintains project authorization', :sidekiq_inline do
group.add_developer(user)
expect(Ability.allowed?(user, :read_project, project)).to be_truthy
end
end
end
@ -45,12 +63,32 @@ RSpec.describe Groups::GroupLinks::DestroyService, '#execute' do
]
end
it 'updates project authorization once per group' do
expect(GroupGroupLink).to receive(:delete).and_call_original
expect(group).to receive(:refresh_members_authorized_projects).with(direct_members_only: true, blocking: false).once
expect(another_group).to receive(:refresh_members_authorized_projects).with(direct_members_only: true, blocking: false).once
context 'with skip_group_share_unlink_auth_refresh feature flag disabled' do
before do
stub_feature_flags(skip_group_share_unlink_auth_refresh: false)
end
subject.execute(links)
it 'updates project authorization once per group' do
expect(GroupGroupLink).to receive(:delete).and_call_original
expect(group).to receive(:refresh_members_authorized_projects).with(direct_members_only: true, blocking: false).once
expect(another_group).to receive(:refresh_members_authorized_projects).with(direct_members_only: true, blocking: false).once
subject.execute(links)
end
end
context 'with skip_group_share_unlink_auth_refresh feature flag enabled' do
before do
stub_feature_flags(skip_group_share_unlink_auth_refresh: true)
end
it 'does not update project authorization once per group' do
expect(GroupGroupLink).to receive(:delete).and_call_original
expect(group).not_to receive(:refresh_members_authorized_projects)
expect(another_group).not_to receive(:refresh_members_authorized_projects)
subject.execute(links)
end
end
end
end

View File

@ -1,147 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::PodLogs::BaseService do
include KubernetesHelpers
let_it_be(:cluster) { create(:cluster, :provided_by_gcp, environment_scope: '*') }
let(:namespace) { 'autodevops-deploy-9-production' }
let(:pod_name) { 'pod-1' }
let(:pod_name_2) { 'pod-2' }
let(:container_name) { 'container-0' }
let(:params) { {} }
let(:raw_pods) do
[
{
name: pod_name,
container_names: %w(container-0-0 container-0-1)
},
{
name: pod_name_2,
container_names: %w(container-1-0 container-1-1)
}
]
end
subject { described_class.new(cluster, namespace, params: params) }
describe '#initialize' do
let(:params) do
{
'container_name' => container_name,
'another_param' => 'foo'
}
end
it 'filters the parameters' do
expect(subject.cluster).to eq(cluster)
expect(subject.namespace).to eq(namespace)
expect(subject.params).to eq({
'container_name' => container_name
})
expect(subject.params.equal?(params)).to be(false)
end
end
describe '#check_arguments' do
context 'when cluster and namespace are provided' do
it 'returns success' do
result = subject.send(:check_arguments, {})
expect(result[:status]).to eq(:success)
end
end
context 'when cluster is nil' do
let(:cluster) { nil }
it 'returns an error' do
result = subject.send(:check_arguments, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Cluster does not exist')
end
end
context 'when namespace is nil' do
let(:namespace) { nil }
it 'returns an error' do
result = subject.send(:check_arguments, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Namespace is empty')
end
end
context 'when namespace is empty' do
let(:namespace) { '' }
it 'returns an error' do
result = subject.send(:check_arguments, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Namespace is empty')
end
end
context 'when pod_name and container_name are provided' do
let(:params) do
{
'pod_name' => pod_name,
'container_name' => container_name
}
end
it 'returns success' do
result = subject.send(:check_arguments, {})
expect(result[:status]).to eq(:success)
expect(result[:pod_name]).to eq(pod_name)
expect(result[:container_name]).to eq(container_name)
end
end
context 'when pod_name is not a string' do
let(:params) do
{
'pod_name' => { something_that_is: :not_a_string }
}
end
it 'returns error' do
result = subject.send(:check_arguments, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Invalid pod_name')
end
end
context 'when container_name is not a string' do
let(:params) do
{
'container_name' => { something_that_is: :not_a_string }
}
end
it 'returns error' do
result = subject.send(:check_arguments, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Invalid container_name')
end
end
end
describe '#get_pod_names' do
it 'returns success with a list of pods' do
result = subject.send(:get_pod_names, raw_pods: raw_pods)
expect(result[:status]).to eq(:success)
expect(result[:pods]).to eq([pod_name, pod_name_2])
end
end
end

View File

@ -1,309 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::PodLogs::ElasticsearchService do
let_it_be(:cluster) { create(:cluster, :provided_by_gcp, environment_scope: '*') }
let(:namespace) { 'autodevops-deploy-9-production' }
let(:pod_name) { 'pod-1' }
let(:container_name) { 'container-1' }
let(:search) { 'foo -bar' }
let(:start_time) { '2019-01-02T12:13:14+02:00' }
let(:end_time) { '2019-01-03T12:13:14+02:00' }
let(:cursor) { '9999934,1572449784442' }
let(:params) { {} }
let(:expected_logs) do
[
{ message: "Log 1", timestamp: "2019-12-13T14:04:22.123456Z" },
{ message: "Log 2", timestamp: "2019-12-13T14:04:23.123456Z" },
{ message: "Log 3", timestamp: "2019-12-13T14:04:24.123456Z" }
]
end
let(:raw_pods) do
[
{
name: pod_name,
container_names: [container_name, "#{container_name}-1"]
}
]
end
subject { described_class.new(cluster, namespace, params: params) }
describe '#get_raw_pods' do
before do
create(:clusters_integrations_elastic_stack, cluster: cluster)
end
it 'returns success with elasticsearch response' do
allow_any_instance_of(::Clusters::Integrations::ElasticStack)
.to receive(:elasticsearch_client)
.and_return(Elasticsearch::Transport::Client.new)
allow_any_instance_of(::Gitlab::Elasticsearch::Logs::Pods)
.to receive(:pods)
.with(namespace)
.and_return(raw_pods)
result = subject.send(:get_raw_pods, {})
expect(result[:status]).to eq(:success)
expect(result[:raw_pods]).to eq(raw_pods)
end
it 'returns an error when ES is unreachable' do
allow_any_instance_of(::Clusters::Integrations::ElasticStack)
.to receive(:elasticsearch_client)
.and_return(nil)
result = subject.send(:get_raw_pods, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Unable to connect to Elasticsearch')
end
it 'handles server errors from elasticsearch' do
allow_any_instance_of(::Clusters::Integrations::ElasticStack)
.to receive(:elasticsearch_client)
.and_return(Elasticsearch::Transport::Client.new)
allow_any_instance_of(::Gitlab::Elasticsearch::Logs::Pods)
.to receive(:pods)
.and_raise(Elasticsearch::Transport::Transport::Errors::ServiceUnavailable.new)
result = subject.send(:get_raw_pods, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Elasticsearch returned status code: ServiceUnavailable')
end
end
describe '#check_times' do
context 'with start and end provided and valid' do
let(:params) do
{
'start_time' => start_time,
'end_time' => end_time
}
end
it 'returns success with times' do
result = subject.send(:check_times, {})
expect(result[:status]).to eq(:success)
expect(result[:start_time]).to eq(start_time)
expect(result[:end_time]).to eq(end_time)
end
end
context 'with start and end not provided' do
let(:params) do
{}
end
it 'returns success with nothing else' do
result = subject.send(:check_times, {})
expect(result.keys.length).to eq(1)
expect(result[:status]).to eq(:success)
end
end
context 'with start valid and end invalid' do
let(:params) do
{
'start_time' => start_time,
'end_time' => 'invalid date'
}
end
it 'returns error' do
result = subject.send(:check_times, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Invalid start or end time format')
end
end
context 'with start invalid and end valid' do
let(:params) do
{
'start_time' => 'invalid date',
'end_time' => end_time
}
end
it 'returns error' do
result = subject.send(:check_times, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Invalid start or end time format')
end
end
end
describe '#check_search' do
context 'with search provided and valid' do
let(:params) do
{
'search' => search
}
end
it 'returns success with search' do
result = subject.send(:check_search, {})
expect(result[:status]).to eq(:success)
expect(result[:search]).to eq(search)
end
end
context 'with search provided and invalid' do
let(:params) do
{
'search' => { term: "foo-bar" }
}
end
it 'returns error' do
result = subject.send(:check_search, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq("Invalid search parameter")
end
end
context 'with search not provided' do
let(:params) do
{}
end
it 'returns success with nothing else' do
result = subject.send(:check_search, {})
expect(result.keys.length).to eq(1)
expect(result[:status]).to eq(:success)
end
end
end
describe '#check_cursor' do
context 'with cursor provided and valid' do
let(:params) do
{
'cursor' => cursor
}
end
it 'returns success with cursor' do
result = subject.send(:check_cursor, {})
expect(result[:status]).to eq(:success)
expect(result[:cursor]).to eq(cursor)
end
end
context 'with cursor provided and invalid' do
let(:params) do
{
'cursor' => { term: "foo-bar" }
}
end
it 'returns error' do
result = subject.send(:check_cursor, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq("Invalid cursor parameter")
end
end
context 'with cursor not provided' do
let(:params) do
{}
end
it 'returns success with nothing else' do
result = subject.send(:check_cursor, {})
expect(result.keys.length).to eq(1)
expect(result[:status]).to eq(:success)
end
end
end
describe '#pod_logs' do
let(:result_arg) do
{
pod_name: pod_name,
container_name: container_name,
search: search,
start_time: start_time,
end_time: end_time,
cursor: cursor
}
end
let(:expected_cursor) { '9999934,1572449784442' }
before do
create(:clusters_integrations_elastic_stack, cluster: cluster)
end
it 'returns the logs' do
allow_any_instance_of(::Clusters::Integrations::ElasticStack)
.to receive(:elasticsearch_client)
.and_return(Elasticsearch::Transport::Client.new)
allow_any_instance_of(::Gitlab::Elasticsearch::Logs::Lines)
.to receive(:pod_logs)
.with(namespace, pod_name: pod_name, container_name: container_name, search: search, start_time: start_time, end_time: end_time, cursor: cursor, chart_above_v2: true)
.and_return({ logs: expected_logs, cursor: expected_cursor })
result = subject.send(:pod_logs, result_arg)
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
expect(result[:cursor]).to eq(expected_cursor)
end
it 'returns an error when ES is unreachable' do
allow_any_instance_of(::Clusters::Integrations::ElasticStack)
.to receive(:elasticsearch_client)
.and_return(nil)
result = subject.send(:pod_logs, result_arg)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Unable to connect to Elasticsearch')
end
it 'handles server errors from elasticsearch' do
allow_any_instance_of(::Clusters::Integrations::ElasticStack)
.to receive(:elasticsearch_client)
.and_return(Elasticsearch::Transport::Client.new)
allow_any_instance_of(::Gitlab::Elasticsearch::Logs::Lines)
.to receive(:pod_logs)
.and_raise(Elasticsearch::Transport::Transport::Errors::ServiceUnavailable.new)
result = subject.send(:pod_logs, result_arg)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Elasticsearch returned status code: ServiceUnavailable')
end
it 'handles cursor errors from elasticsearch' do
allow_any_instance_of(::Clusters::Integrations::ElasticStack)
.to receive(:elasticsearch_client)
.and_return(Elasticsearch::Transport::Client.new)
allow_any_instance_of(::Gitlab::Elasticsearch::Logs::Lines)
.to receive(:pod_logs)
.and_raise(::Gitlab::Elasticsearch::Logs::Lines::InvalidCursor.new)
result = subject.send(:pod_logs, result_arg)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Invalid cursor value provided')
end
end
end

View File

@ -1,310 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::PodLogs::KubernetesService do
include KubernetesHelpers
let_it_be(:cluster) { create(:cluster, :provided_by_gcp, environment_scope: '*') }
let(:namespace) { 'autodevops-deploy-9-production' }
let(:pod_name) { 'pod-1' }
let(:pod_name_2) { 'pod-2' }
let(:container_name) { 'container-0' }
let(:container_name_2) { 'foo-0' }
let(:params) { {} }
let(:raw_logs) do
"2019-12-13T14:04:22.123456Z Log 1\n2019-12-13T14:04:23.123456Z Log 2\n" \
"2019-12-13T14:04:24.123456Z Log 3"
end
let(:raw_pods) do
[
{
name: pod_name,
container_names: [container_name, "#{container_name}-1"]
},
{
name: pod_name_2,
container_names: [container_name_2, "#{container_name_2}-1"]
}
]
end
subject { described_class.new(cluster, namespace, params: params) }
describe '#get_raw_pods' do
let(:service) { create(:cluster_platform_kubernetes, :configured) }
it 'returns success with passthrough k8s response' do
stub_kubeclient_pods(namespace)
result = subject.send(:get_raw_pods, {})
expect(result[:status]).to eq(:success)
expect(result[:raw_pods]).to eq([{
name: 'kube-pod',
container_names: %w(container-0 container-0-1)
}])
end
end
describe '#pod_logs' do
let(:result_arg) do
{
pod_name: pod_name,
container_name: container_name
}
end
let(:expected_logs) { raw_logs }
let(:service) { create(:cluster_platform_kubernetes, :configured) }
it 'returns the logs' do
stub_kubeclient_logs(pod_name, namespace, container: container_name)
result = subject.send(:pod_logs, result_arg)
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
end
it 'handles Not Found errors from k8s' do
allow_any_instance_of(Gitlab::Kubernetes::KubeClient)
.to receive(:get_pod_log)
.with(any_args)
.and_raise(Kubeclient::ResourceNotFoundError.new(404, 'Not Found', {}))
result = subject.send(:pod_logs, result_arg)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Pod not found')
end
it 'handles HTTP errors from k8s' do
allow_any_instance_of(Gitlab::Kubernetes::KubeClient)
.to receive(:get_pod_log)
.with(any_args)
.and_raise(Kubeclient::HttpError.new(500, 'Error', {}))
result = subject.send(:pod_logs, result_arg)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Kubernetes API returned status code: 500')
end
end
describe '#encode_logs_to_utf8', :aggregate_failures do
let(:service) { create(:cluster_platform_kubernetes, :configured) }
let(:expected_logs) { '2019-12-13T14:04:22.123456Z ✔ Started logging errors to Sentry' }
let(:raw_logs) { expected_logs.dup.force_encoding(Encoding::ASCII_8BIT) }
let(:result) { subject.send(:encode_logs_to_utf8, result_arg) }
let(:result_arg) do
{
pod_name: pod_name,
container_name: container_name,
logs: raw_logs
}
end
it 'converts logs to utf-8' do
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
end
it 'returns error if output of encoding helper is blank' do
allow(Gitlab::EncodingHelper).to receive(:encode_utf8).and_return('')
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Unable to convert Kubernetes logs encoding to UTF-8')
end
it 'returns error if output of encoding helper is nil' do
allow(Gitlab::EncodingHelper).to receive(:encode_utf8).and_return(nil)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Unable to convert Kubernetes logs encoding to UTF-8')
end
it 'returns error if output of encoding helper is not UTF-8' do
allow(Gitlab::EncodingHelper).to receive(:encode_utf8)
.and_return(expected_logs.encode(Encoding::UTF_16BE))
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Unable to convert Kubernetes logs encoding to UTF-8')
end
context 'when logs are nil' do
let(:raw_logs) { nil }
let(:expected_logs) { nil }
it 'returns nil' do
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
end
end
context 'when logs are blank' do
let(:raw_logs) { (+'').force_encoding(Encoding::ASCII_8BIT) }
let(:expected_logs) { '' }
it 'returns blank string' do
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
end
end
context 'when logs are already in utf-8' do
let(:raw_logs) { expected_logs }
it 'does not fail' do
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
end
end
end
describe '#split_logs' do
let(:service) { create(:cluster_platform_kubernetes, :configured) }
let(:expected_logs) do
[
{ message: "Log 1", pod: 'pod-1', timestamp: "2019-12-13T14:04:22.123456Z" },
{ message: "Log 2", pod: 'pod-1', timestamp: "2019-12-13T14:04:23.123456Z" },
{ message: "Log 3", pod: 'pod-1', timestamp: "2019-12-13T14:04:24.123456Z" }
]
end
let(:result_arg) do
{
pod_name: pod_name,
container_name: container_name,
logs: raw_logs
}
end
it 'returns the logs' do
result = subject.send(:split_logs, result_arg)
aggregate_failures do
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
end
end
end
describe '#check_pod_name' do
it 'returns success if pod_name was specified' do
result = subject.send(:check_pod_name, pod_name: pod_name, pods: [pod_name])
expect(result[:status]).to eq(:success)
expect(result[:pod_name]).to eq(pod_name)
end
it 'returns success if pod_name was not specified but there are pods' do
result = subject.send(:check_pod_name, pod_name: nil, pods: [pod_name])
expect(result[:status]).to eq(:success)
expect(result[:pod_name]).to eq(pod_name)
end
it 'returns error if pod_name was not specified and there are no pods' do
result = subject.send(:check_pod_name, pod_name: nil, pods: [])
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('No pods available')
end
it 'returns error if pod_name was specified but does not exist' do
result = subject.send(:check_pod_name, pod_name: 'another-pod', pods: [pod_name])
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Pod does not exist')
end
it 'returns error if pod_name is too long' do
result = subject.send(:check_pod_name, pod_name: "a very long string." * 15, pods: [pod_name])
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('pod_name cannot be larger than 253 chars')
end
it 'returns error if pod_name is in invalid format' do
result = subject.send(:check_pod_name, pod_name: "Invalid_pod_name", pods: [pod_name])
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('pod_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character')
end
end
describe '#check_container_name' do
it 'returns success if container_name was specified' do
result = subject.send(:check_container_name,
container_name: container_name,
pod_name: pod_name,
raw_pods: raw_pods
)
expect(result[:status]).to eq(:success)
expect(result[:container_name]).to eq(container_name)
end
it 'returns success if container_name was not specified and there are containers' do
result = subject.send(:check_container_name,
pod_name: pod_name_2,
raw_pods: raw_pods
)
expect(result[:status]).to eq(:success)
expect(result[:container_name]).to eq(container_name_2)
end
it 'returns error if container_name was not specified and there are no containers on the pod' do
raw_pods.first[:container_names] = []
result = subject.send(:check_container_name,
pod_name: pod_name,
raw_pods: raw_pods
)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('No containers available')
end
it 'returns error if container_name was specified but does not exist' do
result = subject.send(:check_container_name,
container_name: 'foo',
pod_name: pod_name,
raw_pods: raw_pods
)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Container does not exist')
end
it 'returns error if container_name is too long' do
result = subject.send(:check_container_name,
container_name: "a very long string." * 15,
pod_name: pod_name,
raw_pods: raw_pods
)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('container_name cannot be larger than 253 chars')
end
it 'returns error if container_name is in invalid format' do
result = subject.send(:check_container_name,
container_name: "Invalid_container_name",
pod_name: pod_name,
raw_pods: raw_pods
)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('container_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character')
end
end
end

View File

@ -126,24 +126,6 @@ module KubernetesHelpers
WebMock.stub_request(:get, pod_url).to_return(response || kube_pod_response)
end
def stub_kubeclient_logs(pod_name, namespace, container: nil, status: nil, message: nil)
stub_kubeclient_discover(service.api_url)
if container
container_query_param = "container=#{container}&"
end
logs_url = service.api_url + "/api/v1/namespaces/#{namespace}/pods/#{pod_name}" \
"/log?#{container_query_param}tailLines=#{::PodLogs::KubernetesService::LOGS_LIMIT}&timestamps=true"
if status
response = { status: status }
response[:body] = { message: message }.to_json if message
end
WebMock.stub_request(:get, logs_url).to_return(response || kube_logs_response)
end
def stub_kubeclient_deployments(namespace, status: nil)
stub_kubeclient_discover(service.api_url)
deployments_url = service.api_url + "/apis/apps/v1/namespaces/#{namespace}/deployments"

View File

@ -2,6 +2,6 @@
RSpec::Matchers.define :match_file do |expected|
match do |actual|
expect(Digest::MD5.hexdigest(actual)).to eq(Digest::MD5.hexdigest(File.read(expected)))
expect(Digest::SHA256.hexdigest(actual)).to eq(Digest::SHA256.hexdigest(File.read(expected)))
end
end

View File

@ -82,7 +82,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type|
it { is_expected.to belong_to(:group) }
it { is_expected.to have_many(:issues) }
it { is_expected.to have_many(:merge_requests) }
it { is_expected.to have_many(:labels) }
it { is_expected.to have_many(:labels).through(:issues) }
end
describe '#timebox_name' do

View File

@ -275,7 +275,9 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
context 'when request exceeds the rate limit', :freeze_time, :clean_gitlab_redis_rate_limiting do
before do
stub_application_setting(notes_create_limit: 1)
allow(::Gitlab::ApplicationRateLimiter).to receive(:increment).and_return(2)
allow_next_instance_of(Gitlab::ApplicationRateLimiter::BaseStrategy) do |strategy|
allow(strategy).to receive(:increment).and_return(2)
end
end
it 'prevents user from creating more notes' do

View File

@ -419,24 +419,6 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
end
end
describe 'Logs' do
it 'has a link to the pod logs page' do
render
expect(rendered).to have_link('Logs', href: project_logs_path(project))
end
describe 'when the user does not have access' do
let(:user) { nil }
it 'does not have a link to the pod logs page' do
render
expect(rendered).not_to have_link('Logs')
end
end
end
describe 'Error Tracking' do
it 'has a link to the error tracking page' do
render

View File

@ -51,16 +51,41 @@ RSpec.describe RemoveExpiredGroupLinksWorker do
subject.perform
end
it 'removes project authorization', :sidekiq_inline do
shared_group = group_group_link.shared_group
shared_with_group = group_group_link.shared_with_group
project = create(:project, group: shared_group)
context 'with skip_group_share_unlink_auth_refresh feature flag disabled' do
before do
stub_feature_flags(skip_group_share_unlink_auth_refresh: false)
end
user = create(:user)
shared_with_group.add_maintainer(user)
it 'removes project authorization', :sidekiq_inline do
shared_group = group_group_link.shared_group
shared_with_group = group_group_link.shared_with_group
project = create(:project, group: shared_group)
expect { subject.perform }.to(
change { user.can?(:read_project, project) }.from(true).to(false))
user = create(:user)
shared_with_group.add_maintainer(user)
expect { subject.perform }.to(
change { user.can?(:read_project, project) }.from(true).to(false))
end
end
context 'with skip_group_share_unlink_auth_refresh feature flag enabled' do
before do
stub_feature_flags(skip_group_share_unlink_auth_refresh: true)
end
it 'does not remove project authorization', :sidekiq_inline do
shared_group = group_group_link.shared_group
shared_with_group = group_group_link.shared_with_group
project = create(:project, group: shared_group)
user = create(:user)
shared_with_group.add_maintainer(user)
subject.perform
expect(user.can?(:read_project, project)).to be_truthy
end
end
end