Commit ebc95134 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents e94e2691 62e6bb3d
15c2f3921c4729e9c4d7ce8592300decfcfdb2e6
12dcff902c9a2178fa6f4992d9d562ad9b422dd2
......@@ -20,7 +20,6 @@ export default {
components: {
GlIcon,
GlIntersectionObserver,
descriptionComponent,
titleComponent,
editedComponent,
formComponent,
......@@ -152,6 +151,18 @@ export default {
required: false,
default: 0,
},
descriptionComponent: {
type: Object,
required: false,
default: () => {
return descriptionComponent;
},
},
showTitleBorder: {
type: Boolean,
required: false,
default: true,
},
},
data() {
const store = new Store({
......@@ -209,6 +220,11 @@ export default {
isOpenStatus() {
return this.issuableStatus === IssuableStatus.Open;
},
pinnedLinkClasses() {
return this.showTitleBorder
? 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'
: '';
},
statusIcon() {
return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close';
},
......@@ -447,9 +463,11 @@ export default {
<pinned-links
:zoom-meeting-url="zoomMeetingUrl"
:published-incident-url="publishedIncidentUrl"
:class="pinnedLinkClasses"
/>
<description-component
<component
:is="descriptionComponent"
v-if="state.descriptionHtml"
:can-update="canUpdate"
:description-html="state.descriptionHtml"
......
<script>
import { GlTab, GlTabs } from '@gitlab/ui';
import DescriptionComponent from './description.vue';
export default {
components: {
GlTab,
GlTabs,
DescriptionComponent,
},
};
</script>
<template>
<div>
<gl-tabs
content-class="gl-reset-line-height gl-mt-3"
class="gl-mt-n3"
data-testid="incident-tabs"
>
<gl-tab :title="__('Summary')">
<description-component v-bind="$attrs" />
</gl-tab>
</gl-tabs>
</div>
</template>
......@@ -45,7 +45,7 @@ export default {
</script>
<template>
<div class="border-bottom gl-mb-6 gl-display-flex gl-justify-content-start">
<div class="gl-display-flex gl-justify-content-start">
<template v-for="(link, i) in pinnedLinks">
<div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }">
<gl-button
......
import Vue from 'vue';
import issuableApp from './components/app.vue';
import incidentTabs from './components/incident_tabs.vue';
export default function initIssuableApp(issuableData = {}) {
return new Vue({
el: document.getElementById('js-issuable-app'),
components: {
issuableApp,
},
render(createElement) {
return createElement('issuable-app', {
props: {
...issuableData,
descriptionComponent: incidentTabs,
showTitleBorder: false,
},
});
},
});
}
import Vue from 'vue';
import issuableApp from './components/app.vue';
import { parseIssuableData } from './utils/parse_data';
export default function initIssueableApp() {
export default function initIssuableApp(issuableData) {
return new Vue({
el: document.getElementById('js-issuable-app'),
components: {
......@@ -10,7 +9,7 @@ export default function initIssueableApp() {
},
render(createElement) {
return createElement('issuable-app', {
props: parseIssuableData(),
props: issuableData,
});
},
});
......
......@@ -4,14 +4,23 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode';
import '~/notes/index';
import { store } from '~/notes/stores';
import initIssueableApp from '~/issue_show';
import initIssueApp from '~/issue_show/issue';
import initIncidentApp from '~/issue_show/incident';
import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
import initRelatedMergeRequestsApp from '~/related_merge_requests';
import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
import { parseIssuableData } from '~/issue_show/utils/parse_data';
export default function() {
initIssueableApp();
const { issueType, ...issuableData } = parseIssuableData();
if (issueType === 'incident') {
initIncidentApp(issuableData);
} else {
initIssueApp(issuableData);
}
initIssuableHeaderWarning(store);
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
......
......@@ -327,7 +327,8 @@ module ApplicationSettingsHelper
:group_import_limit,
:group_export_limit,
:group_download_export_limit,
:wiki_page_max_content_bytes
:wiki_page_max_content_bytes,
:container_registry_delete_tags_service_timeout
]
end
......
# frozen_string_literal: true
module ContainerRegistryHelper
def limit_delete_tags_service?
Feature.enabled?(:container_registry_expiration_policies_throttling) &&
ContainerRegistry::Client.supports_tag_delete?
end
end
......@@ -292,6 +292,7 @@ module IssuablesHelper
{
hasClosingMergeRequest: issuable.merge_requests_count(current_user) != 0,
issueType: issuable.issue_type,
zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable),
sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier # rubocop:disable CodeReuse/ActiveRecord
}
......@@ -301,8 +302,8 @@ module IssuablesHelper
return { groupPath: parent.path } if parent.is_a?(Group)
{
projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path
projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path
}
end
......
......@@ -282,6 +282,9 @@ class ApplicationSetting < ApplicationRecord
validates :hashed_storage_enabled, inclusion: { in: [true], message: _("Hashed storage can't be disabled anymore for new projects") }
validates :container_registry_delete_tags_service_timeout,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
......
......@@ -163,7 +163,8 @@ module ApplicationSettingImplementation
user_default_external: false,
user_default_internal_regex: nil,
user_show_add_ssh_key_message: true,
wiki_page_max_content_bytes: 50.megabytes
wiki_page_max_content_bytes: 50.megabytes,
container_registry_delete_tags_service_timeout: 100
}
end
......
......@@ -351,10 +351,10 @@ class Service < ApplicationRecord
{ success: result.present?, result: result }
end
# Disable test for instance-level services.
# Disable test for instance-level and group-level services.
# https://gitlab.com/gitlab-org/gitlab/-/issues/213138
def can_test?
!instance?
!instance? && !group_id
end
# Returns a hash of the properties that have been assigned a new value since last save,
......
......@@ -5,6 +5,11 @@ module Projects
module Gitlab
class DeleteTagsService
include BaseServiceUtility
include ::Gitlab::Utils::StrongMemoize
DISABLED_TIMEOUTS = [nil, 0].freeze
TimeoutError = Class.new(StandardError)
def initialize(container_repository, tag_names)
@container_repository = container_repository
......@@ -17,12 +22,42 @@ module Projects
def execute
return success(deleted: []) if @tag_names.empty?
delete_tags
rescue TimeoutError => e
::Gitlab::ErrorTracking.track_exception(e, tags_count: @tag_names&.size, container_repository_id: @container_repository&.id)
error('timeout while deleting tags')
end
private
def delete_tags
start_time = Time.zone.now
deleted_tags = @tag_names.select do |name|
raise TimeoutError if timeout?(start_time)
@container_repository.delete_tag_by_name(name)
end
deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags')
end
def timeout?(start_time)
return false unless throttling_enabled?
return false if service_timeout.in?(DISABLED_TIMEOUTS)
(Time.zone.now - start_time) > service_timeout
end
def throttling_enabled?
strong_memoize(:feature_flag) do
Feature.enabled?(:container_registry_expiration_policies_throttling)
end
end
def service_timeout
::Gitlab::CurrentSettings.current_application_settings.container_registry_delete_tags_service_timeout
end
end
end
end
......
......@@ -15,7 +15,7 @@ module Projects
# This is a hack as the registry doesn't support deleting individual
# tags. This code effectively pushes a dummy image and assigns the tag to it.
# This way when the tag is deleted only the dummy image is affected.
# This is used to preverse compatibility with third-party registries that
# This is used to preserve compatibility with third-party registries that
# don't support fast delete.
# See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion
def execute
......
......@@ -14,5 +14,11 @@
.form-text.text-muted
= _("Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.")
= link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'use-with-external-container-registries')
- if limit_delete_tags_service?
.form-group
= f.label :container_registry_delete_tags_service_timeout, _('Cleanup policy maximum processing time (seconds)'), class: 'label-bold'
= f.number_field :container_registry_delete_tags_service_timeout, min: 0, class: 'form-control'
.form-text.text-muted
= _("Tags are deleted until the timeout is reached. Any remaining tags are included the next time the policy runs. To remove the time limit, set it to 0.")
= f.submit 'Save changes', class: "btn btn-success"
......@@ -21,7 +21,7 @@
.col-sm-12
= f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title, required: true, autofocus: !@page.persisted?, placeholder: s_('Wiki|Page title')
%span.d-inline-block.mw-100.gl-mt-2
= icon('lightbulb-o')
= sprite_icon('bulb', size: 12, css_class: 'gl-mr-n1')
- if @page.persisted?
= s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.")
= link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'),
......
......@@ -17,13 +17,13 @@
= sprite_icon('pencil')
- elsif current_user
- if @user.abuse_report
%button{ class: link_classes + 'btn btn-danger mr-1', title: s_('UserProfile|Already reported for abuse'),
data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }
= icon('exclamation-circle')
%button{ class: link_classes + 'btn btn-danger', title: s_('UserProfile|Already reported for abuse'),
data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }>
= sprite_icon('error')
- else
= link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn',
title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= icon('exclamation-circle')
= sprite_icon('error')
- if can?(current_user, :read_user_profile, @user)
= link_to user_path(@user, rss_url_options), class: link_classes + 'btn btn-svg btn-default has-tooltip', title: s_('UserProfile|Subscribe'), 'aria-label': 'Subscribe' do
= sprite_icon('rss', css_class: 'qa-rss-icon')
......
---
title: Add timeout support in the delete tags service for the GitLab Registry
merge_request: 36319
author:
type: changed
---
title: Replace fa-exclamation-circle and fa-lightbulb-o with GitLab SVG icons
merge_request: 40857
author:
type: changed
---
title: Add Summary tab for incident issues
merge_request: 39822
author:
type: added
---
name: container_registry_expiration_policies_throttling
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36319
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/238190
group: group::package
type: development
default_enabled: false
# frozen_string_literal: true
class AddContainerRegistryDeleteTagsServiceTimeoutToApplicationSettings < ActiveRecord::Migration[6.0]
DOWNTIME = false
def up
add_column(
:application_settings,
:container_registry_delete_tags_service_timeout,
:integer,
default: 250,
null: false
)
end
def down
remove_column(:application_settings, :container_registry_delete_tags_service_timeout)
end
end
3d49c22b718c5b4af0a7372584fe12ab730e1ffca501c7f582f7d01200708eb1
\ No newline at end of file
......@@ -9266,6 +9266,7 @@ CREATE TABLE public.application_settings (
wiki_page_max_content_bytes bigint DEFAULT 52428800 NOT NULL,
elasticsearch_indexed_file_size_limit_kb integer DEFAULT 1024 NOT NULL,
enforce_namespace_storage_limit boolean DEFAULT false NOT NULL,
container_registry_delete_tags_service_timeout integer DEFAULT 250 NOT NULL,
CONSTRAINT check_51700b31b5 CHECK ((char_length(default_branch_name) <= 255)),
CONSTRAINT check_9c6c447a13 CHECK ((char_length(maintenance_mode_message) <= 255)),
CONSTRAINT check_d03919528d CHECK ((char_length(container_registry_vendor) <= 255)),
......
......@@ -533,6 +533,11 @@ The cleanup policy:
1. Excludes from the list any tags matching the `name_regex_keep` value (tags to preserve).
1. Finally, the remaining tags in the list are deleted from the Container Registry.
CAUTION: **Warning:**
On GitLab.com, the execution time for the cleanup policy is limited, and some of the tags may remain in
the Container Registry after the policy runs. The next time the policy runs, the remaining tags are included,
so it may take multiple runs for all tags to be deleted.
### Create a cleanup policy
You can create a cleanup policy in [the API](#use-the-cleanup-policy-api) or the UI.
......
......@@ -4,9 +4,9 @@ import VulnerabilitySeverities from 'ee/security_dashboard/components/first_clas
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import projectsQuery from 'ee/security_dashboard/graphql/get_instance_security_dashboard_projects.query.graphql';
import createFlash from '~/flash';
import { createProjectLoadingError } from '../helpers';
import InstanceSecurityVulnerabilities from './first_class_instance_security_dashboard_vulnerabilities.vue';
import { __ } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import CsvExportButton from './csv_export_button.vue';
import vulnerabilityHistoryQuery from '../graphql/instance_vulnerability_history.query.graphql';
import vulnerabilityGradesQuery from '../graphql/instance_vulnerability_grades.query.graphql';
......@@ -35,7 +35,7 @@ export default {
return data.instanceSecurityDashboard.projects.nodes;
},
error() {
createFlash(__('Something went wrong, unable to get projects'));
createFlash({ message: createProjectLoadingError() });
},
},
},
......
......@@ -2,6 +2,7 @@
import { GlAlert } from '@gitlab/ui';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import projectsQuery from 'ee/security_dashboard/graphql/get_instance_security_dashboard_projects.query.graphql';
import { createProjectLoadingError } from '../helpers';
import ProjectManager from './first_class_project_manager/project_manager.vue';
export default {
......@@ -27,13 +28,18 @@ export default {
hasError: false,
};
},
computed: {
errorMessage() {
return createProjectLoadingError();
},
},
};
</script>
<template>
<security-dashboard-layout>
<gl-alert v-if="hasError" variant="danger">
{{ __('Something went wrong, unable to get projects') }}
{{ errorMessage }}
</gl-alert>
<div v-else class="gl-display-flex gl-justify-content-center">
<project-manager :projects="projects" />
......
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { createProjectLoadingError } from '../helpers';
import DashboardNotConfigured from './empty_states/group_dashboard_not_configured.vue';
import SecurityChartsLayout from './security_charts_layout.vue';
import VulnerabilityChart from './first_class_vulnerability_chart.vue';
import VulnerabilitySeverities from './first_class_vulnerability_severities.vue';
import vulnerabilityHistoryQuery from '../graphql/group_vulnerability_history.query.graphql';
import vulnerabilityGradesQuery from '../graphql/group_vulnerability_grades.query.graphql';
import SecurityChartsLayout from './security_charts_layout.vue';
import vulnerableProjectsQuery from '../graphql/vulnerable_projects.query.graphql';
export default {
components: {
GlLoadingIcon,
DashboardNotConfigured,
SecurityChartsLayout,
VulnerabilitySeverities,
VulnerabilityChart,
......@@ -17,18 +24,55 @@ export default {
required: true,
},
},
apollo: {
projects: {
query: vulnerableProjectsQuery,
variables() {
return { fullPath: this.groupFullPath };
},
update(data) {
return data?.group?.projects?.nodes ?? [];
},
error() {
createFlash({ message: createProjectLoadingError() });
},
},
},
data() {
return {
projects: [],
vulnerabilityHistoryQuery,
vulnerabilityGradesQuery,
};
},
computed: {
isLoadingProjects() {
return this.$apollo.queries.projects.loading;
},
shouldShowCharts() {
return Boolean(!this.isLoadingProjects && this.projects.length);
},
shouldShowEmptyState() {
return !this.isLoadingProjects && !this.projects.length;
},
},
};
</script>
<template>
<security-charts-layout>
<vulnerability-chart :query="vulnerabilityHistoryQuery" :group-full-path="groupFullPath" />
<vulnerability-severities :query="vulnerabilityGradesQuery" :group-full-path="groupFullPath" />
<template v-if="shouldShowEmptyState" #empty-state>
<dashboard-not-configured />
</template>
<template v-else-if="shouldShowCharts" #default>
<vulnerability-chart :query="vulnerabilityHistoryQuery" :group-full-path="groupFullPath" />
<vulnerability-severities
:query="vulnerabilityGradesQuery"
:group-full-path="groupFullPath"
/>
</template>
<template v-else #loading>
<gl-loading-icon size="lg" class="gl-mt-6" />
</template>
</security-charts-layout>
</template>
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { createProjectLoadingError } from '../helpers';
import DashboardNotConfigured from './empty_states/instance_dashboard_not_configured.vue';
import SecurityChartsLayout from './security_charts_layout.vue';
import VulnerabilityChart from './first_class_vulnerability_chart.vue';
import VulnerabilitySeverities from './first_class_vulnerability_severities.vue';
import projectsQuery from '../graphql/get_instance_security_dashboard_projects.query.graphql';
import vulnerabilityHistoryQuery from '../graphql/instance_vulnerability_history.query.graphql';
import vulnerabilityGradesQuery from '../graphql/instance_vulnerability_grades.query.graphql';
export default {
components: {
GlLoadingIcon,
DashboardNotConfigured,
SecurityChartsLayout,
VulnerabilitySeverities,
VulnerabilityChart,
},
apollo: {
projects: {
query: projectsQuery,
update(data) {
return data?.instanceSecurityDashboard?.projects?.nodes ?? [];
},
error() {
createFlash({ message: createProjectLoadingError() });
},
},
},
data() {
return {
projects: [],
vulnerabilityHistoryQuery,
vulnerabilityGradesQuery,
};
},
computed: {
isLoadingProjects() {
return this.$apollo.queries.projects.loading;
},
shouldShowCharts() {
return Boolean(!this.isLoadingProjects && this.projects.length);
},
shouldShowEmptyState() {
return !this.isLoadingProjects && !this.projects.length;
},
},
};
</script>
<template>
<security-charts-layout>
<vulnerability-chart :query="vulnerabilityHistoryQuery" />
<vulnerability-severities :query="vulnerabilityGradesQuery" />
<template v-if="shouldShowEmptyState" #empty-state>
<dashboard-not-configured />
</template>
<template v-else-if="shouldShowCharts" #default>
<vulnerability-chart :query="vulnerabilityHistoryQuery" />
<vulnerability-severities :query="vulnerabilityGradesQuery" />
</template>
<template v-else #loading>
<gl-loading-icon size="lg" class="gl-mt-6" />
</template>
</security-charts-layout>
</template>
......@@ -8,11 +8,13 @@ export default {
};
</script>
<template functional>
<div>
<template>
<div data-testid="security-charts-layout">
<h2>{{ $options.i18n.title }}</h2>
<slot name="loading"></slot>
<div class="security-charts gl-display-flex gl-flex-wrap">
<slot></slot>
</div>
<slot name="empty-state"></slot>
</div>
</template>
export const COLLAPSE_SECURITY_REPORTS_SUMMARY_LOCAL_STORAGE_KEY =
'hide_pipelines_security_reports_summary_details';
export default () => ({});
......@@ -3,7 +3,7 @@ import { ALL, BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/c
import { REPORT_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { s__, __ } from '~/locale';
const parseOptions = obj =>
Object.entries(obj).map(([id, name]) => ({ id: id.toUpperCase(), name }));
......@@ -107,4 +107,6 @@ export const preparePageInfo = pageInfo => {
return { ...pageInfo, hasNextPage: Boolean(pageInfo?.endCursor) };
};
export const createProjectLoadingError = () => __('An error occurred while retrieving projects.');
export default () => ({});
......@@ -28,6 +28,10 @@ export default (el, dashboardType) => {
}
const props = {};
const provide = {
dashboardDocumentation: el.dataset.dashboardDocumentation,
emptyStateSvgPath: el.dataset.emptyStateSvgPath,
};
let component;
......@@ -36,6 +40,7 @@ export default (el, dashboardType) => {
props.groupFullPath = el.dataset.groupFullPath;
} else if (dashboardType === DASHBOARD_TYPES.INSTANCE) {
component = InstanceSecurityCharts;
provide.instanceDashboardSettingsPath = el.dataset.instanceDashboardSettingsPath;
}
const router = createRouter();
......@@ -46,6 +51,7 @@ export default (el, dashboardType) => {
store,
router,
apolloProvider,
provide: () => provide,
render(createElement) {
return createElement(component, { props });
},
......
---
title: Implement empty state on security dashboard
merge_request: 40413
author:
type: changed
......@@ -51,7 +51,7 @@ describe('First Class Instance Dashboard Component', () => {
});
it('renders the alert component', () => {
expect(findAlert().text()).toBe('Something went wrong, unable to get projects');
expect(findAlert().text()).toBe('An error occurred while retrieving projects.');
});
});
});
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'jest/helpers/test_constants';
import { GlLoadingIcon } from '@gitlab/ui';
import DashboardNotConfigured from 'ee/security_dashboard/components/empty_states/group_dashboard_not_configured.vue';
import GroupSecurityCharts from 'ee/security_dashboard/components/group_security_charts.vue';
import SecurityChartsLayout from 'ee/security_dashboard/components/security_charts_layout.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import VulnerabilitySeverities from 'ee/security_dashboard/components/first_class_vulnerability_severities.vue';
import vulnerabilityHistoryQuery from 'ee/security_dashboard/graphql/group_vulnerability_history.query.graphql';
import vulnerabilityGradesQuery from 'ee/security_dashboard/graphql/group_vulnerability_grades.query.graphql';
jest.mock('ee/security_dashboard/graphql/group_vulnerability_history.query.graphql', () => ({}));
jest.mock('ee/security_dashboard/graphql/group_vulnerability_grades.query.graphql', () => ({
mockGrades: true,
}));
jest.mock('ee/security_dashboard/graphql/group_vulnerability_history.query.graphql', () => ({
mockHistory: true,
}));
describe('Group Security Charts component', () => {
let wrapper;
......@@ -13,32 +22,87 @@ describe('Group Security Charts component', () => {
const groupFullPath = `${TEST_HOST}/group/5`;
const findSecurityChartsLayoutComponent = () => wrapper.find(SecurityChartsLayout);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findVulnerabilityChart = () => wrapper.find(VulnerabilityChart);
const findVulnerabilitySeverities = () => wrapper.find(VulnerabilitySeverities);
const findDashboardNotConfigured = () => wrapper.find(DashboardNotConfigured);
const createWrapper = () => {
const createWrapper = ({ loading = false } = {}) => {
wrapper = shallowMount(GroupSecurityCharts, {
mocks: {
$apollo: {
queries: {
projects: {
loading,
},
},
},
},
propsData: { groupFullPath },
stubs: {
SecurityChartsLayout,
},
});
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders the default page', () => {
it('renders the loading page', () => {
createWrapper({ loading: true });
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(vulnerabilityChart.props()).toEqual({ query: {}, groupFullPath });
expect(dashboardNotConfigured.exists()).toBe(false);
expect(loadingIcon.exists()).toBe(true);
expect(vulnerabilityChart.exists()).toBe(false);
expect(vulnerabilitySeverities.exists()).toBe(false);
});
it('renders the empty state', () => {
createWrapper();
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(true);
expect(loadingIcon.exists()).toBe(false);
expect(vulnerabilityChart.exists()).toBe(false);
expect(vulnerabilitySeverities.exists()).toBe(false);
});
it('renders the default page', async () => {
createWrapper();
wrapper.setData({ projects: [{ name: 'project1' }] });
await wrapper.vm.$nextTick();
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(false);
expect(loadingIcon.exists()).toBe(false);
expect(vulnerabilityChart.exists()).toBe(true);
expect(vulnerabilityChart.props()).toEqual({ query: vulnerabilityHistoryQuery, groupFullPath });
expect(vulnerabilitySeverities.exists()).toBe(true);
expect(vulnerabilitySeverities.props().groupFullPath).toEqual(groupFullPath);
expect(vulnerabilitySeverities.props()).toEqual({
query: vulnerabilityGradesQuery,
groupFullPath,
helpPagePath: '',
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import InstanceSecurityCharts from 'ee/security_dashboard/components/instance_security_charts.vue';
import DashboardNotConfigured from 'ee/security_dashboard/components/empty_states/instance_dashboard_not_configured.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import VulnerabilitySeverities from 'ee/security_dashboard/components/first_class_vulnerability_severities.vue';
import SecurityChartsLayout from 'ee/security_dashboard/components/security_charts_layout.vue';
......@@ -17,28 +19,79 @@ describe('Instance Security Charts component', () => {
let wrapper;
const findSecurityChartsLayoutComponent = () => wrapper.find(SecurityChartsLayout);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findVulnerabilityChart = () => wrapper.find(VulnerabilityChart);
const findVulnerabilitySeverities = () => wrapper.find(VulnerabilitySeverities);
const findDashboardNotConfigured = () => wrapper.find(DashboardNotConfigured);
const createWrapper = () => {
wrapper = shallowMount(InstanceSecurityCharts, {});
const createWrapper = ({ loading = false } = {}) => {
wrapper = shallowMount(InstanceSecurityCharts, {
mocks: {
$apollo: {
queries: {
projects: {
loading,
},
},
},
},
stubs: {
SecurityChartsLayout,
},
});
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders the default page', () => {
it('renders the loading page', () => {
createWrapper({ loading: true });
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(false);
expect(loadingIcon.exists()).toBe(true);
expect(vulnerabilityChart.exists()).toBe(false);
expect(vulnerabilitySeverities.exists()).toBe(false);
});
it('renders the empty state', () => {
createWrapper();
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(true);
expect(loadingIcon.exists()).toBe(false);
expect(vulnerabilityChart.exists()).toBe(false);
expect(vulnerabilitySeverities.exists()).toBe(false);
});
it('renders the default page', async () => {
createWrapper();
wrapper.setData({ projects: [{ name: 'project1' }] });
await wrapper.vm.$nextTick();
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(false);
expect(loadingIcon.exists()).toBe(false);
expect(vulnerabilityChart.props()).toEqual({ query: vulnerabilityHistoryQuery });
expect(vulnerabilitySeverities.exists()).toBe(true);
expect(vulnerabilitySeverities.props()).toEqual({
......
......@@ -4,19 +4,23 @@ import SecurityChartsLayout from 'ee/security_dashboard/components/security_char
describe('Security Charts Layout component', () => {
let wrapper;
const DummyComponent = {
name: 'dummy-component',
template: '<p>dummy component</p>',
const DummyComponent1 = {
name: 'dummy-component-1',
template: '<p>dummy component 1</p>',
};
const DummyComponent2 = {
name: 'dummy-component-2',
template: '<p>dummy component 2</p>',
};
const findSlot = () => wrapper.find('.security-charts');
const findSlot = () => wrapper.find(`[data-testid="security-charts-layout"]`);
const createWrapper = slots => {
wrapper = shallowMount(SecurityChartsLayout, { slots });
};
beforeEach(() => {
createWrapper({ default: DummyComponent });
createWrapper({ default: DummyComponent1, 'empty-state': DummyComponent2 });
});
afterEach(() => {
......@@ -26,6 +30,11 @@ describe('Security Charts Layout component', () => {
it('should render the default slot', () => {
const slot = findSlot();
expect(slot.find(DummyComponent).exists()).toBe(true);
expect(slot.find(DummyComponent1).exists()).toBe(true);
});
it('should render the empty-state slot', () => {
const slot = findSlot();
expect(slot.find(DummyComponent2).exists()).toBe(true);
});
});
......@@ -21,6 +21,17 @@ module ContainerRegistry
# Taken from: FaradayMiddleware::FollowRedirects
REDIRECT_CODES = Set.new [301, 302, 303, 307]
def self.supports_tag_delete?
registry_config = Gitlab.config.registry
return false unless registry_config.enabled && registry_config.api_url.present?
return true if ::Gitlab.com?
token = Auth::ContainerRegistryAuthenticationService.access_token([], [])
client = new(registry_config.api_url, token: token)
client.supports_tag_delete?
end
def initialize(base_uri, options = {})
@base_uri = base_uri
@options = options
......
......@@ -2834,6 +2834,9 @@ msgstr ""
msgid "An error occurred while retrieving diff files"
msgstr ""
msgid "An error occurred while retrieving projects."
msgstr ""
msgid "An error occurred while saving LDAP override status. Please try again."
msgstr ""
......@@ -5009,6 +5012,9 @@ msgstr ""
msgid "Cleanup policy for tags"
msgstr ""
msgid "Cleanup policy maximum processing time (seconds)"
msgstr ""
msgid "Clear"
msgstr ""
......@@ -24103,6 +24109,9 @@ msgstr ""
msgid "Tags"
msgstr ""
msgid "Tags are deleted until the timeout is reached. Any remaining tags are included the next time the policy runs. To remove the time limit, set it to 0."
msgstr ""
msgid "Tags feed"
msgstr ""
......
......@@ -285,6 +285,55 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n
expect(current_settings.auto_devops_domain).to eq('domain.com')
expect(page).to have_content "Application settings saved successfully"
end
context 'Container Registry' do
context 'delete tags service execution timeout' do
let(:feature_flag_enabled) { true }
let(:client_support) { true }
before do
stub_container_registry_config(enabled: true)
stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled)
allow(ContainerRegistry::Client).to receive(:supports_tag_delete?).and_return(client_support)
end
RSpec.shared_examples 'not having service timeout settings' do
it 'lacks the timeout settings' do
visit ci_cd_admin_application_settings_path
expect(page).not_to have_content "Container Registry delete tags service execution timeout"
end
end
context 'with feature flag enabled' do
context 'with client supporting tag delete' do
it 'changes the timeout' do
visit ci_cd_admin_application_settings_path
page.within('.as-registry') do
fill_in 'application_setting_container_registry_delete_tags_service_timeout', with: 400
click_button 'Save changes'
end
expect(current_settings.container_registry_delete_tags_service_timeout).to eq(400)
expect(page).to have_content "Application settings saved successfully"
end
end
context 'with client not supporting tag delete' do
let(:client_support) { false }
it_behaves_like 'not having service timeout settings'
end
end
context 'with feature flag disabled' do
let(:feature_flag_enabled) { false }
it_behaves_like 'not having service timeout settings'
end
end
end
end
context 'Repository page' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Incident Detail', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:incident) { create(:issue, project: project, author: user, issue_type: 'incident', description: 'hello') }
context 'when user displays the incident' do
before do
visit project_issue_path(project, incident)
wait_for_requests
end
it 'shows the incident tabs' do
page.within('.issuable-details') do
incident_tabs = find('[data-testid="incident-tabs"]')
expect(find('h2')).to have_content(incident.title)
expect(incident_tabs).to have_content('Summary')
expect(incident_tabs).to have_content(incident.description)
end
end
end
end
......@@ -9,6 +9,9 @@ import '~/behaviors/markdown/render_gfm';
import IssuableApp from '~/issue_show/components/app.vue';
import eventHub from '~/issue_show/event_hub';
import { initialRequest, secondRequest } from '../mock_data';
import IncidentTabs from '~/issue_show/components/incident_tabs.vue';
import DescriptionComponent from '~/issue_show/components/description.vue';
import PinnedLinks from '~/issue_show/components/pinned_links.vue';
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
......@@ -22,6 +25,27 @@ const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811';
const publishedIncidentUrl = 'https://status.com/';
const defaultProps = {
canUpdate: true,
canDestroy: true,
endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
updateEndpoint: TEST_HOST,
issuableRef: '#1',
issuableStatus: 'opened',
initialTitleHtml: '',
initialTitleText: '',
initialDescriptionHtml: 'test',
initialDescriptionText: 'test',
lockVersion: 1,
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectNamespace: '/',
projectPath: '/',
issuableTemplateNamesPath: '/issuable-templates-path',
zoomMeetingUrl,
publishedIncidentUrl,
};
describe('Issuable output', () => {
useMockIntersectionObserver();
......@@ -31,6 +55,12 @@ describe('Issuable output', () => {
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
const mountComponent = (props = {}) => {
wrapper = mount(IssuableApp, {
propsData: { ...defaultProps, ...props },
});
};
beforeEach(() => {
setFixtures(`
<div>
......@@ -57,28 +87,7 @@ describe('Issuable output', () => {
return res;
});
wrapper = mount(IssuableApp, {
propsData: {
canUpdate: true,
canDestroy: true,
endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
updateEndpoint: TEST_HOST,
issuableRef: '#1',
issuableStatus: 'opened',
initialTitleHtml: '',
initialTitleText: '',
initialDescriptionHtml: 'test',
initialDescriptionText: 'test',
lockVersion: 1,
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectNamespace: '/',
projectPath: '/',
issuableTemplateNamesPath: '/issuable-templates-path',
zoomMeetingUrl,
publishedIncidentUrl,
},
});
mountComponent();
});
afterEach(() => {
......@@ -562,4 +571,46 @@ describe('Issuable output', () => {
});
});
});
describe('Composable description component', () => {
const findIncidentTabs = () => wrapper.find(IncidentTabs);
const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
const findPinnedLinks = () => wrapper.find(PinnedLinks);
const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6';
describe('when using description component', () => {
it('renders the description component', () => {
expect(findDescriptionComponent().exists()).toBe(true);
});
it('does not render incident tabs', () => {
expect(findIncidentTabs().exists()).toBe(false);
});
it('adds a border below the header', () => {
expect(findPinnedLinks().attributes('class')).toContain(borderClass);
});
});
describe('when using incident tabs description wrapper', () => {
beforeEach(() => {
mountComponent({
descriptionComponent: IncidentTabs,
showTitleBorder: false,
});
});
it('renders the description component', () => {
expect(findDescriptionComponent().exists()).toBe(true);
});
it('renders incident tabs', () => {
expect(findIncidentTabs().exists()).toBe(true);
});
it('does not add a border below the header', () => {
expect(findPinnedLinks().attributes('class')).not.toContain(borderClass);
});
});
});
});
......@@ -5,20 +5,13 @@ import mountComponent from 'helpers/vue_mount_component_helper';
import { TEST_HOST } from 'helpers/test_constants';
import Description from '~/issue_show/components/description.vue';
import TaskList from '~/task_list';
import { descriptionProps as props } from '../mock_data';
jest.mock('~/task_list');
describe('Description component', () => {
let vm;
let DescriptionComponent;
const props = {
canUpdate: true,
descriptionHtml: 'test',
descriptionText: 'test',
updatedAt: new Date().toString(),
taskStatus: '',
updateUrl: TEST_HOST,
};
beforeEach(() => {
DescriptionComponent = Vue.extend(Description);
......
import { shallowMount } from '@vue/test-utils';
import { GlTab } from '@gitlab/ui';
import IncidentTabs from '~/issue_show/components/incident_tabs.vue';
import { descriptionProps } from '../mock_data';
import DescriptionComponent from '~/issue_show/components/description.vue';
describe('Incident Tabs component', () => {
let wrapper;
const mountComponent = () => {
wrapper = shallowMount(IncidentTabs, {
propsData: {
...descriptionProps,
},
stubs: {
DescriptionComponent: true,
},
});
};
beforeEach(() => {
mountComponent();
});
const findTabs = () => wrapper.findAll(GlTab);
const findSummaryTab = () => findTabs().at(0);
const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
describe('default state', () => {
it('renders the summary tab', async () => {
expect(findTabs()).toHaveLength(1);
expect(findSummaryTab().exists()).toBe(true);
expect(findSummaryTab().attributes('title')).toBe('Summary');
});
it('renders the description component', () => {
expect(findDescriptionComponent().exists()).toBe(true);
});
it('passes all props to the description component', () => {
expect(findDescriptionComponent().props()).toMatchObject(descriptionProps);
});
});
});
import initIssueableApp from '~/issue_show';
import initIssuableApp from '~/issue_show/issue';
import { parseIssuableData } from '~/issue_show/utils/parse_data';
describe('Issue show index', () => {
describe('initIssueableApp', () => {
it('should initialize app with no potential XSS attack', () => {
// Warning: this test is currently faulty.
// More details at https://gitlab.com/gitlab-org/gitlab/-/issues/241717
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should initialize app with no potential XSS attack', () => {
const d = document.createElement('div');
d.id = 'js-issuable-app-initial-data';
d.innerHTML = JSON.stringify({
initialDescriptionHtml: '&lt;img src=x onerror=alert(1)&gt;',
});
document.body.appendChild(d);
const alertSpy = jest.spyOn(window, 'alert');
initIssueableApp();
const issuableData = parseIssuableData();
initIssuableApp(issuableData);
expect(alertSpy).not.toHaveBeenCalled();
});
......
import { TEST_HOST } from 'helpers/test_constants';
export const initialRequest = {
title: '<p>this is a title</p>',
title_text: 'this is a title',
......@@ -21,3 +23,11 @@ export const secondRequest = {
updated_by_path: '/other_user',
lock_version: 2,
};
export const descriptionProps = {
canUpdate: true,
descriptionHtml: 'test',
descriptionText: 'test',
taskStatus: '',
updateUrl: TEST_HOST,
};
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ContainerRegistryHelper do
using RSpec::Parameterized::TableSyntax
describe '#limit_delete_tags_service?' do
subject { helper.limit_delete_tags_service? }
where(:feature_flag_enabled, :client_support, :expected_result) do
true | true | true
true | false | false
false | true | false
false | false | false
end
with_them do
before do
stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled)
allow(ContainerRegistry::Client).to receive(:supports_tag_delete?).and_return(client_support)
end
it { is_expected.to eq(expected_result) }
end
end
end
......@@ -197,7 +197,8 @@ RSpec.describe IssuablesHelper do
initialTitleText: issue.title,
initialDescriptionHtml: '<p dir="auto">issue text</p>',
initialDescriptionText: 'issue text',
initialTaskStatus: '0 of 0 tasks completed'
initialTaskStatus: '0 of 0 tasks completed',
issueType: 'issue'
}
expect(helper.issuable_initial_data(issue)).to match(hash_including(expected_data))
end
......
......@@ -289,4 +289,57 @@ RSpec.describe ContainerRegistry::Client do
end
end
end
describe '.supports_tag_delete?' do
let(:registry_enabled) { true }
let(:registry_api_url) { 'http://sandbox.local' }
let(:registry_tags_support_enabled) { true }
let(:is_on_dot_com) { false }
subject { described_class.supports_tag_delete? }
before do
allow(::Gitlab).to receive(:com?).and_return(is_on_dot_com)
stub_container_registry_config(enabled: registry_enabled, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key')
stub_registry_tags_support(registry_tags_support_enabled)
end
context 'with the registry enabled' do
it { is_expected.to be true }
context 'without an api url' do
let(:registry_api_url) { '' }
it { is_expected.to be false }
end
context 'on .com' do
let(:is_on_dot_com) { true }
it { is_expected.to be true }
end
context 'when registry server does not support tag deletion' do
let(:registry_tags_support_enabled) { false }
it { is_expected.to be false }
end
end
context 'with the registry disabled' do
let(:registry_enabled) { false }
it { is_expected.to be false }
end
def stub_registry_tags_support(supported = true)
status_code = supported ? 200 : 404
stub_request(:options, "#{registry_api_url}/v2/name/tags/reference/tag")
.to_return(
status: status_code,
body: '',
headers: { 'Allow' => 'DELETE' }
)
end
end
end
......@@ -71,6 +71,8 @@ RSpec.describe ApplicationSetting do
it { is_expected.not_to allow_value('three').for(:push_event_activities_limit) }
it { is_expected.not_to allow_value(nil).for(:push_event_activities_limit) }
it { is_expected.to validate_numericality_of(:container_registry_delete_tags_service_timeout).only_integer.is_greater_than_or_equal_to(0) }
it { is_expected.to validate_numericality_of(:snippet_size_limit).only_integer.is_greater_than(0) }
it { is_expected.to validate_numericality_of(:wiki_page_max_content_bytes).only_integer.is_greater_than_or_equal_to(1024) }
it { is_expected.to validate_presence_of(:max_artifacts_size) }
......
......@@ -171,6 +171,16 @@ RSpec.describe Service do
it { is_expected.to be_falsey }
end
end
context 'when group-level service' do
Service.available_services_types.each do |service_type|
let(:service) do
service_type.constantize.new(group_id: group.id)
end
it { is_expected.to be_falsey }
end
end
end
describe '#test' do
......
......@@ -90,6 +90,10 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
subject { service.execute(repository) }
before do
stub_feature_flags(container_registry_expiration_policies_throttling: false)
end
context 'without permissions' do
it { is_expected.to include(status: :error) }
end
......@@ -119,6 +123,18 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
it_behaves_like 'logging a success response'
end
context 'with a timeout error' do
before do
expect_next_instance_of(::Projects::ContainerRepository::Gitlab::DeleteTagsService) do |delete_service|
expect(delete_service).to receive(:delete_tags).and_raise(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError)
end
end
it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
it_behaves_like 'logging an error response', message: 'timeout while deleting tags'
end
end
context 'and the feature is disabled' do
......
......@@ -12,13 +12,21 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
subject { service.execute }
context 'with tags to delete' do
before do
stub_feature_flags(container_registry_expiration_policies_throttling: false)
end
RSpec.shared_examples 'deleting tags' do
it 'deletes the tags by name' do
stub_delete_reference_requests(tags)
expect_delete_tag_by_names(tags)
is_expected.to eq(status: :success, deleted: tags)
end
end
context 'with tags to delete' do
it_behaves_like 'deleting tags'
it 'succeeds when tag delete returns 404' do
stub_delete_reference_requests('A' => 200, 'Ba' => 404)
......@@ -41,6 +49,47 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
it { is_expected.to eq(status: :error, message: 'could not delete tags') }
end
end
context 'with throttling enabled' do
let(:timeout) { 10 }
before do
stub_feature_flags(container_registry_expiration_policies_throttling: true)
stub_application_setting(container_registry_delete_tags_service_timeout: timeout)
end
it_behaves_like 'deleting tags'
context 'with timeout' do
context 'set to a valid value' do
before do
allow(Time.zone).to receive(:now).and_return(10, 15, 25) # third call to Time.zone.now will be triggering the timeout
stub_delete_reference_requests('A' => 200)
end
it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
it 'tracks the exception' do
expect(::Gitlab::ErrorTracking)
.to receive(:track_exception).with(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError, tags_count: tags.size, container_repository_id: repository.id)
subject
end
end
context 'set to 0' do
let(:timeout) { 0 }
it_behaves_like 'deleting tags'
end
context 'set to nil' do
let(:timeout) { nil }
it_behaves_like 'deleting tags'
end
end
end
end
context 'with empty tags' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment