Commit b2375e52 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 4240e055 fa312dfb
...@@ -70,34 +70,50 @@ export default { ...@@ -70,34 +70,50 @@ export default {
</span> </span>
<span class="uploading-error-container hide"> <span class="uploading-error-container hide">
<span class="uploading-error-icon"> <span class="uploading-error-icon">
<template> <gl-icon name="media" />
<gl-icon name="media" />
</template>
</span> </span>
<span class="uploading-error-message"></span> <span class="uploading-error-message"></span>
<gl-sprintf <gl-sprintf
:message=" :message="
__( __(
'%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}', '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}.',
) )
" "
> >
<template #retryButton="{content}"> <template #retryButton="{content}">
<button class="retry-uploading-link" type="button">{{ content }}</button> <gl-button
variant="link"
category="primary"
class="retry-uploading-link gl-vertical-align-baseline"
>
{{ content }}
</gl-button>
</template> </template>
<template #newFileButton="{content}"> <template #newFileButton="{content}">
<button class="attach-new-file markdown-selector" type="button">{{ content }}</button> <gl-button
variant="link"
category="primary"
class="markdown-selector attach-new-file gl-vertical-align-baseline"
>
{{ content }}
</gl-button>
</template> </template>
</gl-sprintf> </gl-sprintf>
</span> </span>
<gl-button class="markdown-selector button-attach-file" variant="link"> <gl-button
<template> icon="media"
<gl-icon name="media" :size="16" /> variant="link"
</template> category="primary"
<span class="text-attach-file">{{ __('Attach a file') }}</span> class="markdown-selector button-attach-file gl-vertical-align-text-bottom"
>
{{ __('Attach a file') }}
</gl-button> </gl-button>
<gl-button class="btn btn-default btn-sm hide button-cancel-uploading-files" variant="link"> <gl-button
variant="link"
category="primary"
class="button-cancel-uploading-files gl-vertical-align-baseline hide"
>
{{ __('Cancel') }} {{ __('Cancel') }}
</gl-button> </gl-button>
</span> </span>
......
...@@ -3,5 +3,3 @@ export const DropdownVariant = { ...@@ -3,5 +3,3 @@ export const DropdownVariant = {
Standalone: 'standalone', Standalone: 'standalone',
Embedded: 'embedded', Embedded: 'embedded',
}; };
export const LIST_BUFFER_SIZE = 5;
<script> <script>
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink } from '@gitlab/ui'; import {
GlIntersectionObserver,
GlLoadingIcon,
GlButton,
GlSearchBoxByType,
GlLink,
} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus'; import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import LabelItem from './label_item.vue'; import LabelItem from './label_item.vue';
import { LIST_BUFFER_SIZE } from './constants';
export default { export default {
LIST_BUFFER_SIZE,
components: { components: {
GlIntersectionObserver,
GlLoadingIcon, GlLoadingIcon,
GlButton, GlButton,
GlSearchBoxByType, GlSearchBoxByType,
GlLink, GlLink,
SmartVirtualList,
LabelItem, LabelItem,
}, },
data() { data() {
...@@ -46,15 +48,8 @@ export default { ...@@ -46,15 +48,8 @@ export default {
} }
return this.labels; return this.labels;
}, },
showListContainer() {
if (this.isDropdownVariantSidebar) {
return !this.labelsFetchInProgress;
}
return true;
},
showNoMatchingResultsMessage() { showNoMatchingResultsMessage() {
return !this.labelsFetchInProgress && !this.visibleLabels.length; return Boolean(this.searchKey) && this.visibleLabels.length === 0;
}, },
}, },
watch: { watch: {
...@@ -67,14 +62,12 @@ export default { ...@@ -67,14 +62,12 @@ export default {
} }
}, },
}, },
mounted() {
this.fetchLabels();
},
methods: { methods: {
...mapActions([ ...mapActions([
'toggleDropdownContents', 'toggleDropdownContents',
'toggleDropdownContentsCreateView', 'toggleDropdownContentsCreateView',
'fetchLabels', 'fetchLabels',
'receiveLabelsSuccess',
'updateSelectedLabels', 'updateSelectedLabels',
'toggleDropdownContents', 'toggleDropdownContents',
]), ]),
...@@ -99,6 +92,17 @@ export default { ...@@ -99,6 +92,17 @@ export default {
} }
} }
}, },
/**
* We want to remove loaded labels to ensure component
* fetches fresh set of labels every time when shown.
*/
handleComponentDisappear() {
this.receiveLabelsSuccess([]);
},
handleCreateLabelClick() {
this.receiveLabelsSuccess([]);
this.toggleDropdownContentsCreateView();
},
/** /**
* This method enables keyboard navigation support for * This method enables keyboard navigation support for
* the dropdown. * the dropdown.
...@@ -135,84 +139,75 @@ export default { ...@@ -135,84 +139,75 @@ export default {
</script> </script>
<template> <template>
<div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown"> <gl-intersection-observer @appear="fetchLabels" @disappear="handleComponentDisappear">
<gl-loading-icon <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
v-if="labelsFetchInProgress" <div
class="labels-fetch-loading position-absolute gl-display-flex gl-align-items-center w-100 h-100" v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
size="md" class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
/> data-testid="dropdown-title"
<div
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
data-testid="dropdown-title"
>
<span class="flex-grow-1">{{ labelsListTitle }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
size="small"
class="dropdown-header-button gl-p-0!"
icon="close"
@click="toggleDropdownContents"
/>
</div>
<div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type
v-model="searchKey"
:autofocus="true"
data-qa-selector="dropdown_input_field"
/>
</div>
<div
v-show="showListContainer"
ref="labelsListContainer"
class="dropdown-content"
data-testid="dropdown-content"
>
<smart-virtual-list
:length="visibleLabels.length"
:remain="$options.LIST_BUFFER_SIZE"
:size="$options.LIST_BUFFER_SIZE"
wclass="list-unstyled mb-0"
wtag="ul"
class="h-100"
> >
<li v-for="(label, index) in visibleLabels" :key="label.id" class="d-block text-left"> <span class="flex-grow-1">{{ labelsListTitle }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
size="small"
class="dropdown-header-button gl-p-0!"
icon="close"
@click="toggleDropdownContents"
/>
</div>
<div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type
v-model="searchKey"
:autofocus="true"
:disabled="labelsFetchInProgress"
data-qa-selector="dropdown_input_field"
/>
</div>
<div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content">
<gl-loading-icon
v-if="labelsFetchInProgress"
class="labels-fetch-loading gl-align-items-center w-100 h-100"
size="md"
/>
<ul v-else class="list-unstyled mb-0">
<label-item <label-item
v-for="(label, index) in visibleLabels"
:key="label.id"
:label="label" :label="label"
:is-label-set="label.set" :is-label-set="label.set"
:highlight="index === currentHighlightItem" :highlight="index === currentHighlightItem"
@clickLabel="handleLabelClick(label)" @clickLabel="handleLabelClick(label)"
/> />
</li> <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center">
<li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center"> {{ __('No matching results') }}
{{ __('No matching results') }} </li>
</li> </ul>
</smart-virtual-list> </div>
</div> <div
<div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-footer"
class="dropdown-footer" data-testid="dropdown-footer"
data-testid="dropdown-footer" >
> <ul class="list-unstyled">
<ul class="list-unstyled"> <li v-if="allowLabelCreate">
<li v-if="allowLabelCreate"> <gl-link
<gl-link class="gl-display-flex w-100 flex-row text-break-word label-item"
class="gl-display-flex w-100 flex-row text-break-word label-item" @click="handleCreateLabelClick"
@click="toggleDropdownContentsCreateView" >
> {{ footerCreateLabelTitle }}
{{ footerCreateLabelTitle }} </gl-link>
</gl-link> </li>
</li> <li>
<li> <gl-link
<gl-link :href="labelsManagePath"
:href="labelsManagePath" class="gl-display-flex flex-row text-break-word label-item"
class="gl-display-flex flex-row text-break-word label-item" >
> {{ footerManageLabelTitle }}
{{ footerManageLabelTitle }} </gl-link>
</gl-link> </li>
</li> </ul>
</ul> </div>
</div> </div>
</div> </gl-intersection-observer>
</template> </template>
<script> <script>
import { GlIcon, GlLink } from '@gitlab/ui'; import { GlLink, GlIcon } from '@gitlab/ui';
export default { export default {
components: { functional: true,
GlIcon,
GlLink,
},
props: { props: {
label: { label: {
type: Object, type: Object,
...@@ -21,46 +18,65 @@ export default { ...@@ -21,46 +18,65 @@ export default {
default: false, default: false,
}, },
}, },
data() { render(h, { props, listeners }) {
return { const { label, highlight, isLabelSet } = props;
isSet: this.isLabelSet,
}; const labelColorBox = h('span', {
}, class: 'dropdown-label-box',
computed: { style: {
labelBoxStyle() { backgroundColor: label.color,
return { },
backgroundColor: this.label.color, attrs: {
}; 'data-testid': 'label-color-box',
}, },
}, });
watch: {
/** const checkedIcon = h(GlIcon, {
* This watcher assures that if user used class: {
* `Enter` key to set/unset label, changes 'mr-2 align-self-center': true,
* are reflected here too. hidden: !isLabelSet,
*/ },
isLabelSet(value) { props: {
this.isSet = value; name: 'mobile-issue-close',
}, },
}, });
methods: {
handleClick() { const noIcon = h('span', {
this.isSet = !this.isSet; class: {
this.$emit('clickLabel', this.label); 'mr-3 pr-2': true,
}, hidden: isLabelSet,
},
attrs: {
'data-testid': 'no-icon',
},
});
const labelTitle = h('span', label.title);
const labelLink = h(
GlLink,
{
class: 'd-flex align-items-baseline text-break-word label-item',
on: {
click: () => {
listeners.clickLabel(label);
},
},
},
[noIcon, checkedIcon, labelColorBox, labelTitle],
);
return h(
'li',
{
class: {
'd-block': true,
'text-left': true,
'is-focused': highlight,
},
},
[labelLink],
);
}, },
}; };
</script> </script>
<template>
<gl-link
class="d-flex align-items-baseline text-break-word label-item"
:class="{ 'is-focused': highlight }"
@click="handleClick"
>
<gl-icon v-show="isSet" name="mobile-issue-close" class="mr-2 align-self-center" />
<span v-show="!isSet" data-testid="no-icon" class="mr-3 pr-2"></span>
<span class="dropdown-label-box" data-testid="label-color-box" :style="labelBoxStyle"></span>
<span>{{ label.title }}</span>
</gl-link>
</template>
...@@ -266,7 +266,7 @@ export default { ...@@ -266,7 +266,7 @@ export default {
</dropdown-value> </dropdown-value>
<dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" /> <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" />
<dropdown-contents <dropdown-contents
v-if="dropdownButtonVisible && showDropdownContents" v-show="dropdownButtonVisible && showDropdownContents"
ref="dropdownContents" ref="dropdownContents"
/> />
</template> </template>
......
...@@ -1017,6 +1017,23 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { ...@@ -1017,6 +1017,23 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
} }
} }
li {
&:hover,
&.is-focused {
.label-item {
@include dropdown-item-hover;
text-decoration: none;
}
}
}
.labels-select-dropdown-button {
.gl-button-text {
width: 100%;
}
}
.labels-select-dropdown-contents { .labels-select-dropdown-contents {
min-height: $dropdown-min-height; min-height: $dropdown-min-height;
max-height: 330px; max-height: 330px;
...@@ -1050,13 +1067,6 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { ...@@ -1050,13 +1067,6 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
.label-item { .label-item {
padding: 8px 20px; padding: 8px 20px;
&:hover,
&.is-focused {
@include dropdown-item-hover;
text-decoration: none;
}
} }
.color-input-container { .color-input-container {
......
...@@ -450,31 +450,6 @@ table { ...@@ -450,31 +450,6 @@ table {
margin-right: 5px; margin-right: 5px;
} }
.attach-new-file,
.button-attach-file,
.retry-uploading-link {
color: $blue-600;
padding: 0;
background: none;
border: 0;
font-size: 14px;
line-height: 16px;
vertical-align: initial;
&:hover,
&:focus {
text-decoration: none;
.text-attach-file {
text-decoration: underline;
}
}
.gl-icon:not(:last-child) {
margin-right: 0;
}
}
.markdown-selector { .markdown-selector {
color: $blue-600; color: $blue-600;
} }
...@@ -2,15 +2,16 @@ ...@@ -2,15 +2,16 @@
module Resolvers module Resolvers
class EchoResolver < BaseResolver class EchoResolver < BaseResolver
type ::GraphQL::STRING_TYPE, null: false
description 'Testing endpoint to validate the API with' description 'Testing endpoint to validate the API with'
argument :text, GraphQL::STRING_TYPE, required: true, argument :text, GraphQL::STRING_TYPE, required: true,
description: 'Text to echo back' description: 'Text to echo back'
def resolve(**args) def resolve(text:)
username = context[:current_user]&.username username = current_user&.username
"#{username.inspect} says: #{args[:text]}" "#{username.inspect} says: #{text}"
end end
end end
end end
%p{ style: 'font-size:18px; text-align:center; line-height:30px;' } %p{ style: 'font-size:18px; text-align:center; line-height:30px;' }
- project_link = link_to(@project.full_name, project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;") - project_link = link_to(@project.full_name, project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;")
= _('Your CSV export of %{count} from project %{project_link} has been added to this email as an attachment.').html_safe % { count: pluralize(@written_count, type.to_s), project_link: project_link } = _('Your CSV export of %{count} from project %{project_link} has been added to this email as an attachment.').html_safe % { count: pluralize(@written_count, type.to_s.titleize.downcase), project_link: project_link }
- if @truncated - if @truncated
%p %p
= _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of 15MB. %{written_count} of %{count} issues have been included. Consider re-exporting with a narrower selection of issues.') % { written_count: @written_count, count: @count } = _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of 15MB. %{written_count} of %{count} issues have been included. Consider re-exporting with a narrower selection of issues.') % { written_count: @written_count, count: @count }
...@@ -23,13 +23,20 @@ ...@@ -23,13 +23,20 @@
= sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom') = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom')
%span.uploading-error-message %span.uploading-error-message
-# Populated by app/assets/javascripts/dropzone_input.js -# Populated by app/assets/javascripts/dropzone_input.js
%button.retry-uploading-link{ type: 'button' }= _("Try again") %button.btn.gl-button.btn-link.gl-vertical-align-baseline.retry-uploading-link
or %span.gl-button-text
%button.attach-new-file.markdown-selector{ type: 'button' }= _("attach a new file") = _("Try again")
= _("or")
%button.btn.gl-button.btn-link.attach-new-file.markdown-selector.gl-vertical-align-baseline
%span.gl-button-text
= _("attach a new file")
= _(".")
%button.btn.markdown-selector.button-attach-file.btn-link{ type: 'button' } %button.btn.gl-button.btn-link.button-attach-file.markdown-selector.button-attach-file.gl-vertical-align-text-bottom
= sprite_icon('media') = sprite_icon('media')
%span.text-attach-file<> %span.gl-button-text
= _("Attach a file") = _("Attach a file")
%button.btn.btn-default.btn-sm.hide.button-cancel-uploading-files{ type: 'button' }= _("Cancel") %button.btn.gl-button.btn-link.button-cancel-uploading-files.gl-vertical-align-baseline.hide
%span.gl-button-text
= _("Cancel")
---
title: Add container repositories API
merge_request: 46495
author:
type: added
---
title: Migrate Bootstrap buttons to GitLab UI buttons for attach a file form actions
merge_request: 46041
author:
type: other
---
title: Fix Vue Labels Select dropdown keyboard scroll
merge_request: 43874
author:
type: fixed
...@@ -124,6 +124,48 @@ Example response: ...@@ -124,6 +124,48 @@ Example response:
] ]
``` ```
## Get details of a single repository
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/209916) in GitLab 13.6.
Get details of a registry repository.
```plaintext
GET /registry/repositories/:id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID of the registry repository accessible by the authenticated user. |
| `tags` | boolean | no | If the parameter is included as `true`, the response includes an array of `"tags"`. |
| `tags_count` | boolean | no | If the parameter is included as `true`, the response includes `"tags_count"`. |
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/registry/repositories/2?tags=true&tags_count=true"
```
Example response:
```json
{
"id": 2,
"name": "",
"path": "group/project",
"project_id": 9,
"location": "gitlab.example.com:5000/group/project",
"created_at": "2019-01-10T13:38:57.391Z",
"cleanup_policy_started_at": "2020-08-17T03:12:35.489Z",
"tags_count": 1,
"tags": [
{
"name": "0.0.1",
"path": "group/project:0.0.1",
"location": "gitlab.example.com:5000/group/project:0.0.1"
}
]
}
```
## Delete registry repository ## Delete registry repository
Delete a repository in registry. Delete a repository in registry.
......
...@@ -149,6 +149,7 @@ export default { ...@@ -149,6 +149,7 @@ export default {
<sidebar-todo <sidebar-todo
v-show="sidebarCollapsed && isUserSignedIn" v-show="sidebarCollapsed && isUserSignedIn"
:sidebar-collapsed="sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed"
data-testid="todo"
/> />
<sidebar-date-picker <sidebar-date-picker
v-show="!sidebarCollapsed" v-show="!sidebarCollapsed"
...@@ -167,6 +168,7 @@ export default { ...@@ -167,6 +168,7 @@ export default {
:date-from-milestones="startDateTimeFromMilestones" :date-from-milestones="startDateTimeFromMilestones"
:selected-date="startDateTime" :selected-date="startDateTime"
:is-date-invalid="isDateInvalid" :is-date-invalid="isDateInvalid"
data-testid="start-date"
block-class="start-date" block-class="start-date"
@toggleCollapse="toggleSidebar({ sidebarCollapsed })" @toggleCollapse="toggleSidebar({ sidebarCollapsed })"
@toggleDateType="changeStartDateType" @toggleDateType="changeStartDateType"
...@@ -188,6 +190,7 @@ export default { ...@@ -188,6 +190,7 @@ export default {
:date-from-milestones="dueDateTimeFromMilestones" :date-from-milestones="dueDateTimeFromMilestones"
:selected-date="dueDateTime" :selected-date="dueDateTime"
:is-date-invalid="isDateInvalid" :is-date-invalid="isDateInvalid"
data-testid="due-date"
block-class="due-date" block-class="due-date"
@toggleDateType="changeDueDateType" @toggleDateType="changeDueDateType"
@saveDate="saveDueDate" @saveDate="saveDueDate"
...@@ -199,9 +202,13 @@ export default { ...@@ -199,9 +202,13 @@ export default {
:max-date="dueDateForCollapsedSidebar" :max-date="dueDateForCollapsedSidebar"
@toggleCollapse="toggleSidebar({ sidebarCollapsed })" @toggleCollapse="toggleSidebar({ sidebarCollapsed })"
/> />
<sidebar-labels :can-update="canUpdate" :sidebar-collapsed="sidebarCollapsed" /> <sidebar-labels
:can-update="canUpdate"
:sidebar-collapsed="sidebarCollapsed"
data-testid="labels-select"
/>
<div v-if="allowSubEpics" class="block ancestors"> <div v-if="allowSubEpics" class="block ancestors">
<ancestors-tree :ancestors="ancestors" :is-fetching="false" /> <ancestors-tree :ancestors="ancestors" :is-fetching="false" data-testid="ancestors" />
</div> </div>
<confidential-issue-sidebar <confidential-issue-sidebar
...@@ -216,7 +223,7 @@ export default { ...@@ -216,7 +223,7 @@ export default {
@toggleSidebar="toggleSidebar({ sidebarCollapsed })" @toggleSidebar="toggleSidebar({ sidebarCollapsed })"
/> />
</div> </div>
<sidebar-subscription :sidebar-collapsed="sidebarCollapsed" /> <sidebar-subscription :sidebar-collapsed="sidebarCollapsed" data-testid="subscribe" />
</div> </div>
</aside> </aside>
</template> </template>
...@@ -90,5 +90,13 @@ module Vulnerabilities ...@@ -90,5 +90,13 @@ module Vulnerabilities
def touch_pipeline def touch_pipeline
pipeline&.touch pipeline&.touch
end end
def finding
Finding.find_by(
project_id: project_id,
report_type: category,
project_fingerprint: project_fingerprint
)
end
end end
end end
...@@ -100,6 +100,10 @@ module Elastic ...@@ -100,6 +100,10 @@ module Elastic
end end
true true
rescue Elasticsearch::Transport::Transport::Error
abort_reindexing!("Couldn't load task status")
false
end end
def compare_documents_count def compare_documents_count
......
...@@ -7,9 +7,13 @@ module Vulnerabilities ...@@ -7,9 +7,13 @@ module Vulnerabilities
def execute def execute
raise Gitlab::Access::AccessDeniedError unless authorized? raise Gitlab::Access::AccessDeniedError unless authorized?
@vulnerability.tap do |vulnerability| @vulnerability.transaction do
update_with_note(vulnerability, state: Vulnerability.states[:confirmed], confirmed_by: @user, confirmed_at: Time.current) DestroyDismissalFeedbackService.new(@user, @vulnerability).execute
update_with_note(@vulnerability, state: Vulnerability.states[:confirmed], confirmed_by: @user, confirmed_at: Time.current)
end end
@vulnerability
end end
end end
end end
# frozen_string_literal: true
require_dependency 'vulnerabilities/base_service'
# This service class removes all the dismissal feedback
# associated with a vulnerability through it's findings.
module Vulnerabilities
class DestroyDismissalFeedbackService < BaseService
def execute
@vulnerability.dismissed_findings.each do |finding|
unless destroy_feedback_for(finding)
handle_finding_revert_error(finding)
raise ActiveRecord::Rollback
end
end
end
private
def destroy_feedback_for(finding)
VulnerabilityFeedback::DestroyService
.new(@project, @user, finding.dismissal_feedback, revert_vulnerability_state: false)
.execute
end
def handle_finding_revert_error(finding)
@vulnerability.errors.add(
:base,
:finding_revert_to_detected_error,
message: _("failed to revert associated finding(id=%{finding_id}) to detected") %
{
finding_id: finding.id
})
end
end
end
...@@ -6,20 +6,23 @@ module Vulnerabilities ...@@ -6,20 +6,23 @@ module Vulnerabilities
class DismissService < BaseService class DismissService < BaseService
FindingsDismissResult = Struct.new(:ok?, :finding, :message) FindingsDismissResult = Struct.new(:ok?, :finding, :message)
def initialize(current_user, vulnerability, comment = nil) def initialize(current_user, vulnerability, comment = nil, dismiss_findings: true)
super(current_user, vulnerability) super(current_user, vulnerability)
@comment = comment @comment = comment
@dismiss_findings = dismiss_findings
end end
def execute def execute
raise Gitlab::Access::AccessDeniedError unless authorized? raise Gitlab::Access::AccessDeniedError unless authorized?
@vulnerability.transaction do @vulnerability.transaction do
result = dismiss_findings if dismiss_findings
result = dismiss_vulnerability_findings
unless result.ok? unless result.ok?
handle_finding_dismissal_error(result.finding, result.message) handle_finding_dismissal_error(result.finding, result.message)
raise ActiveRecord::Rollback raise ActiveRecord::Rollback
end
end end
update_with_note(@vulnerability, state: Vulnerability.states[:dismissed], dismissed_by: @user, dismissed_at: Time.current) update_with_note(@vulnerability, state: Vulnerability.states[:dismissed], dismissed_by: @user, dismissed_at: Time.current)
...@@ -30,6 +33,8 @@ module Vulnerabilities ...@@ -30,6 +33,8 @@ module Vulnerabilities
private private
attr_reader :dismiss_findings
def feedback_service_for(finding) def feedback_service_for(finding)
VulnerabilityFeedback::CreateService.new(@project, @user, feedback_params_for(finding)) VulnerabilityFeedback::CreateService.new(@project, @user, feedback_params_for(finding))
end end
...@@ -40,11 +45,12 @@ module Vulnerabilities ...@@ -40,11 +45,12 @@ module Vulnerabilities
feedback_type: 'dismissal', feedback_type: 'dismissal',
project_fingerprint: finding.project_fingerprint, project_fingerprint: finding.project_fingerprint,
comment: @comment, comment: @comment,
pipeline: @project.latest_pipeline_with_security_reports(only_successful: true) pipeline: @project.latest_pipeline_with_security_reports(only_successful: true),
dismiss_vulnerability: false
} }
end end
def dismiss_findings def dismiss_vulnerability_findings
@vulnerability.findings.each do |finding| @vulnerability.findings.each do |finding|
result = feedback_service_for(finding).execute result = feedback_service_for(finding).execute
......
...@@ -7,9 +7,13 @@ module Vulnerabilities ...@@ -7,9 +7,13 @@ module Vulnerabilities
def execute def execute
raise Gitlab::Access::AccessDeniedError unless authorized? raise Gitlab::Access::AccessDeniedError unless authorized?
@vulnerability.tap do |vulnerability| @vulnerability.transaction do
update_with_note(vulnerability, state: Vulnerability.states[:resolved], resolved_by: @user, resolved_at: Time.current) DestroyDismissalFeedbackService.new(@user, @vulnerability).execute
update_with_note(@vulnerability, state: Vulnerability.states[:resolved], resolved_by: @user, resolved_at: Time.current)
end end
@vulnerability
end end
end end
end end
...@@ -10,46 +10,12 @@ module Vulnerabilities ...@@ -10,46 +10,12 @@ module Vulnerabilities
raise Gitlab::Access::AccessDeniedError unless authorized? raise Gitlab::Access::AccessDeniedError unless authorized?
@vulnerability.transaction do @vulnerability.transaction do
revert_result = revert_findings_to_detected_state DestroyDismissalFeedbackService.new(@user, @vulnerability).execute
raise ActiveRecord::Rollback unless revert_result
update_with_note(@vulnerability, state: Vulnerability.states[:detected], **REVERT_PARAMS) update_with_note(@vulnerability, state: Vulnerability.states[:detected], **REVERT_PARAMS)
end end
@vulnerability @vulnerability
end end
private
def destroy_feedback_for(finding)
VulnerabilityFeedback::DestroyService
.new(@project, @user, finding.dismissal_feedback)
.execute
end
def revert_findings_to_detected_state
@vulnerability
.dismissed_findings
.each do |finding|
result = destroy_feedback_for(finding)
unless result
handle_finding_revert_error(finding)
return false
end
end
true
end
def handle_finding_revert_error(finding)
@vulnerability.errors.add(
:base,
:finding_revert_to_detected_error,
message: _("failed to revert associated finding(id=%{finding_id}) to detected") %
{
finding_id: finding.id
})
end
end end
end end
...@@ -2,21 +2,17 @@ ...@@ -2,21 +2,17 @@
module VulnerabilityFeedback module VulnerabilityFeedback
class CreateService < ::BaseService class CreateService < ::BaseService
def execute include Gitlab::Utils::StrongMemoize
vulnerability_feedback = @project.vulnerability_feedback.find_or_init_for(create_params)
def execute
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :create_vulnerability_feedback, vulnerability_feedback) raise Gitlab::Access::AccessDeniedError unless can?(current_user, :create_vulnerability_feedback, vulnerability_feedback)
if vulnerability_feedback.for_issue? && if vulnerability_feedback.for_issue? && !vulnerability_feedback.vulnerability_data.blank?
!vulnerability_feedback.vulnerability_data.blank? create_issue
elsif vulnerability_feedback.for_merge_request? && !vulnerability_feedback.vulnerability_data.blank?
create_issue_for(vulnerability_feedback) create_merge_request
elsif vulnerability_feedback.for_merge_request? &&
!vulnerability_feedback.vulnerability_data.blank?
create_merge_request_for(vulnerability_feedback)
else else
vulnerability_feedback.save dismiss_existing_vulnerability
end end
if vulnerability_feedback.persisted? && vulnerability_feedback.valid? if vulnerability_feedback.persisted? && vulnerability_feedback.valid?
...@@ -34,9 +30,15 @@ module VulnerabilityFeedback ...@@ -34,9 +30,15 @@ module VulnerabilityFeedback
private private
attr_reader :params
def vulnerability_feedback
@vulnerability_feedback ||= @project.vulnerability_feedback.find_or_init_for(create_params)
end
def create_params def create_params
@params[:author] = @current_user @params[:author] = @current_user
@params.merge(comment_params) @params.merge(comment_params).except(:dismiss_vulnerability)
end end
def comment_params def comment_params
...@@ -52,7 +54,7 @@ module VulnerabilityFeedback ...@@ -52,7 +54,7 @@ module VulnerabilityFeedback
super().merge(vulnerability_feedback: vulnerability_feedback) super().merge(vulnerability_feedback: vulnerability_feedback)
end end
def create_issue_for(vulnerability_feedback) def create_issue
# Wrap Feedback and Issue creation in the same transaction # Wrap Feedback and Issue creation in the same transaction
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
result = Issues::CreateFromVulnerabilityDataService result = Issues::CreateFromVulnerabilityDataService
...@@ -80,7 +82,7 @@ module VulnerabilityFeedback ...@@ -80,7 +82,7 @@ module VulnerabilityFeedback
end end
end end
def create_merge_request_for(vulnerability_feedback) def create_merge_request
result = MergeRequests::CreateFromVulnerabilityDataService result = MergeRequests::CreateFromVulnerabilityDataService
.new(@project, @current_user, vulnerability_feedback.vulnerability_data) .new(@project, @current_user, vulnerability_feedback.vulnerability_data)
.execute .execute
...@@ -115,5 +117,26 @@ module VulnerabilityFeedback ...@@ -115,5 +117,26 @@ module VulnerabilityFeedback
.new(current_user, vulnerability, issue, link_type: Vulnerabilities::IssueLink.link_types[:created]) .new(current_user, vulnerability, issue, link_type: Vulnerabilities::IssueLink.link_types[:created])
.execute .execute
end end
def dismiss_existing_vulnerability
ActiveRecord::Base.transaction do
if dismiss_vulnerability? && existing_vulnerability
Vulnerabilities::DismissService.new(current_user,
existing_vulnerability,
params[:comment],
dismiss_findings: false).execute
end
raise ActiveRecord::Rollback unless vulnerability_feedback.save
end
end
def existing_vulnerability
strong_memoize(:existing_vulnerability) { vulnerability_feedback.finding&.vulnerability }
end
def dismiss_vulnerability?
params[:dismiss_vulnerability] != false && can?(current_user, :admin_vulnerability, project)
end
end end
end end
...@@ -2,15 +2,34 @@ ...@@ -2,15 +2,34 @@
module VulnerabilityFeedback module VulnerabilityFeedback
class DestroyService < ::BaseService class DestroyService < ::BaseService
def initialize(project, user, vulnerability_feedback) include Gitlab::Utils::StrongMemoize
@project, @current_user, @vulnerability_feedback = project, user, vulnerability_feedback
def initialize(project, user, vulnerability_feedback, revert_vulnerability_state: true)
@project, @current_user, @vulnerability_feedback, @revert_vulnerability_state = project, user, vulnerability_feedback, revert_vulnerability_state
end end
def execute def execute
# TODO: Add system note when destroying a dismissal feedback # TODO: Add system note when destroying a dismissal feedback
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_vulnerability_feedback, @vulnerability_feedback) raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_vulnerability_feedback, vulnerability_feedback)
revert_vulnerability if revert_vulnerability_state?
vulnerability_feedback.destroy
end
private
attr_reader :vulnerability_feedback, :revert_vulnerability_state
def revert_vulnerability
Vulnerabilities::RevertToDetectedService.new(current_user, existing_vulnerability).execute
end
def revert_vulnerability_state?
revert_vulnerability_state && existing_vulnerability
end
@vulnerability_feedback.destroy def existing_vulnerability
strong_memoize(:existing_vulnerability) { vulnerability_feedback.finding&.vulnerability }
end end
end end
end end
---
title: Fix Global Search Reindexing when reindexing task is not found
merge_request: 46224
author:
type: fixed
---
title: Synchronize dismissal information between `finding` and `vulnerability` entries
merge_request: 46141
author:
type: fixed
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import EpicSidebar from 'ee/epic/components/epic_sidebar.vue'; import EpicSidebar from 'ee/epic/components/epic_sidebar.vue';
...@@ -7,28 +6,32 @@ import createStore from 'ee/epic/store'; ...@@ -7,28 +6,32 @@ import createStore from 'ee/epic/store';
import epicUtils from 'ee/epic/utils/epic_utils'; import epicUtils from 'ee/epic/utils/epic_utils';
import { dateTypes } from 'ee/epic/constants'; import { dateTypes } from 'ee/epic/constants';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; import { parsePikadayDate } from '~/lib/utils/datetime_utility';
import { mockEpicMeta, mockEpicData, mockAncestors } from '../mock_data'; import { mockEpicMeta, mockEpicData, mockAncestors } from '../mock_data';
const createComponent = ({ methods } = {}) => {
const store = createStore();
store.dispatch('setEpicMeta', mockEpicMeta);
store.dispatch('setEpicData', mockEpicData);
store.state.ancestors = mockAncestors;
return shallowMount(EpicSidebar, {
store,
methods,
});
};
describe('EpicSidebarComponent', () => { describe('EpicSidebarComponent', () => {
const originalUserId = gon.current_user_id; const originalUserId = gon.current_user_id;
let vm; let wrapper;
let store;
beforeEach(() => { beforeEach(() => {
const Component = Vue.extend(EpicSidebar); wrapper = createComponent();
store = createStore();
store.dispatch('setEpicMeta', mockEpicMeta);
store.dispatch('setEpicData', mockEpicData);
store.state.ancestors = mockAncestors;
vm = mountComponentWithStore(Component, {
store,
});
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
}); });
describe('methods', () => { describe('methods', () => {
...@@ -36,7 +39,7 @@ describe('EpicSidebarComponent', () => { ...@@ -36,7 +39,7 @@ describe('EpicSidebarComponent', () => {
it('calls `epicUtils.getDateFromMilestonesTooltip` with `dateType` param', () => { it('calls `epicUtils.getDateFromMilestonesTooltip` with `dateType` param', () => {
jest.spyOn(epicUtils, 'getDateFromMilestonesTooltip'); jest.spyOn(epicUtils, 'getDateFromMilestonesTooltip');
vm.getDateFromMilestonesTooltip(dateTypes.start); wrapper.vm.getDateFromMilestonesTooltip(dateTypes.start);
expect(epicUtils.getDateFromMilestonesTooltip).toHaveBeenCalledWith( expect(epicUtils.getDateFromMilestonesTooltip).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
...@@ -48,11 +51,11 @@ describe('EpicSidebarComponent', () => { ...@@ -48,11 +51,11 @@ describe('EpicSidebarComponent', () => {
describe('changeStartDateType', () => { describe('changeStartDateType', () => {
it('calls `toggleStartDateType` on component with `dateTypeIsFixed` param', () => { it('calls `toggleStartDateType` on component with `dateTypeIsFixed` param', () => {
jest.spyOn(vm, 'toggleStartDateType'); jest.spyOn(wrapper.vm, 'toggleStartDateType');
vm.changeStartDateType(true, true); wrapper.vm.changeStartDateType(true, true);
expect(vm.toggleStartDateType).toHaveBeenCalledWith( expect(wrapper.vm.toggleStartDateType).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
dateTypeIsFixed: true, dateTypeIsFixed: true,
}), }),
...@@ -60,11 +63,11 @@ describe('EpicSidebarComponent', () => { ...@@ -60,11 +63,11 @@ describe('EpicSidebarComponent', () => {
}); });
it('calls `saveDate` on component when `typeChangeOnEdit` param false', () => { it('calls `saveDate` on component when `typeChangeOnEdit` param false', () => {
jest.spyOn(vm, 'saveDate'); jest.spyOn(wrapper.vm, 'saveDate');
vm.changeStartDateType(true, false); wrapper.vm.changeStartDateType(true, false);
expect(vm.saveDate).toHaveBeenCalledWith( expect(wrapper.vm.saveDate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
dateTypeIsFixed: true, dateTypeIsFixed: true,
dateType: dateTypes.start, dateType: dateTypes.start,
...@@ -76,11 +79,11 @@ describe('EpicSidebarComponent', () => { ...@@ -76,11 +79,11 @@ describe('EpicSidebarComponent', () => {
describe('saveStartDate', () => { describe('saveStartDate', () => {
it('calls `saveDate` on component with `date` param set to `newDate`', () => { it('calls `saveDate` on component with `date` param set to `newDate`', () => {
jest.spyOn(vm, 'saveDate'); jest.spyOn(wrapper.vm, 'saveDate');
vm.saveStartDate('2018-1-1'); wrapper.vm.saveStartDate('2018-1-1');
expect(vm.saveDate).toHaveBeenCalledWith( expect(wrapper.vm.saveDate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
dateTypeIsFixed: true, dateTypeIsFixed: true,
dateType: dateTypes.start, dateType: dateTypes.start,
...@@ -92,11 +95,11 @@ describe('EpicSidebarComponent', () => { ...@@ -92,11 +95,11 @@ describe('EpicSidebarComponent', () => {
describe('changeDueDateType', () => { describe('changeDueDateType', () => {
it('calls `toggleDueDateType` on component with `dateTypeIsFixed` param', () => { it('calls `toggleDueDateType` on component with `dateTypeIsFixed` param', () => {
jest.spyOn(vm, 'toggleDueDateType'); jest.spyOn(wrapper.vm, 'toggleDueDateType');
vm.changeDueDateType(true, true); wrapper.vm.changeDueDateType(true, true);
expect(vm.toggleDueDateType).toHaveBeenCalledWith( expect(wrapper.vm.toggleDueDateType).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
dateTypeIsFixed: true, dateTypeIsFixed: true,
}), }),
...@@ -104,11 +107,11 @@ describe('EpicSidebarComponent', () => { ...@@ -104,11 +107,11 @@ describe('EpicSidebarComponent', () => {
}); });
it('calls `saveDate` on component when `typeChangeOnEdit` param false', () => { it('calls `saveDate` on component when `typeChangeOnEdit` param false', () => {
jest.spyOn(vm, 'saveDate'); jest.spyOn(wrapper.vm, 'saveDate');
vm.changeDueDateType(true, false); wrapper.vm.changeDueDateType(true, false);
expect(vm.saveDate).toHaveBeenCalledWith( expect(wrapper.vm.saveDate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
dateTypeIsFixed: true, dateTypeIsFixed: true,
dateType: dateTypes.due, dateType: dateTypes.due,
...@@ -120,11 +123,11 @@ describe('EpicSidebarComponent', () => { ...@@ -120,11 +123,11 @@ describe('EpicSidebarComponent', () => {
describe('saveDueDate', () => { describe('saveDueDate', () => {
it('calls `saveDate` on component with `date` param set to `newDate`', () => { it('calls `saveDate` on component with `date` param set to `newDate`', () => {
jest.spyOn(vm, 'saveDate'); jest.spyOn(wrapper.vm, 'saveDate');
vm.saveDueDate('2018-1-1'); wrapper.vm.saveDueDate('2018-1-1');
expect(vm.saveDate).toHaveBeenCalledWith( expect(wrapper.vm.saveDate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
dateTypeIsFixed: true, dateTypeIsFixed: true,
dateType: dateTypes.due, dateType: dateTypes.due,
...@@ -144,117 +147,86 @@ describe('EpicSidebarComponent', () => { ...@@ -144,117 +147,86 @@ describe('EpicSidebarComponent', () => {
gon.current_user_id = originalUserId; gon.current_user_id = originalUserId;
}); });
it('renders component container element with classes `right-sidebar-expanded`, `right-sidebar` & `epic-sidebar`', done => { it('renders component container element with classes `right-sidebar-expanded`, `right-sidebar` & `epic-sidebar`', async () => {
store.dispatch('toggleSidebarFlag', false); wrapper.vm.$store.dispatch('toggleSidebarFlag', false);
vm.$nextTick() await wrapper.vm.$nextTick();
.then(() => {
expect(vm.$el.classList.contains('right-sidebar-expanded')).toBe(true); expect(wrapper.classes()).toContain('right-sidebar-expanded');
expect(vm.$el.classList.contains('right-sidebar')).toBe(true); expect(wrapper.classes()).toContain('right-sidebar');
expect(vm.$el.classList.contains('epic-sidebar')).toBe(true); expect(wrapper.classes()).toContain('epic-sidebar');
})
.then(done)
.catch(done.fail);
}); });
it('renders header container element with classes `issuable-sidebar` & `js-issuable-update`', () => { it('renders header container element with classes `issuable-sidebar` & `js-issuable-update`', () => {
expect(vm.$el.querySelector('.issuable-sidebar.js-issuable-update')).not.toBeNull(); expect(wrapper.find('.issuable-sidebar.js-issuable-update').exists()).toBe(true);
}); });
it('renders Todo toggle button element when sidebar is collapsed and user is signed in', done => { it('renders Todo toggle button element when sidebar is collapsed and user is signed in', async () => {
store.dispatch('toggleSidebarFlag', true); wrapper.vm.$store.dispatch('toggleSidebarFlag', true);
vm.$nextTick() await wrapper.vm.$nextTick();
.then(() => {
const todoBlockEl = vm.$el.querySelector('.block.todo');
expect(todoBlockEl).not.toBeNull(); expect(wrapper.find('[data-testid="todo"]').exists()).toBe(true);
expect(todoBlockEl.querySelector('button.btn-todo')).not.toBeNull();
})
.then(done)
.catch(done.fail);
}); });
it('renders Start date & Due date elements when sidebar is expanded', done => { it('renders Start date & Due date elements when sidebar is expanded', async () => {
store.dispatch('toggleSidebarFlag', false); wrapper.vm.$store.dispatch('toggleSidebarFlag', false);
vm.$nextTick() await wrapper.vm.$nextTick();
.then(() => {
const startDateEl = vm.$el.querySelector('.block.date.start-date'); const startDateEl = wrapper.find('[data-testid="start-date"]');
const dueDateEl = vm.$el.querySelector('.block.date.due-date'); const dueDateEl = wrapper.find('[data-testid="due-date"]');
expect(startDateEl).not.toBeNull(); expect(startDateEl.exists()).toBe(true);
expect(startDateEl.querySelector('.title').innerText.trim()).toContain('Start date'); expect(startDateEl.props()).toMatchObject({
expect( label: 'Start date',
startDateEl.querySelector('.value .value-type-fixed .value-content').innerText.trim(), dateFixed: parsePikadayDate(mockEpicMeta.startDateFixed),
).toBe('Jun 1, 2018'); });
expect(dueDateEl).not.toBeNull(); expect(dueDateEl.exists()).toBe(true);
expect(dueDateEl.querySelector('.title').innerText.trim()).toContain('Due date'); expect(dueDateEl.props()).toMatchObject({
expect( label: 'Due date',
dueDateEl.querySelector('.value .value-type-fixed .value-content').innerText.trim(), dateFixed: parsePikadayDate(mockEpicMeta.dueDateFixed),
).toBe('Aug 1, 2018'); });
})
.then(done)
.catch(done.fail);
}); });
it('renders labels select element', () => { it('renders labels select element', () => {
expect(vm.$el.querySelector('.js-labels-block')).not.toBeNull(); expect(wrapper.find('[data-testid="labels-select"]').exists()).toBe(true);
}); });
describe('when sub-epics feature is available', () => { describe('when sub-epics feature is available', () => {
it('renders ancestors list', done => { it('renders ancestors list', async () => {
store.dispatch('toggleSidebarFlag', false); wrapper.vm.$store.dispatch('toggleSidebarFlag', false);
store.dispatch('setEpicMeta', { wrapper.vm.$store.dispatch('setEpicMeta', {
...mockEpicMeta, ...mockEpicMeta,
allowSubEpics: false, allowSubEpics: false,
}); });
vm.$nextTick() await wrapper.vm.$nextTick();
.then(() => {
expect(vm.$el.querySelector('.block.ancestors')).toBeNull(); expect(wrapper.find('.block.ancestors').exists()).toBe(false);
})
.then(done)
.catch(done.fail);
}); });
}); });
describe('when sub-epics feature is not available', () => { describe('when sub-epics feature is not available', () => {
it('does not render ancestors list', done => { it('does not render ancestors list', async () => {
store.dispatch('toggleSidebarFlag', false); wrapper.vm.$store.dispatch('toggleSidebarFlag', false);
vm.$nextTick()
.then(() => {
const ancestorsEl = vm.$el.querySelector('.block.ancestors');
const reverseAncestors = [...mockAncestors].reverse(); await wrapper.vm.$nextTick();
const getEls = selector => Array.from(ancestorsEl.querySelectorAll(selector)); const ancestorsEl = wrapper.find('[data-testid="ancestors"]');
expect(ancestorsEl).not.toBeNull(); expect(ancestorsEl.exists()).toBe(true);
expect(ancestorsEl.props('ancestors')).toEqual([...mockAncestors].reverse());
expect(getEls('li.vertical-timeline-row')).toHaveLength(reverseAncestors.length);
expect(getEls('a').map(el => el.innerText.trim())).toEqual(
reverseAncestors.map(a => a.title),
);
expect(getEls('li.vertical-timeline-row a').map(a => a.getAttribute('href'))).toEqual(
reverseAncestors.map(a => a.url),
);
})
.then(done)
.catch(done.fail);
}); });
}); });
it('renders participants list element', () => { it('renders participants list element', () => {
expect(vm.$el.querySelector('.block.participants')).not.toBeNull(); expect(wrapper.find('.block.participants').exists()).toBe(true);
}); });
it('renders subscription toggle element', () => { it('renders subscription toggle element', () => {
expect(vm.$el.querySelector('.block.subscription')).not.toBeNull(); expect(wrapper.find('[data-testid="subscribe"]').exists()).toBe(true);
}); });
}); });
...@@ -264,12 +236,13 @@ describe('EpicSidebarComponent', () => { ...@@ -264,12 +236,13 @@ describe('EpicSidebarComponent', () => {
fetchEpicDetails: jest.fn(), fetchEpicDetails: jest.fn(),
}; };
shallowMount(EpicSidebar, { const wrapperWithMethod = createComponent({
store,
methods: methodSpies, methods: methodSpies,
}); });
expect(methodSpies.fetchEpicDetails).toHaveBeenCalled(); expect(methodSpies.fetchEpicDetails).toHaveBeenCalled();
wrapperWithMethod.destroy();
}); });
}); });
}); });
...@@ -22,6 +22,7 @@ describe('SidebarLabelsComponent', () => { ...@@ -22,6 +22,7 @@ describe('SidebarLabelsComponent', () => {
propsData: { canUpdate: false, sidebarCollapsed: false }, propsData: { canUpdate: false, sidebarCollapsed: false },
store, store,
stubs: { stubs: {
LabelsSelectVue: true,
GlLabel: true, GlLabel: true,
}, },
}); });
......
...@@ -235,4 +235,25 @@ RSpec.describe Vulnerabilities::Feedback do ...@@ -235,4 +235,25 @@ RSpec.describe Vulnerabilities::Feedback do
it { is_expected.to eq({ project_id: project.id, category: category, project_fingerprint: project_fingerprint }) } it { is_expected.to eq({ project_id: project.id, category: category, project_fingerprint: project_fingerprint }) }
end end
describe '#finding' do
let_it_be(:feedback) { create(:vulnerability_feedback) }
subject { feedback.finding }
context 'when the is no finding persisted' do
it { is_expected.to be_nil }
end
context 'when there is a persisted finding' do
let!(:finding) do
create(:vulnerabilities_finding,
project: feedback.project,
report_type: feedback.category,
project_fingerprint: feedback.project_fingerprint)
end
it { is_expected.to eq(finding) }
end
end
end end
...@@ -71,6 +71,13 @@ RSpec.describe Elastic::ClusterReindexingService, :elastic do ...@@ -71,6 +71,13 @@ RSpec.describe Elastic::ClusterReindexingService, :elastic do
expect { subject.execute }.to change { task.reload.state }.from('reindexing').to('failure') expect { subject.execute }.to change { task.reload.state }.from('reindexing').to('failure')
expect(task.reload.error_message).to match(/has failed with/) expect(task.reload.error_message).to match(/has failed with/)
end end
it 'errors if task is not found' do
allow(Gitlab::Elastic::Helper.default).to receive(:task_status).and_raise(Elasticsearch::Transport::Transport::Errors::NotFound)
expect { subject.execute }.to change { task.reload.state }.from('reindexing').to('failure')
expect(task.reload.error_message).to match(/couldn't load task status/i)
end
end end
context 'task finishes correctly' do context 'task finishes correctly' do
......
...@@ -22,6 +22,7 @@ RSpec.describe Vulnerabilities::ConfirmService do ...@@ -22,6 +22,7 @@ RSpec.describe Vulnerabilities::ConfirmService do
end end
it_behaves_like 'calls vulnerability statistics utility services in order' it_behaves_like 'calls vulnerability statistics utility services in order'
it_behaves_like 'removes dismissal feedback from associated findings'
it 'confirms a vulnerability' do it 'confirms a vulnerability' do
Timecop.freeze do Timecop.freeze do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Vulnerabilities::DestroyDismissalFeedbackService do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:vulnerability) { create(:vulnerability, project: project) }
before(:all) do
finding_1 = create(:vulnerabilities_finding, project: project)
finding_2 = create(:vulnerabilities_finding, project: project)
create(:vulnerability_feedback, project: project, category: finding_1.report_type, project_fingerprint: finding_1.project_fingerprint)
create(:vulnerability_feedback, project: project, category: finding_2.report_type, project_fingerprint: finding_2.project_fingerprint)
create(:vulnerability_feedback)
vulnerability.findings << finding_1
vulnerability.findings << finding_2
end
describe '#execute' do
subject(:destroy_feedback) { described_class.new(user, vulnerability).execute }
context 'without necessary permissions' do
it 'raises `Gitlab::Access::AccessDeniedError` error' do
expect { destroy_feedback }.to raise_error(Gitlab::Access::AccessDeniedError)
.and not_change { Vulnerabilities::Feedback.count }
end
end
context 'with necessary permissions' do
before do
project.add_developer(user)
end
it 'destroys the feedback records associated with the findings of the given vulnerability' do
expect { destroy_feedback }.to change { Vulnerabilities::Feedback.count }.from(3).to(1)
end
end
end
end
...@@ -14,7 +14,8 @@ RSpec.describe Vulnerabilities::DismissService do ...@@ -14,7 +14,8 @@ RSpec.describe Vulnerabilities::DismissService do
let!(:pipeline) { create(:ci_pipeline, :success, project: project) } let!(:pipeline) { create(:ci_pipeline, :success, project: project) }
let!(:build) { create(:ee_ci_build, :sast, pipeline: pipeline) } let!(:build) { create(:ee_ci_build, :sast, pipeline: pipeline) }
let(:vulnerability) { create(:vulnerability, :with_findings, project: project) } let(:vulnerability) { create(:vulnerability, :with_findings, project: project) }
let(:service) { described_class.new(user, vulnerability) } let(:dismiss_findings) { true }
let(:service) { described_class.new(user, vulnerability, dismiss_findings: dismiss_findings) }
subject(:dismiss_vulnerability) { service.execute } subject(:dismiss_vulnerability) { service.execute }
...@@ -25,13 +26,29 @@ RSpec.describe Vulnerabilities::DismissService do ...@@ -25,13 +26,29 @@ RSpec.describe Vulnerabilities::DismissService do
it_behaves_like 'calls vulnerability statistics utility services in order' it_behaves_like 'calls vulnerability statistics utility services in order'
it 'dismisses a vulnerability and its associated findings' do context 'when the `dismiss_findings` argument is false' do
Timecop.freeze do let(:dismiss_findings) { false }
dismiss_vulnerability
expect(vulnerability.reload).to( it 'dismisses only vulnerability' do
have_attributes(state: 'dismissed', dismissed_by: user, dismissed_at: be_like_time(Time.current))) Timecop.freeze do
expect(vulnerability.findings).to all have_vulnerability_dismissal_feedback dismiss_vulnerability
expect(vulnerability.reload).to(
have_attributes(state: 'dismissed', dismissed_by: user, dismissed_at: be_like_time(Time.current)))
expect(vulnerability.findings).not_to include have_vulnerability_dismissal_feedback
end
end
end
context 'when the `dismiss_findings` argument is not false' do
it 'dismisses a vulnerability and its associated findings' do
Timecop.freeze do
dismiss_vulnerability
expect(vulnerability.reload).to(
have_attributes(state: 'dismissed', dismissed_by: user, dismissed_at: be_like_time(Time.current)))
expect(vulnerability.findings).to all have_vulnerability_dismissal_feedback
end
end end
end end
...@@ -62,7 +79,7 @@ RSpec.describe Vulnerabilities::DismissService do ...@@ -62,7 +79,7 @@ RSpec.describe Vulnerabilities::DismissService do
context 'when there is a finding dismissal error' do context 'when there is a finding dismissal error' do
before do before do
allow(service).to receive(:dismiss_findings).and_return( allow(service).to receive(:dismiss_vulnerability_findings).and_return(
described_class::FindingsDismissResult.new(false, broken_finding, 'something went wrong')) described_class::FindingsDismissResult.new(false, broken_finding, 'something went wrong'))
end end
......
...@@ -22,6 +22,7 @@ RSpec.describe Vulnerabilities::ResolveService do ...@@ -22,6 +22,7 @@ RSpec.describe Vulnerabilities::ResolveService do
end end
it_behaves_like 'calls vulnerability statistics utility services in order' it_behaves_like 'calls vulnerability statistics utility services in order'
it_behaves_like 'removes dismissal feedback from associated findings'
it 'resolves a vulnerability' do it 'resolves a vulnerability' do
Timecop.freeze do Timecop.freeze do
......
...@@ -23,7 +23,6 @@ RSpec.describe Vulnerabilities::RevertToDetectedService do ...@@ -23,7 +23,6 @@ RSpec.describe Vulnerabilities::RevertToDetectedService do
expect(vulnerability.reload).to( expect(vulnerability.reload).to(
have_attributes(state: 'detected', dismissed_by: nil, dismissed_at: nil, resolved_by: nil, resolved_at: nil, confirmed_by: nil, confirmed_at: nil)) have_attributes(state: 'detected', dismissed_by: nil, dismissed_at: nil, resolved_by: nil, resolved_at: nil, confirmed_by: nil, confirmed_at: nil))
expect(vulnerability.findings).to all not_have_vulnerability_dismissal_feedback
end end
end end
...@@ -34,6 +33,7 @@ RSpec.describe Vulnerabilities::RevertToDetectedService do ...@@ -34,6 +33,7 @@ RSpec.describe Vulnerabilities::RevertToDetectedService do
end end
it_behaves_like 'calls vulnerability statistics utility services in order' it_behaves_like 'calls vulnerability statistics utility services in order'
it_behaves_like 'removes dismissal feedback from associated findings'
end end
context 'with an authorized user with proper permissions' do context 'with an authorized user with proper permissions' do
...@@ -59,23 +59,6 @@ RSpec.describe Vulnerabilities::RevertToDetectedService do ...@@ -59,23 +59,6 @@ RSpec.describe Vulnerabilities::RevertToDetectedService do
include_examples 'reverts vulnerability' include_examples 'reverts vulnerability'
end end
context 'when there is an error' do
let(:broken_finding) { vulnerability.findings.first }
let!(:dismissal_feedback) do
create(:vulnerability_feedback, :dismissal, project: broken_finding.project, project_fingerprint: broken_finding.project_fingerprint)
end
before do
allow(service).to receive(:destroy_feedback_for).and_return(false)
end
it 'responds with error' do
expect(revert_vulnerability_to_detected.errors.messages).to eq(
base: ["failed to revert associated finding(id=#{broken_finding.id}) to detected"])
end
end
context 'when security dashboard feature is disabled' do context 'when security dashboard feature is disabled' do
before do before do
stub_licensed_features(security_dashboard: false) stub_licensed_features(security_dashboard: false)
......
...@@ -7,20 +7,24 @@ RSpec.describe VulnerabilityFeedback::CreateService, '#execute' do ...@@ -7,20 +7,24 @@ RSpec.describe VulnerabilityFeedback::CreateService, '#execute' do
let(:project) { create(:project, :public, :repository, namespace: group) } let(:project) { create(:project, :public, :repository, namespace: group) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:pipeline) { create(:ci_pipeline, project: project) } let(:pipeline) { create(:ci_pipeline, project: project) }
let(:dismiss_vulnerability) { true }
before do before do
group.add_developer(user) group.add_developer(user)
end end
context 'when params are valid' do context 'when params are valid' do
let(:category) { 'sast' }
let(:project_fingerprint) { '418291a26024a1445b23fe64de9380cdcdfd1fa8' }
let(:feedback_params) do let(:feedback_params) do
{ {
feedback_type: 'dismissal', pipeline_id: pipeline.id, category: 'sast', feedback_type: 'dismissal', pipeline_id: pipeline.id, category: category,
project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa8', project_fingerprint: project_fingerprint,
comment: 'a dismissal comment', comment: 'a dismissal comment',
dismiss_vulnerability: dismiss_vulnerability,
vulnerability_data: { vulnerability_data: {
blob_path: '/path/to/blob', blob_path: '/path/to/blob',
category: 'sast', category: category,
priority: 'Low', line: '41', priority: 'Low', line: '41',
file: 'subdir/src/main/java/com/gitlab/security_products/tests/App.java', file: 'subdir/src/main/java/com/gitlab/security_products/tests/App.java',
cve: '818bf5dacb291e15d9e6dc3c5ac32178:PREDICTABLE_RANDOM', cve: '818bf5dacb291e15d9e6dc3c5ac32178:PREDICTABLE_RANDOM',
...@@ -42,6 +46,13 @@ RSpec.describe VulnerabilityFeedback::CreateService, '#execute' do ...@@ -42,6 +46,13 @@ RSpec.describe VulnerabilityFeedback::CreateService, '#execute' do
context 'when feedback_type is dismissal' do context 'when feedback_type is dismissal' do
let(:result) { described_class.new(project, user, feedback_params).execute } let(:result) { described_class.new(project, user, feedback_params).execute }
let!(:finding) do
create(:vulnerabilities_finding,
:detected,
project: project,
report_type: category,
project_fingerprint: project_fingerprint)
end
it 'creates the feedback with the given params' do it 'creates the feedback with the given params' do
expect(result[:status]).to eq(:success) expect(result[:status]).to eq(:success)
...@@ -51,8 +62,8 @@ RSpec.describe VulnerabilityFeedback::CreateService, '#execute' do ...@@ -51,8 +62,8 @@ RSpec.describe VulnerabilityFeedback::CreateService, '#execute' do
expect(feedback.author).to eq(user) expect(feedback.author).to eq(user)
expect(feedback.feedback_type).to eq('dismissal') expect(feedback.feedback_type).to eq('dismissal')
expect(feedback.pipeline_id).to eq(pipeline.id) expect(feedback.pipeline_id).to eq(pipeline.id)
expect(feedback.category).to eq('sast') expect(feedback.category).to eq(category)
expect(feedback.project_fingerprint).to eq('418291a26024a1445b23fe64de9380cdcdfd1fa8') expect(feedback.project_fingerprint).to eq(project_fingerprint)
expect(feedback.for_dismissal?).to eq(true) expect(feedback.for_dismissal?).to eq(true)
expect(feedback.for_issue?).to eq(false) expect(feedback.for_issue?).to eq(false)
expect(feedback.issue).to be_nil expect(feedback.issue).to be_nil
...@@ -87,6 +98,44 @@ RSpec.describe VulnerabilityFeedback::CreateService, '#execute' do ...@@ -87,6 +98,44 @@ RSpec.describe VulnerabilityFeedback::CreateService, '#execute' do
expect(feedback.comment_timestamp).to be_nil expect(feedback.comment_timestamp).to be_nil
end end
end end
context 'when the `dismiss_vulnerability` argument is true' do
context 'when the security_dashboard is not enabled' do
it 'does not dismiss the existing vulnerability' do
expect { result }.not_to change { finding.vulnerability.reload.state }.from('detected')
end
end
context 'when the security_dashboard is enabled' do
before do
stub_licensed_features(security_dashboard: true)
end
it 'dismisses the existing vulnerability' do
expect { result }.to change { finding.vulnerability.reload.state }.from('detected').to('dismissed')
end
end
end
context 'when the `dismiss_vulnerability` argument is false' do
let(:dismiss_vulnerability) { false }
context 'when the security_dashboard is not enabled' do
it 'does not dismiss the existing vulnerability' do
expect { result }.not_to change { finding.vulnerability.reload.state }.from('detected')
end
end
context 'when the security_dashboard is enabled' do
before do
stub_licensed_features(security_dashboard: true)
end
it 'dismisses the existing vulnerability' do
expect { result }.not_to change { finding.vulnerability.reload.state }.from('detected')
end
end
end
end end
context 'when feedback_type is issue' do context 'when feedback_type is issue' do
...@@ -145,7 +194,7 @@ RSpec.describe VulnerabilityFeedback::CreateService, '#execute' do ...@@ -145,7 +194,7 @@ RSpec.describe VulnerabilityFeedback::CreateService, '#execute' do
feedback_params.deep_merge( feedback_params.deep_merge(
feedback_type: 'issue', feedback_type: 'issue',
vulnerability_data: { vulnerability_id: vulnerability_id } vulnerability_data: { vulnerability_id: vulnerability_id }
) ).except(:dismiss_vulnerability)
end end
subject(:result) { described_class.new(project, user, feedback_params_with_vulnerability_id).execute } subject(:result) { described_class.new(project, user, feedback_params_with_vulnerability_id).execute }
......
...@@ -3,32 +3,67 @@ ...@@ -3,32 +3,67 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe VulnerabilityFeedback::DestroyService, '#execute' do RSpec.describe VulnerabilityFeedback::DestroyService, '#execute' do
let(:group) { create(:group) } let(:project) { create(:project, :public, :repository) }
let(:project) { create(:project, :public, :repository, namespace: group) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:vulnerability_feedback) { create(:vulnerability_feedback, feedback_type, project: project)} let(:vulnerability_feedback) { create(:vulnerability_feedback, feedback_type, project: project)}
let(:revert_vulnerability_state) { true }
let(:service_object) { described_class.new(project, user, vulnerability_feedback, revert_vulnerability_state: revert_vulnerability_state) }
before do before do
group.add_developer(user) project.add_developer(user)
stub_licensed_features(security_dashboard: true)
end end
subject { described_class.new(project, user, vulnerability_feedback).execute } subject(:destroy_feedback) { service_object.execute }
context 'when feedback_type is dismissal' do context 'when feedback_type is dismissal' do
let(:feedback_type) { :dismissal } let(:feedback_type) { :dismissal }
it 'destroys the feedback' do context 'when the user is authorized' do
subject context 'when the `revert_vulnerability_state` argument is set as true' do
context 'when the finding is not associated with a vulnerability' do
it 'destroys the feedback' do
expect { destroy_feedback }.to change { vulnerability_feedback.destroyed? }.to(true)
end
end
expect { vulnerability_feedback.reload }.to raise_error ActiveRecord::RecordNotFound context 'when the finding is associated with a vulnerability' do
let(:finding) { create(:vulnerabilities_finding, :dismissed, project: project) }
let(:vulnerability_feedback) { finding.dismissal_feedback }
it 'changes the state of the vulnerability to `detected`' do
expect { destroy_feedback }.to change { finding.vulnerability.reload.state }.from('dismissed').to('detected')
end
end
end
context 'when the `revert_vulnerability_state` argument is set as false' do
let(:revert_vulnerability_state) { false }
context 'when the finding is not associated with a vulnerability' do
it 'destroys the feedback' do
expect { destroy_feedback }.to change { vulnerability_feedback.destroyed? }.to(true)
end
end
context 'when the finding is associated with a vulnerability' do
let(:finding) { create(:vulnerabilities_finding, :dismissed, project: project) }
let(:vulnerability_feedback) { finding.dismissal_feedback }
it 'does not change the state of the vulnerability to `detected`' do
expect { destroy_feedback }.not_to change { finding.vulnerability.reload.state }
end
end
end
end end
context 'when user is not authorized' do context 'when user is not authorized' do
let(:unauthorized_user) { create(:user) } before do
project.add_guest(user)
end
it 'raise error if permission is denied' do it 'raise error if permission is denied' do
expect { described_class.new(project, unauthorized_user, vulnerability_feedback).execute } expect { destroy_feedback }.to raise_error(Gitlab::Access::AccessDeniedError)
.to raise_error(Gitlab::Access::AccessDeniedError)
end end
end end
end end
...@@ -37,8 +72,7 @@ RSpec.describe VulnerabilityFeedback::DestroyService, '#execute' do ...@@ -37,8 +72,7 @@ RSpec.describe VulnerabilityFeedback::DestroyService, '#execute' do
let(:feedback_type) { :issue } let(:feedback_type) { :issue }
it 'raise error as this type of feedback can not be destroyed' do it 'raise error as this type of feedback can not be destroyed' do
expect { described_class.new(project, user, vulnerability_feedback).execute } expect { destroy_feedback }.to raise_error(Gitlab::Access::AccessDeniedError)
.to raise_error(Gitlab::Access::AccessDeniedError)
end end
end end
...@@ -46,8 +80,7 @@ RSpec.describe VulnerabilityFeedback::DestroyService, '#execute' do ...@@ -46,8 +80,7 @@ RSpec.describe VulnerabilityFeedback::DestroyService, '#execute' do
let(:feedback_type) { :merge_request } let(:feedback_type) { :merge_request }
it 'raise error as this type of feedback can not be destroyed' do it 'raise error as this type of feedback can not be destroyed' do
expect { described_class.new(project, user, vulnerability_feedback).execute } expect { destroy_feedback }.to raise_error(Gitlab::Access::AccessDeniedError)
.to raise_error(Gitlab::Access::AccessDeniedError)
end end
end end
end end
# frozen_string_literal: true
RSpec.shared_examples 'removes dismissal feedback from associated findings' do
let(:finding) { create(:vulnerabilities_finding, vulnerability: vulnerability, project: vulnerability.project) }
before do
create(:vulnerability_feedback,
:dismissal,
project: finding.project,
category: finding.report_type,
project_fingerprint: finding.project_fingerprint)
end
context 'when there is no error' do
it 'removes dismissal feedback from associated findings' do
expect { subject }.to change { Vulnerabilities::Feedback.count }.by(-1)
end
end
context 'when there is an error' do
before do
allow_next_instance_of(VulnerabilityFeedback::DestroyService) do |destroy_service_object|
allow(destroy_service_object).to receive(:execute).and_return(false)
end
end
it 'does not remove any feedback' do
expect { subject }.not_to change { Vulnerabilities::Feedback.count }
end
it 'responds with error' do
expect(subject.errors.messages).to eq(
base: ["failed to revert associated finding(id=#{finding.id}) to detected"])
end
end
end
...@@ -161,6 +161,7 @@ module API ...@@ -161,6 +161,7 @@ module API
mount ::API::Commits mount ::API::Commits
mount ::API::CommitStatuses mount ::API::CommitStatuses
mount ::API::ContainerRegistryEvent mount ::API::ContainerRegistryEvent
mount ::API::ContainerRepositories
mount ::API::DeployKeys mount ::API::DeployKeys
mount ::API::DeployTokens mount ::API::DeployTokens
mount ::API::Deployments mount ::API::Deployments
......
# frozen_string_literal: true
module API
class ContainerRepositories < ::API::Base
include Gitlab::Utils::StrongMemoize
helpers ::API::Helpers::PackagesHelpers
before { authenticate! }
namespace 'registry' do
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :repositories, requirements: { id: /[0-9]*/ } do
desc 'Get a container repository' do
detail 'This feature was introduced in GitLab 13.6.'
success Entities::ContainerRegistry::Repository
end
params do
optional :tags, type: Boolean, default: false, desc: 'Determines if tags should be included'
optional :tags_count, type: Boolean, default: false, desc: 'Determines if the tags count should be included'
end
get ':id' do
authorize!(:read_container_image, repository)
present repository, with: Entities::ContainerRegistry::Repository, tags: params[:tags], tags_count: params[:tags_count], user: current_user
end
end
end
helpers do
def repository
strong_memoize(:repository) do
ContainerRepository.find(params[:id])
end
end
end
end
end
...@@ -10,6 +10,8 @@ module API ...@@ -10,6 +10,8 @@ module API
end end
class Repository < Grape::Entity class Repository < Grape::Entity
include ::API::Helpers::RelatedResourcesHelpers
expose :id expose :id
expose :name expose :name
expose :path expose :path
...@@ -19,6 +21,13 @@ module API ...@@ -19,6 +21,13 @@ module API
expose :expiration_policy_started_at, as: :cleanup_policy_started_at expose :expiration_policy_started_at, as: :cleanup_policy_started_at
expose :tags_count, if: -> (_, options) { options[:tags_count] } expose :tags_count, if: -> (_, options) { options[:tags_count] }
expose :tags, using: Tag, if: -> (_, options) { options[:tags] } expose :tags, using: Tag, if: -> (_, options) { options[:tags] }
expose :delete_api_path, if: ->(object, options) { Ability.allowed?(options[:user], :admin_container_image, object) }
private
def delete_api_path
expose_url api_v4_projects_registry_repositories_path(repository_id: object.id, id: object.project_id)
end
end end
class TagDetails < Tag class TagDetails < Tag
......
...@@ -737,7 +737,7 @@ msgstr[1] "" ...@@ -737,7 +737,7 @@ msgstr[1] ""
msgid "%{reportType} %{status} detected no vulnerabilities." msgid "%{reportType} %{status} detected no vulnerabilities."
msgstr "" msgstr ""
msgid "%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}" msgid "%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}."
msgstr "" msgstr ""
msgid "%{seconds}s" msgid "%{seconds}s"
...@@ -1047,6 +1047,9 @@ msgstr "" ...@@ -1047,6 +1047,9 @@ msgstr ""
msgid "- show less" msgid "- show less"
msgstr "" msgstr ""
msgid "."
msgstr ""
msgid "0 bytes" msgid "0 bytes"
msgstr "" msgstr ""
......
...@@ -58,8 +58,8 @@ RSpec.describe 'User uploads file to note' do ...@@ -58,8 +58,8 @@ RSpec.describe 'User uploads file to note' do
error_text = 'File is too big (0.06MiB). Max filesize: 0.01MiB.' error_text = 'File is too big (0.06MiB). Max filesize: 0.01MiB.'
expect(page).to have_selector('.uploading-error-message', visible: true, text: error_text) expect(page).to have_selector('.uploading-error-message', visible: true, text: error_text)
expect(page).to have_selector('.retry-uploading-link', visible: true, text: 'Try again') expect(page).to have_button('Try again', visible: true)
expect(page).to have_selector('.attach-new-file', visible: true, text: 'attach a new file') expect(page).to have_button('attach a new file', visible: true)
expect(page).not_to have_button('Attach a file') expect(page).not_to have_button('Attach a file')
end end
end end
......
import Vuex from 'vuex'; import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; import {
GlIntersectionObserver,
GlButton,
GlLoadingIcon,
GlSearchBoxByType,
GlLink,
} from '@gitlab/ui';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue';
import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue';
...@@ -88,20 +93,25 @@ describe('DropdownContentsLabelsView', () => { ...@@ -88,20 +93,25 @@ describe('DropdownContentsLabelsView', () => {
}); });
}); });
describe('showListContainer', () => { describe('showNoMatchingResultsMessage', () => {
it.each` it.each`
variant | loading | showList searchKey | labels | labelsDescription | returnValue
${'sidebar'} | ${false} | ${true} ${''} | ${[]} | ${'empty'} | ${false}
${'sidebar'} | ${true} | ${false} ${'bug'} | ${[]} | ${'empty'} | ${true}
${'not-sidebar'} | ${true} | ${true} ${''} | ${mockLabels} | ${'not empty'} | ${false}
${'not-sidebar'} | ${false} | ${true} ${'bug'} | ${mockLabels} | ${'not empty'} | ${false}
`( `(
'returns $showList if `state.variant` is "$variant" and `labelsFetchInProgress` is $loading', 'returns $returnValue when searchKey is "$searchKey" and visibleLabels is $labelsDescription',
({ variant, loading, showList }) => { async ({ searchKey, labels, returnValue }) => {
createComponent({ ...mockConfig, variant }); wrapper.setData({
wrapper.vm.$store.state.labelsFetchInProgress = loading; searchKey,
});
expect(wrapper.vm.showListContainer).toBe(showList); wrapper.vm.$store.dispatch('receiveLabelsSuccess', labels);
await wrapper.vm.$nextTick();
expect(wrapper.vm.showNoMatchingResultsMessage).toBe(returnValue);
}, },
); );
}); });
...@@ -118,6 +128,28 @@ describe('DropdownContentsLabelsView', () => { ...@@ -118,6 +128,28 @@ describe('DropdownContentsLabelsView', () => {
}); });
}); });
describe('handleComponentDisappear', () => {
it('calls action `receiveLabelsSuccess` with empty array', () => {
jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
wrapper.vm.handleComponentDisappear();
expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
});
});
describe('handleCreateLabelClick', () => {
it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', () => {
jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
jest.spyOn(wrapper.vm, 'toggleDropdownContentsCreateView');
wrapper.vm.handleCreateLabelClick();
expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
expect(wrapper.vm.toggleDropdownContentsCreateView).toHaveBeenCalled();
});
});
describe('handleKeyDown', () => { describe('handleKeyDown', () => {
it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => { it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => {
wrapper.setData({ wrapper.setData({
...@@ -226,8 +258,8 @@ describe('DropdownContentsLabelsView', () => { ...@@ -226,8 +258,8 @@ describe('DropdownContentsLabelsView', () => {
}); });
describe('template', () => { describe('template', () => {
it('renders component container element with class `labels-select-contents-list`', () => { it('renders gl-intersection-observer as component root', () => {
expect(wrapper.attributes('class')).toContain('labels-select-contents-list'); expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true);
}); });
it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => { it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => {
...@@ -272,15 +304,11 @@ describe('DropdownContentsLabelsView', () => { ...@@ -272,15 +304,11 @@ describe('DropdownContentsLabelsView', () => {
expect(searchInputEl.attributes('autofocus')).toBe('true'); expect(searchInputEl.attributes('autofocus')).toBe('true');
}); });
it('renders smart-virtual-list element', () => {
expect(wrapper.find(SmartVirtualList).exists()).toBe(true);
});
it('renders label elements for all labels', () => { it('renders label elements for all labels', () => {
expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length); expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length);
}); });
it('renders label element with "is-focused" when value of `currentHighlightItem` is more than -1', () => { it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', () => {
wrapper.setData({ wrapper.setData({
currentHighlightItem: 0, currentHighlightItem: 0,
}); });
...@@ -288,7 +316,7 @@ describe('DropdownContentsLabelsView', () => { ...@@ -288,7 +316,7 @@ describe('DropdownContentsLabelsView', () => {
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
const labelItemEl = findDropdownContent().find(LabelItem); const labelItemEl = findDropdownContent().find(LabelItem);
expect(labelItemEl.props('highlight')).toBe(true); expect(labelItemEl.attributes('highlight')).toBe('true');
}); });
}); });
...@@ -310,9 +338,12 @@ describe('DropdownContentsLabelsView', () => { ...@@ -310,9 +338,12 @@ describe('DropdownContentsLabelsView', () => {
return wrapper.vm.$nextTick(() => { return wrapper.vm.$nextTick(() => {
const dropdownContent = findDropdownContent(); const dropdownContent = findDropdownContent();
const loadingIcon = findLoadingIcon();
expect(dropdownContent.exists()).toBe(true); expect(dropdownContent.exists()).toBe(true);
expect(dropdownContent.isVisible()).toBe(false); expect(dropdownContent.isVisible()).toBe(true);
expect(loadingIcon.exists()).toBe(true);
expect(loadingIcon.isVisible()).toBe(true);
}); });
}); });
......
...@@ -6,11 +6,15 @@ import { mockRegularLabel } from './mock_data'; ...@@ -6,11 +6,15 @@ import { mockRegularLabel } from './mock_data';
const mockLabel = { ...mockRegularLabel, set: true }; const mockLabel = { ...mockRegularLabel, set: true };
const createComponent = ({ label = mockLabel, highlight = true } = {}) => const createComponent = ({
label = mockLabel,
isLabelSet = mockLabel.set,
highlight = true,
} = {}) =>
shallowMount(LabelItem, { shallowMount(LabelItem, {
propsData: { propsData: {
label, label,
isLabelSet: label.set, isLabelSet,
highlight, highlight,
}, },
}); });
...@@ -26,94 +30,44 @@ describe('LabelItem', () => { ...@@ -26,94 +30,44 @@ describe('LabelItem', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('computed', () => {
describe('labelBoxStyle', () => {
it('returns an object containing `backgroundColor` based on `label` prop', () => {
expect(wrapper.vm.labelBoxStyle).toEqual(
expect.objectContaining({
backgroundColor: mockLabel.color,
}),
);
});
});
});
describe('watchers', () => {
describe('isLabelSet', () => {
it('sets value of `isLabelSet` to `isSet` data prop', () => {
expect(wrapper.vm.isSet).toBe(true);
wrapper.setProps({
isLabelSet: false,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.isSet).toBe(false);
});
});
});
});
describe('methods', () => {
describe('handleClick', () => {
it('sets value of `isSet` data prop to opposite of its current value', () => {
wrapper.setData({
isSet: true,
});
wrapper.vm.handleClick();
expect(wrapper.vm.isSet).toBe(false);
wrapper.vm.handleClick();
expect(wrapper.vm.isSet).toBe(true);
});
it('emits event `clickLabel` on component with `label` prop as param', () => {
wrapper.vm.handleClick();
expect(wrapper.emitted('clickLabel')).toBeTruthy();
expect(wrapper.emitted('clickLabel')[0]).toEqual([mockLabel]);
});
});
});
describe('template', () => { describe('template', () => {
it('renders gl-link component', () => { it('renders gl-link component', () => {
expect(wrapper.find(GlLink).exists()).toBe(true); expect(wrapper.find(GlLink).exists()).toBe(true);
}); });
it('renders gl-link component with class `is-focused` when `highlight` prop is true', () => { it('renders component root with class `is-focused` when `highlight` prop is true', () => {
wrapper.setProps({ const wrapperTemp = createComponent({
highlight: true, highlight: true,
}); });
return wrapper.vm.$nextTick(() => { expect(wrapperTemp.classes()).toContain('is-focused');
expect(wrapper.find(GlLink).classes()).toContain('is-focused');
}); wrapperTemp.destroy();
}); });
it('renders visible gl-icon component when `isSet` prop is true', () => { it('renders visible gl-icon component when `isLabelSet` prop is true', () => {
wrapper.setData({ const wrapperTemp = createComponent({
isSet: true, isLabelSet: true,
}); });
return wrapper.vm.$nextTick(() => { const iconEl = wrapperTemp.find(GlIcon);
const iconEl = wrapper.find(GlIcon);
expect(iconEl.isVisible()).toBe(true); expect(iconEl.isVisible()).toBe(true);
expect(iconEl.props('name')).toBe('mobile-issue-close'); expect(iconEl.props('name')).toBe('mobile-issue-close');
});
wrapperTemp.destroy();
}); });
it('renders visible span element as placeholder instead of gl-icon when `isSet` prop is false', () => { it('renders visible span element as placeholder instead of gl-icon when `isLabelSet` prop is false', () => {
wrapper.setData({ const wrapperTemp = createComponent({
isSet: false, isLabelSet: false,
}); });
return wrapper.vm.$nextTick(() => { const placeholderEl = wrapperTemp.find('[data-testid="no-icon"]');
const placeholderEl = wrapper.find('[data-testid="no-icon"]');
expect(placeholderEl.isVisible()).toBe(true); expect(placeholderEl.isVisible()).toBe(true);
});
wrapperTemp.destroy();
}); });
it('renders label color element', () => { it('renders label color element', () => {
......
...@@ -8,6 +8,13 @@ RSpec.describe Resolvers::EchoResolver do ...@@ -8,6 +8,13 @@ RSpec.describe Resolvers::EchoResolver do
let(:current_user) { create(:user) } let(:current_user) { create(:user) }
let(:text) { 'Message test' } let(:text) { 'Message test' }
specify do
expect(described_class.field_options).to include(
type: eq(::GraphQL::STRING_TYPE),
null: be_falsey
)
end
describe '#resolve' do describe '#resolve' do
it 'echoes text and username' do it 'echoes text and username' do
expect(resolve_echo(text)).to eq %Q("#{current_user.username}" says: #{text}) expect(resolve_echo(text)).to eq %Q("#{current_user.username}" says: #{text})
......
...@@ -52,7 +52,8 @@ RSpec.describe Emails::MergeRequests do ...@@ -52,7 +52,8 @@ RSpec.describe Emails::MergeRequests do
it { expect(subject.subject).to eq("#{project.name} | Exported merge requests") } it { expect(subject.subject).to eq("#{project.name} | Exported merge requests") }
it { expect(subject.to).to contain_exactly(user.notification_email_for(project.group)) } it { expect(subject.to).to contain_exactly(user.notification_email_for(project.group)) }
it { expect(subject).to have_content('Your CSV export of 10 merge requests from project')} it { expect(subject.html_part).to have_content("Your CSV export of 10 merge requests from project") }
it { expect(subject.text_part).to have_content("Your CSV export of 10 merge requests from project") }
context 'when truncated' do context 'when truncated' do
let(:export_status) do let(:export_status) do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::ContainerRepositories do
let_it_be(:project) { create(:project, :private) }
let_it_be(:reporter) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:repository) { create(:container_repository, project: project) }
let(:users) do
{
anonymous: nil,
guest: guest,
reporter: reporter
}
end
let(:api_user) { reporter }
before do
project.add_reporter(reporter)
project.add_guest(guest)
stub_container_registry_config(enabled: true)
end
describe 'GET /registry/repositories/:id' do
let(:url) { "/registry/repositories/#{repository.id}" }
subject { get api(url, api_user) }
it_behaves_like 'rejected container repository access', :guest, :forbidden
it_behaves_like 'rejected container repository access', :anonymous, :unauthorized
context 'for allowed user' do
it 'returns a repository' do
subject
expect(json_response['id']).to eq(repository.id)
expect(response.body).not_to include('tags')
end
it 'returns a matching schema' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('registry/repository')
end
context 'with tags param' do
let(:url) { "/registry/repositories/#{repository.id}?tags=true" }
before do
stub_container_registry_tags(repository: repository.path, tags: %w(rootA latest), with_manifest: true)
end
it 'returns a repository and its tags' do
subject
expect(json_response['id']).to eq(repository.id)
expect(response.body).to include('tags')
end
end
context 'with tags_count param' do
let(:url) { "/registry/repositories/#{repository.id}?tags_count=true" }
before do
stub_container_registry_tags(repository: repository.path, tags: %w(rootA latest), with_manifest: true)
end
it 'returns a repository and its tags_count' do
subject
expect(response.body).to include('tags_count')
expect(json_response['tags_count']).to eq(2)
end
end
end
context 'with invalid repository id' do
let(:url) { "/registry/repositories/#{non_existing_record_id}" }
it_behaves_like 'returning response status', :not_found
end
end
end
...@@ -4,7 +4,7 @@ require 'spec_helper' ...@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'GraphQL' do RSpec.describe 'GraphQL' do
include GraphqlHelpers include GraphqlHelpers
let(:query) { graphql_query_for('echo', 'text' => 'Hello world' ) } let(:query) { graphql_query_for('echo', text: 'Hello world' ) }
context 'logging' do context 'logging' do
shared_examples 'logging a graphql query' do shared_examples 'logging a graphql query' 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