Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
bbd945a9ea
commit
ab421e159d
2
Gemfile
2
Gemfile
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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>
|
|
@ -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);
|
||||
};
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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:
|
||||
|
||||
|
|
|
@ -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. |
|
||||
|
|
|
@ -2516,8 +2516,8 @@ job2:
|
|||
job2:
|
||||
script: echo "test"
|
||||
only:
|
||||
- branches
|
||||
- tags
|
||||
- branches
|
||||
- tags
|
||||
```
|
||||
|
||||
#### `only:variables` / `except:variables`
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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/*
|
||||
```
|
||||
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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:"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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? }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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) }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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}×tamps=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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in New Issue