+ {{ + s__( + 'WorkItem|Applying a template will replace the existing description. Any changes you have made will be lost.', + ) + }} +
+ +
+
Dolorem dolorem omnis impedit cupiditate pariatur officia velit.
', + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMountExtended(ListItemDescription, { + propsData: { ...defaultPropsData, ...propsData }, + }); + }; + + it('renders description', () => { + createComponent(); + + expect(wrapper.findComponent(GlTruncateText).element.firstChild.innerHTML).toBe( + defaultPropsData.descriptionHtml, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/resource_lists/list_item_spec.js b/spec/frontend/vue_shared/components/resource_lists/list_item_spec.js index 559e777360c..257de50d535 100644 --- a/spec/frontend/vue_shared/components/resource_lists/list_item_spec.js +++ b/spec/frontend/vue_shared/components/resource_lists/list_item_spec.js @@ -1,6 +1,7 @@ import { GlAvatarLabeled, GlIcon } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ListItem from '~/vue_shared/components/resource_lists/list_item.vue'; +import ListItemDescription from '~/vue_shared/components/resource_lists/list_item_description.vue'; import ListActions from '~/vue_shared/components/list_actions/list_actions.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; @@ -27,20 +28,21 @@ describe('ListItem', () => { resource, }; - const createComponent = ({ propsData = {}, stubs = {} } = {}) => { + const createComponent = ({ propsData = {}, stubs = {}, scopedSlots = {} } = {}) => { wrapper = shallowMountExtended(ListItem, { propsData: { ...defaultPropsData, ...propsData }, scopedSlots: { 'avatar-meta': '', stats: '', footer: '', + ...scopedSlots, }, stubs, }); }; const findAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled); - const findGroupDescription = () => wrapper.findByTestId('description'); + const findDescription = () => wrapper.findComponent(ListItemDescription); const findListActions = () => wrapper.findComponent(ListActions); const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); @@ -80,35 +82,47 @@ describe('ListItem', () => { expect(wrapper.findByTestId('footer').exists()).toBe(true); }); - describe('when resource has a description', () => { - it('renders description', () => { - const descriptionHtml = 'Foo bar
'; - + describe('when avatar-default slot is provided', () => { + beforeEach(() => { createComponent({ - propsData: { - resource: { - ...resource, - descriptionHtml, - }, - }, + scopedSlots: { 'avatar-default': '' }, }); + }); - expect(findGroupDescription().element.innerHTML).toBe(descriptionHtml); + it('renders slot instead of description', () => { + expect(wrapper.findByTestId('avatar-default').exists()).toBe(true); + expect(findDescription().exists()).toBe(false); }); }); - describe('when resource does not have a description', () => { - it('does not render description', () => { - createComponent({ - propsData: { - resource: { - ...resource, - descriptionHtml: null, - }, - }, + describe('when avatar-default slot is not provided', () => { + describe('when resource has a description', () => { + beforeEach(() => { + createComponent(); }); - expect(findGroupDescription().exists()).toBe(false); + it('renders description', () => { + expect(findDescription().props('descriptionHtml')).toBe( + defaultPropsData.resource.descriptionHtml, + ); + }); + }); + + describe('when resource does not have a description', () => { + beforeEach(() => { + createComponent({ + propsData: { + resource: { + ...resource, + descriptionHtml: null, + }, + }, + }); + }); + + it('does not render description', () => { + expect(findDescription().exists()).toBe(false); + }); }); }); @@ -130,17 +144,37 @@ describe('ListItem', () => { }); }); - describe('when resource has available actions', () => { - it('displays actions dropdown', () => { - createComponent({ - propsData: { + describe('when actions prop is passed', () => { + describe('when resource has available actions', () => { + it('displays actions dropdown', () => { + createComponent({ + propsData: { + actions, + }, + }); + + expect(findListActions().props()).toMatchObject({ actions, - }, + availableActions: resource.availableActions, + }); + }); + }); + + describe('when resource does not have available actions', () => { + beforeEach(() => { + createComponent({ + propsData: { + actions, + resource: { + ...resource, + availableActions: [], + }, + }, + }); }); - expect(findListActions().props()).toMatchObject({ - actions, - availableActions: resource.availableActions, + it('does not display actions dropdown', () => { + expect(findListActions().exists()).toBe(false); }); }); }); @@ -155,12 +189,20 @@ describe('ListItem', () => { }); }); - describe('when resource does not have available actions', () => { + describe('when actions slot is provided', () => { beforeEach(() => { - createComponent(); + createComponent({ + propsData: { + actions, + }, + scopedSlots: { + actions: '', + }, + }); }); - it('does not display actions dropdown', () => { + it('renders slot instead of list actions component', () => { + expect(wrapper.findByTestId('actions').exists()).toBe(true); expect(findListActions().exists()).toBe(false); }); }); diff --git a/spec/frontend/vue_shared/components/resource_lists/list_item_stat_spec.js b/spec/frontend/vue_shared/components/resource_lists/list_item_stat_spec.js index 4d3f0d713cd..6b833e2339c 100644 --- a/spec/frontend/vue_shared/components/resource_lists/list_item_stat_spec.js +++ b/spec/frontend/vue_shared/components/resource_lists/list_item_stat_spec.js @@ -1,4 +1,4 @@ -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlLink } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ListItemStat from '~/vue_shared/components/resource_lists/list_item_stat.vue'; @@ -21,13 +21,26 @@ describe('ListItemStat', () => { }); }; - it('renders stat with icon and tooltip', () => { + it('renders stat in div with icon and tooltip', () => { createComponent(); const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(wrapper.element.tagName).toBe('DIV'); expect(wrapper.text()).toBe(defaultPropsData.stat); expect(tooltip.value).toBe(defaultPropsData.tooltipText); expect(wrapper.findComponent(GlIcon).props('name')).toBe(defaultPropsData.iconName); }); + + describe('when href prop is passed', () => { + const href = 'http://gdk.test:3000/foo/bar/-/forks`'; + + beforeEach(() => { + createComponent({ propsData: { href } }); + }); + + it('renders `GlLink` component', () => { + expect(wrapper.findComponent(GlLink).attributes('href')).toBe(href); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js index 1f8d49da9fc..b52d0219543 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -1,9 +1,9 @@ import { GlAlert, GlForm } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import EditedAt from '~/issues/show/components/edited.vue'; import { updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; @@ -11,8 +11,10 @@ import { ENTER_KEY } from '~/lib/utils/keys'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import WorkItemDescription from '~/work_items/components/work_item_description.vue'; import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue'; +import WorkItemDescriptionTemplatesListbox from '~/work_items/components/work_item_description_template_listbox.vue'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; +import workItemDescriptionTemplateQuery from '~/work_items/graphql/work_item_description_template.query.graphql'; import { autocompleteDataSources, markdownPreviewPath, newWorkItemId } from '~/work_items/utils'; import { updateWorkItemMutationResponse, @@ -34,14 +36,32 @@ describe('WorkItemDescription', () => { const findRenderedDescription = () => wrapper.findComponent(WorkItemDescriptionRendered); const findEditedAt = () => wrapper.findComponent(EditedAt); const findConflictsAlert = () => wrapper.findComponent(GlAlert); - const findConflictedDescription = () => wrapper.find('[data-testid="conflicted-description"]'); + const findConflictedDescription = () => wrapper.findByTestId('conflicted-description'); + const findDescriptionTemplateListbox = () => + wrapper.findComponent(WorkItemDescriptionTemplatesListbox); + const findDescriptionTemplateWarning = () => wrapper.findByTestId('description-template-warning'); + const findDescriptionTemplateWarningButton = (type) => + findDescriptionTemplateWarning().find(`[data-testid="template-${type}"]`); const editDescription = (newText) => findMarkdownEditor().vm.$emit('input', newText); - const findCancelButton = () => wrapper.find('[data-testid="cancel"]'); - const findSubmitButton = () => wrapper.find('[data-testid="save-description"]'); + const findCancelButton = () => wrapper.findByTestId('cancel'); + const findSubmitButton = () => wrapper.findByTestId('save-description'); const clickCancel = () => findForm().vm.$emit('reset', new Event('reset')); + const successfulTemplateHandler = jest.fn().mockResolvedValue({ + data: { + namespace: { + id: 'gid://gitlab/Namespaces::ProjectNamespace/34', + workItemDescriptionTemplates: { + __typename: 'WorkItemDescriptionTemplateConnection', + nodes: [{ name: 'example', content: 'A template' }], + }, + __typename: 'Namespace', + }, + }, + }); + const createComponent = async ({ mutationHandler = mutationSuccessHandler, canUpdate = true, @@ -55,11 +75,13 @@ describe('WorkItemDescription', () => { workItemTypeName = workItemQueryResponse.data.workItem.workItemType.name, editMode = false, showButtonsBelowField, + descriptionTemplateHandler = successfulTemplateHandler, } = {}) => { - wrapper = shallowMount(WorkItemDescription, { + wrapper = shallowMountExtended(WorkItemDescription, { apolloProvider: createMockApollo([ [workItemByIidQuery, workItemResponseHandler], [updateWorkItemMutation, mutationHandler], + [workItemDescriptionTemplateQuery, descriptionTemplateHandler], ]), propsData: { fullPath: 'test-project-path', @@ -72,6 +94,9 @@ describe('WorkItemDescription', () => { }, provide: { isGroup, + glFeatures: { + workItemsAlpha: true, + }, }, stubs: { GlAlert, @@ -269,6 +294,73 @@ describe('WorkItemDescription', () => { expect(wrapper.emitted('updateWorkItem')).toEqual([[{ clearDraft: expect.any(Function) }]]); }); + describe('description templates', () => { + it('displays the description template selection listbox', async () => { + await createComponent({ isEditing: true }); + expect(findDescriptionTemplateListbox().exists()).toBe(true); + }); + + describe('selecting a template successfully', () => { + beforeEach(async () => { + await createComponent({ + isEditing: true, + workItemId: newWorkItemId(workItemQueryResponse.data.workItem.workItemType.name), + }); + findDescriptionTemplateListbox().vm.$emit('selectTemplate', 'example'); + await nextTick(); + await waitForPromises(); + }); + + it('queries for the template content when a template is selected', () => { + expect(successfulTemplateHandler).toHaveBeenCalledWith({ + name: 'example', + fullPath: 'test-project-path', + }); + }); + + it('displays a warning when a description template is selected', () => { + expect(findDescriptionTemplateWarning().exists()).toBe(true); + expect(findDescriptionTemplateWarningButton('cancel').exists()).toBe(true); + expect(findDescriptionTemplateWarningButton('apply').exists()).toBe(true); + }); + + it('hides the warning when the cancel button is clicked', async () => { + expect(findDescriptionTemplateWarning().exists()).toBe(true); + findDescriptionTemplateWarningButton('cancel').vm.$emit('click'); + await nextTick(); + expect(findDescriptionTemplateWarning().exists()).toBe(false); + }); + + it('applies the template when the apply button is clicked', async () => { + findDescriptionTemplateWarningButton('apply').vm.$emit('click'); + await nextTick(); + expect(findMarkdownEditor().props('value')).toBe('A template'); + }); + + it('hides the warning when the template is applied', async () => { + findDescriptionTemplateWarningButton('apply').vm.$emit('click'); + await nextTick(); + expect(findDescriptionTemplateWarning().exists()).toBe(false); + }); + }); + + describe('selecting a template unsuccessfully', () => { + beforeEach(async () => { + await createComponent({ + isEditing: true, + descriptionTemplateHandler: jest.fn().mockRejectedValue(new Error()), + }); + findDescriptionTemplateListbox().vm.$emit('selectTemplate', 'example'); + await nextTick(); + await waitForPromises(); + }); + + it('emits an error event', () => { + expect(wrapper.emitted('error')).toEqual([['Unable to find selected template.']]); + }); + }); + }); + describe('when description has conflicts', () => { beforeEach(async () => { const workItemResponseHandler = jest diff --git a/spec/frontend/work_items/components/work_item_description_template_listbox_spec.js b/spec/frontend/work_items/components/work_item_description_template_listbox_spec.js new file mode 100644 index 00000000000..50f96f04132 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_description_template_listbox_spec.js @@ -0,0 +1,163 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlCollapsibleListbox, GlSkeletonLoader, GlLink } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemDescriptionTemplateListbox from '~/work_items/components/work_item_description_template_listbox.vue'; +import descriptionTemplatesListQuery from '~/work_items/graphql/work_item_description_templates_list.query.graphql'; + +Vue.use(VueApollo); + +const mockTemplatesList = [ + { name: 'template 1', __typename: 'WorkItemDescriptionTemplate' }, + { name: 'template 2', __typename: 'WorkItemDescriptionTemplate' }, + { name: 'template 3', __typename: 'WorkItemDescriptionTemplate' }, + { name: 'template 4', __typename: 'WorkItemDescriptionTemplate' }, +]; + +const mockDescriptionTemplatesResult = { + data: { + namespace: { + __typename: 'Namespace', + id: 'gid://gitlab/Project/1', + workItemDescriptionTemplates: { + __typename: 'WorkItemDescriptionTemplateConnection', + nodes: mockTemplatesList, + }, + }, + }, +}; + +const mockEmptyDescriptionTemplatesResult = { + data: { + namespace: { + __typename: 'Namespace', + id: 'gid://gitlab/Project/1', + workItemDescriptionTemplates: { + __typename: 'WorkItemDescriptionTemplateConnection', + nodes: [], + }, + }, + }, +}; + +describe('WorkItemDescriptionTemplateListbox', () => { + let wrapper; + let handler; + + const createComponent = ({ template, templatesResult = mockDescriptionTemplatesResult } = {}) => { + handler = jest.fn().mockResolvedValue(templatesResult); + wrapper = mountExtended(WorkItemDescriptionTemplateListbox, { + apolloProvider: createMockApollo([[descriptionTemplatesListQuery, handler]]), + propsData: { + fullPath: 'gitlab-org/gitlab', + template, + }, + }); + }; + + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findTemplateMessage = () => wrapper.findByTestId('template-message'); + const findTemplateMessageLink = () => wrapper.findComponent(GlLink); + + it('displays a skeleton loader', () => { + createComponent(); + expect(findSkeletonLoader().exists()).toBe(true); + }); + + describe('when the templates have been fetched', () => { + it('does not display a skeleton loader', async () => { + createComponent(); + await waitForPromises(); + expect(findSkeletonLoader().exists()).toBe(false); + }); + describe('and there are templates to display', () => { + describe('and there is no template already selected', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('renders a collapsible-listbox component', () => { + expect(findListbox().exists()).toBe(true); + }); + + it('displays "Choose a template" by default', () => { + expect(findListbox().text()).toContain('Choose a template'); + }); + + it('displays a header in the listbox that says "Select template"', () => { + expect(findListbox().text()).toContain('Select template'); + }); + }); + + describe('when there is already a template selected', () => { + beforeEach(async () => { + createComponent({ + template: mockTemplatesList[0].name, + }); + await waitForPromises(); + }); + + it('displays the template name in the listbox', () => { + expect(findListbox().text()).toContain(mockTemplatesList[0].name); + }); + }); + + describe('when the listbox is opened', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + findListbox().vm.$emit('shown'); + await nextTick(); + }); + + it('displays a list of templates', () => { + const text = findListbox().text(); + for (const template of mockTemplatesList) { + expect(text).toContain(template.name); + } + }); + + it('allows searching to narrow down results', async () => { + // only matches 'template 4' + findListbox().vm.$emit('search', '4'); + await nextTick(); + expect(findListbox().props('items')).toHaveLength(1); + }); + }); + + describe('when a template is selected from the list', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + findListbox().vm.$emit('shown'); + findListbox().vm.$emit('select', mockTemplatesList[0]); + }); + + it('emits the selected template', () => { + expect(wrapper.emitted('selectTemplate')).toEqual([[mockTemplatesList[0]]]); + }); + }); + }); + + describe('but there are no templates to display', () => { + beforeEach(async () => { + createComponent({ templatesResult: mockEmptyDescriptionTemplatesResult }); + await waitForPromises(); + }); + it('displays a message about adding description templates', () => { + expect(findTemplateMessage().text()).toMatchInterpolatedText( + 'Add description templates to help your contributors communicate effectively!', + ); + }); + it('displays a link to the docs', () => { + expect(findTemplateMessageLink().attributes('href')).toBe( + '/help/user/project/description_templates', + ); + }); + }); + }); +}); diff --git a/spec/graphql/resolvers/todos_resolver_spec.rb b/spec/graphql/resolvers/todos_resolver_spec.rb index e9651c48c9d..78788e2cccc 100644 --- a/spec/graphql/resolvers/todos_resolver_spec.rb +++ b/spec/graphql/resolvers/todos_resolver_spec.rb @@ -120,6 +120,32 @@ RSpec.describe Resolvers::TodosResolver, feature_category: :notifications do expect(todos).to contain_exactly(todo4, todo5) end + + context 'when filtering by is_snoozed' do + let_it_be(:new_user) { create(:user) } + + let_it_be(:todo1) { create(:todo, user: new_user, project: project) } + let_it_be(:todo2) { create(:todo, user: new_user, snoozed_until: 1.month.from_now, project: project) } + let_it_be(:todo3) { create(:todo, user: new_user, snoozed_until: 1.hour.from_now, project: project) } + + it 'only returns snoozed todos' do + todos = resolve_todos(args: { is_snoozed: true, sort: 'CREATED_ASC' }, context: { current_user: new_user }) + + expect(todos.items).to eq([todo2, todo3]) + end + + context 'when todos_snoozing feature flag is disabled' do + before do + stub_feature_flags(todos_snoozing: false) + end + + it 'ignores the is_snoozed filter' do + todos = resolve_todos(args: { is_snoozed: true, sort: 'CREATED_ASC' }, context: { current_user: new_user }) + + expect(todos.items).to eq([todo1, todo2, todo3]) + end + end + end end context 'when sort is provided' do diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 3939f3a9b57..8e1bccf92a1 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -276,6 +276,7 @@ merge_requests: - applicable_post_merge_approval_rules - requested_changes - scan_result_policy_reads_through_violations +- security_policies_through_violations - scan_result_policy_reads_through_approval_rules - running_scan_result_policy_violations - failed_scan_result_policy_violations diff --git a/spec/models/user_detail_spec.rb b/spec/models/user_detail_spec.rb index 16332fbe5a3..489a8183e6d 100644 --- a/spec/models/user_detail_spec.rb +++ b/spec/models/user_detail_spec.rb @@ -16,6 +16,7 @@ RSpec.describe UserDetail, feature_category: :system_access do let(:step_url) { '_some_string_' } let(:email_opt_in) { true } let(:registration_type) { 'free' } + let(:registration_objective) { 0 } let(:glm_source) { 'glm_source' } let(:glm_content) { 'glm_content' } let(:joining_project) { true } @@ -29,7 +30,8 @@ RSpec.describe UserDetail, feature_category: :system_access do glm_source: glm_source, glm_content: glm_content, joining_project: joining_project, - role: role + role: role, + registration_objective: registration_objective } end @@ -99,6 +101,34 @@ RSpec.describe UserDetail, feature_category: :system_access do end end + context 'for registration_objective' do + let(:onboarding_status) do + { + registration_objective: registration_objective + } + end + + it { is_expected.to allow_value(onboarding_status).for(:onboarding_status) } + + context "when 'registration_objective' is invalid" do + let(:registration_objective) { [] } + + it { is_expected.not_to allow_value(onboarding_status).for(:onboarding_status) } + end + + context "when 'registration_objective' is invalid integer" do + let(:registration_objective) { 10 } + + it { is_expected.not_to allow_value(onboarding_status).for(:onboarding_status) } + end + + context "when 'registration_objective' is invalid string" do + let(:registration_objective) { 'long-string-not-listed' } + + it { is_expected.not_to allow_value(onboarding_status).for(:onboarding_status) } + end + end + context 'for glm_content' do let(:onboarding_status) do { diff --git a/spec/services/award_emojis/copy_service_spec.rb b/spec/services/award_emojis/copy_service_spec.rb index c7b9ea7e1c5..98c98b8b861 100644 --- a/spec/services/award_emojis/copy_service_spec.rb +++ b/spec/services/award_emojis/copy_service_spec.rb @@ -3,12 +3,14 @@ require 'spec_helper' RSpec.describe AwardEmojis::CopyService, feature_category: :team_planning do + let_it_be(:project) { create(:project, :in_group) } + let_it_be(:custom_emoji_in_origin_namespace) { create(:custom_emoji, name: 'partyparrot', namespace: project.group) } let_it_be(:from_awardable) do - create( - :issue, + create(:issue, project: project, award_emoji: [ build(:award_emoji, name: AwardEmoji::THUMBS_UP), - build(:award_emoji, name: AwardEmoji::THUMBS_DOWN) + build(:award_emoji, name: AwardEmoji::THUMBS_DOWN), + build(:award_emoji, name: custom_emoji_in_origin_namespace.name) ]) end @@ -23,7 +25,7 @@ RSpec.describe AwardEmojis::CopyService, feature_category: :team_planning do subject(:execute_service) { described_class.new(from_awardable, to_awardable).execute } - it 'copies AwardEmojis', :aggregate_failures do + it 'copies AwardEmojis that exist in the destination namespace', :aggregate_failures do expect { execute_service }.to change { AwardEmoji.count }.by(2) expect(to_awardable.award_emoji.map(&:name)).to match_array([AwardEmoji::THUMBS_UP, AwardEmoji::THUMBS_DOWN]) end diff --git a/spec/services/todos/snoozing_service_spec.rb b/spec/services/todos/snoozing_service_spec.rb index 2c41804d325..26a3e364edd 100644 --- a/spec/services/todos/snoozing_service_spec.rb +++ b/spec/services/todos/snoozing_service_spec.rb @@ -25,11 +25,11 @@ RSpec.describe Todos::SnoozingService, feature_category: :team_planning do context 'when the todo is already snoozed' do let!(:todo) { create(:todo, :pending, snoozed_until: time1, user: user) } - it 'does not change the snoozed_until timestamp' do + it 'changes the snoozed_until timestamp' do service.snooze_todo(todo, time2) todo.reload - expect(todo.snoozed_until).to eq(time1) + expect(todo.snoozed_until).to eq(time2) end end diff --git a/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb b/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb index fb849de6489..e0d5c096847 100644 --- a/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb +++ b/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb @@ -1554,52 +1554,49 @@ RSpec.shared_examples 'a container registry auth service' do ] end - before do - enable_admin_mode!(current_user) if current_user == instance_admin - end - using RSpec::Parameterized::TableSyntax # rubocop:disable Layout/LineLength -- Avoid formatting to keep one-line table layout - where(:user, :requested_scopes, :expected_access, :expected_deny_patterns) do - ref(:project_developer) | lazy { ["repository:#{container_repository_path}:pull"] } | true | {} - ref(:project_developer) | lazy { ["repository:#{container_repository_path}:push"] } | true | { 'push' => %w[v1.* latest admin-only] } - ref(:project_developer) | lazy { ["repository:#{container_repository_path}:delete"] } | false | nil # developers can't obtain delete access - ref(:project_developer) | lazy { ["repository:#{container_repository_path}:pull,push"] } | true | { 'push' => %w[v1.* latest admin-only] } - ref(:project_developer) | lazy { ["repository:#{container_repository_path}:pull,delete"] } | true | {} - ref(:project_developer) | lazy { ["repository:#{container_repository_path}:push,delete"] } | true | { 'push' => %w[v1.* latest admin-only] } - ref(:project_developer) | lazy { ["repository:#{container_repository_path}:pull,push,delete"] } | true | { 'push' => %w[v1.* latest admin-only] } - ref(:project_developer) | lazy { ["repository:#{container_repository_path}:*"] } | false | nil # developers can't obtain full access - ref(:project_developer) | lazy { ["repository:#{container_repository_path}:push,push"] } | true | { 'push' => %w[v1.* latest admin-only] } # single test for edge case where access may be repeated - ref(:project_developer) | lazy { ["repository:#{container_repository_path}:push,foo"] } | true | { 'push' => %w[v1.* latest admin-only] } # test for (today impossible) case where an access is unknown - ref(:project_developer) | lazy { ["repository:#{container_repository_path}:foo"] } | false | {} # test for (today impossible) case where the access is unknown + where(:user, :requested_scopes, :enable_admin_mode, :expected_access, :expected_deny_patterns) do + ref(:project_developer) | lazy { ["repository:#{container_repository_path}:pull"] } | false | true | {} + ref(:project_developer) | lazy { ["repository:#{container_repository_path}:push"] } | false | true | { 'push' => %w[v1.* latest admin-only] } + ref(:project_developer) | lazy { ["repository:#{container_repository_path}:delete"] } | false | false | nil # developers can't obtain delete access + ref(:project_developer) | lazy { ["repository:#{container_repository_path}:pull,push"] } | false | true | { 'push' => %w[v1.* latest admin-only] } + ref(:project_developer) | lazy { ["repository:#{container_repository_path}:pull,delete"] } | false | true | {} + ref(:project_developer) | lazy { ["repository:#{container_repository_path}:push,delete"] } | false | true | { 'push' => %w[v1.* latest admin-only] } + ref(:project_developer) | lazy { ["repository:#{container_repository_path}:pull,push,delete"] } | false | true | { 'push' => %w[v1.* latest admin-only] } + ref(:project_developer) | lazy { ["repository:#{container_repository_path}:*"] } | false | false | nil # developers can't obtain full access + ref(:project_developer) | lazy { ["repository:#{container_repository_path}:push,push"] } | false | true | { 'push' => %w[v1.* latest admin-only] } # single test for edge case where access may be repeated + ref(:project_developer) | lazy { ["repository:#{container_repository_path}:push,foo"] } | false | true | { 'push' => %w[v1.* latest admin-only] } # test for (today impossible) case where an access is unknown + ref(:project_developer) | lazy { ["repository:#{container_repository_path}:foo"] } | false | false | {} # test for (today impossible) case where the access is unknown - ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:pull"] } | true | {} - ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:push"] } | true | { 'push' => %w[latest admin-only] } - ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:delete"] } | true | { 'delete' => %w[admin-only] } - ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:pull,push"] } | true | { 'push' => %w[latest admin-only] } - ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:pull,delete"] } | true | { 'delete' => %w[admin-only] } - ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:push,delete"] } | true | { 'push' => %w[latest admin-only], 'delete' => %w[admin-only] } - ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:pull,push,delete"] } | true | { 'push' => %w[latest admin-only], 'delete' => %w[admin-only] } - ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:*"] } | true | { 'push' => %w[latest admin-only], 'delete' => %w[admin-only] } + ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:pull"] } | false | true | {} + ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:push"] } | false | true | { 'push' => %w[latest admin-only] } + ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:delete"] } | false | true | { 'delete' => %w[admin-only] } + ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:pull,push"] } | false | true | { 'push' => %w[latest admin-only] } + ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:pull,delete"] } | false | true | { 'delete' => %w[admin-only] } + ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:push,delete"] } | false | true | { 'push' => %w[latest admin-only], 'delete' => %w[admin-only] } + ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:pull,push,delete"] } | false | true | { 'push' => %w[latest admin-only], 'delete' => %w[admin-only] } + ref(:project_maintainer) | lazy { ["repository:#{container_repository_path}:*"] } | false | true | { 'push' => %w[latest admin-only], 'delete' => %w[admin-only] } - ref(:project_owner) | lazy { ["repository:#{container_repository_path}:pull"] } | true | {} - ref(:project_owner) | lazy { ["repository:#{container_repository_path}:push"] } | true | { 'push' => %w[admin-only] } - ref(:project_owner) | lazy { ["repository:#{container_repository_path}:delete"] } | true | { 'delete' => [] } - ref(:project_owner) | lazy { ["repository:#{container_repository_path}:pull,push"] } | true | { 'push' => %w[admin-only] } - ref(:project_owner) | lazy { ["repository:#{container_repository_path}:pull,delete"] } | true | { 'delete' => [] } - ref(:project_owner) | lazy { ["repository:#{container_repository_path}:push,delete"] } | true | { 'push' => %w[admin-only], 'delete' => [] } - ref(:project_owner) | lazy { ["repository:#{container_repository_path}:pull,push,delete"] } | true | { 'push' => %w[admin-only], 'delete' => [] } - ref(:project_owner) | lazy { ["repository:#{container_repository_path}:*"] } | true | { 'push' => %w[admin-only], 'delete' => [] } + ref(:project_owner) | lazy { ["repository:#{container_repository_path}:pull"] } | false | true | {} + ref(:project_owner) | lazy { ["repository:#{container_repository_path}:push"] } | false | true | { 'push' => %w[admin-only] } + ref(:project_owner) | lazy { ["repository:#{container_repository_path}:delete"] } | false | true | { 'delete' => [] } + ref(:project_owner) | lazy { ["repository:#{container_repository_path}:pull,push"] } | false | true | { 'push' => %w[admin-only] } + ref(:project_owner) | lazy { ["repository:#{container_repository_path}:pull,delete"] } | false | true | { 'delete' => [] } + ref(:project_owner) | lazy { ["repository:#{container_repository_path}:push,delete"] } | false | true | { 'push' => %w[admin-only], 'delete' => [] } + ref(:project_owner) | lazy { ["repository:#{container_repository_path}:pull,push,delete"] } | false | true | { 'push' => %w[admin-only], 'delete' => [] } + ref(:project_owner) | lazy { ["repository:#{container_repository_path}:*"] } | false | true | { 'push' => %w[admin-only], 'delete' => [] } - ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:pull"] } | true | {} - ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:push"] } | true | { 'push' => [] } - ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:delete"] } | true | { 'delete' => [] } - ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:pull,push"] } | true | { 'push' => [] } - ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:pull,delete"] } | true | { 'delete' => [] } - ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:push,delete"] } | true | { 'push' => [], 'delete' => [] } - ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:pull,push,delete"] } | true | { 'push' => [], 'delete' => [] } - ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:*"] } | true | { 'push' => [], 'delete' => [] } + ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:pull"] } | true | true | {} + ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:push"] } | true | true | { 'push' => [] } + ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:delete"] } | true | true | { 'delete' => [] } + ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:pull,push"] } | true | true | { 'push' => [] } + ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:pull,delete"] } | true | true | { 'delete' => [] } + ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:push,delete"] } | true | true | { 'push' => [], 'delete' => [] } + ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:pull,push,delete"] } | true | true | { 'push' => [], 'delete' => [] } + ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:*"] } | true | true | { 'push' => [], 'delete' => [] } + ref(:instance_admin) | lazy { ["repository:#{container_repository_path}:*"] } | false | false | {} # ensure that admin mode is properly enforced end # rubocop:enable Layout/LineLength @@ -1607,6 +1604,10 @@ RSpec.shared_examples 'a container registry auth service' do let(:current_user) { user } let(:current_params) { { scopes: requested_scopes } } + before do + enable_admin_mode!(current_user) if enable_admin_mode + end + it 'returns the expected tag deny access patterns' do is_expected.to include(:token)