Commit 5dfe5b5d authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch '267394-improve-search-empty-message' into 'master'

Search: Improve empty state message

See merge request gitlab-org/gitlab!46237
parents df1507b9 c7cdd60c
1c4fdefdaf88730c025b5c7ba7ddc42c268043d4
940a45ca938b20031820a4976f936a5b6173de92
......@@ -51,6 +51,7 @@ export const FIELDS = [
key: 'actions',
thClass: 'col-actions',
tdClass: 'col-actions',
showFunction: 'showActionsField',
},
];
......
......@@ -2,6 +2,12 @@
import { mapState } from 'vuex';
import { GlTable, GlBadge } from '@gitlab/ui';
import MembersTableCell from 'ee_else_ce/vue_shared/components/members/table/members_table_cell.vue';
import {
canOverride,
canRemove,
canResend,
canUpdate,
} from 'ee_else_ce/vue_shared/components/members/utils';
import { FIELDS } from '../constants';
import initUserPopovers from '~/user_popovers';
import MemberAvatar from './member_avatar.vue';
......@@ -33,14 +39,40 @@ export default {
),
},
computed: {
...mapState(['members', 'tableFields']),
...mapState(['members', 'tableFields', 'currentUserId', 'sourceId']),
filteredFields() {
return FIELDS.filter(field => this.tableFields.includes(field.key));
return FIELDS.filter(field => this.tableFields.includes(field.key) && this.showField(field));
},
userIsLoggedIn() {
return this.currentUserId !== null;
},
},
mounted() {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
},
methods: {
showField(field) {
if (!Object.prototype.hasOwnProperty.call(field, 'showFunction')) {
return true;
}
return this[field.showFunction]();
},
showActionsField() {
if (!this.userIsLoggedIn) {
return false;
}
return this.members.some(member => {
return (
canRemove(member, this.sourceId) ||
canResend(member) ||
canUpdate(member, this.currentUserId, this.sourceId) ||
canOverride(member)
);
});
},
},
};
</script>
......
<script>
import { mapState } from 'vuex';
import { MEMBER_TYPES } from '../constants';
import { isGroup, isDirectMember, isCurrentUser, canRemove, canResend, canUpdate } from '../utils';
export default {
name: 'MembersTableCell',
......@@ -13,7 +14,7 @@ export default {
computed: {
...mapState(['sourceId', 'currentUserId']),
isGroup() {
return Boolean(this.member.sharedWithGroup);
return isGroup(this.member);
},
isInvite() {
return Boolean(this.member.invite);
......@@ -33,19 +34,19 @@ export default {
return MEMBER_TYPES.user;
},
isDirectMember() {
return this.isGroup || this.member.source?.id === this.sourceId;
return isDirectMember(this.member, this.sourceId);
},
isCurrentUser() {
return this.member.user?.id === this.currentUserId;
return isCurrentUser(this.member, this.currentUserId);
},
canRemove() {
return this.isDirectMember && this.member.canRemove;
return canRemove(this.member, this.sourceId);
},
canResend() {
return Boolean(this.member.invite?.canResend);
return canResend(this.member);
},
canUpdate() {
return !this.isCurrentUser && this.isDirectMember && this.member.canUpdate;
return canUpdate(this.member, this.currentUserId, this.sourceId);
},
},
render() {
......
......@@ -17,3 +17,32 @@ export const generateBadges = (member, isCurrentUser) => [
variant: 'info',
},
];
export const isGroup = member => {
return Boolean(member.sharedWithGroup);
};
export const isDirectMember = (member, sourceId) => {
return isGroup(member) || member.source?.id === sourceId;
};
export const isCurrentUser = (member, currentUserId) => {
return member.user?.id === currentUserId;
};
export const canRemove = (member, sourceId) => {
return isDirectMember(member, sourceId) && member.canRemove;
};
export const canResend = member => {
return Boolean(member.invite?.canResend);
};
export const canUpdate = (member, currentUserId, sourceId) => {
return (
!isCurrentUser(member, currentUserId) && isDirectMember(member, sourceId) && member.canUpdate
);
};
// Defined in `ee/app/assets/javascripts/vue_shared/components/members/utils.js`
export const canOverride = () => false;
# frozen_string_literal: true
module Mutations
module AlertManagement
module HttpIntegration
class Destroy < HttpIntegrationBase
graphql_name 'HttpIntegrationDestroy'
argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration],
required: true,
description: "The id of the integration to remove"
def resolve(id:)
integration = authorized_find!(id: id)
response ::AlertManagement::HttpIntegrations::DestroyService.new(
integration,
current_user
).execute
end
end
end
end
end
......@@ -7,7 +7,7 @@ module Mutations
field :integration,
Types::AlertManagement::HttpIntegrationType,
null: true,
description: "The updated HTTP integration"
description: "The HTTP integration"
authorize :admin_operations
......
......@@ -18,10 +18,14 @@ module Resolvers
required: false,
default_value: 'created_desc'
def resolve(ids: nil, usernames: nil, sort: nil)
argument :search, GraphQL::STRING_TYPE,
required: false,
description: "Query to search users by name, username, or primary email."
def resolve(ids: nil, usernames: nil, sort: nil, search: nil)
authorize!
::UsersFinder.new(context[:current_user], finder_params(ids, usernames, sort)).execute
::UsersFinder.new(context[:current_user], finder_params(ids, usernames, sort, search)).execute
end
def ready?(**args)
......@@ -42,11 +46,12 @@ module Resolvers
private
def finder_params(ids, usernames, sort)
def finder_params(ids, usernames, sort, search)
params = {}
params[:sort] = sort if sort
params[:username] = usernames if usernames
params[:id] = parse_gids(ids) if ids
params[:search] = search if search
params
end
......
......@@ -14,6 +14,7 @@ module Types
mount_mutation Mutations::AlertManagement::HttpIntegration::Create
mount_mutation Mutations::AlertManagement::HttpIntegration::Update
mount_mutation Mutations::AlertManagement::HttpIntegration::ResetToken
mount_mutation Mutations::AlertManagement::HttpIntegration::Destroy
mount_mutation Mutations::AlertManagement::PrometheusIntegration::Create
mount_mutation Mutations::AlertManagement::PrometheusIntegration::Update
mount_mutation Mutations::AlertManagement::PrometheusIntegration::ResetToken
......
# frozen_string_literal: true
module AlertManagement
module HttpIntegrations
class DestroyService
# @param integration [AlertManagement::HttpIntegration]
# @param current_user [User]
def initialize(integration, current_user)
@integration = integration
@current_user = current_user
end
def execute
return error_no_permissions unless allowed?
return error_multiple_integrations unless Feature.enabled?(:multiple_http_integrations, integration.project)
if integration.destroy
success
else
error(integration.errors.full_messages.to_sentence)
end
end
private
attr_reader :integration, :current_user
def allowed?
current_user&.can?(:admin_operations, integration)
end
def error(message)
ServiceResponse.error(message: message)
end
def success
ServiceResponse.success(payload: { integration: integration })
end
def error_no_permissions
error(_('You have insufficient permissions to remove this HTTP integration'))
end
def error_multiple_integrations
error(_('Removing integrations is not supported for this project'))
end
end
end
end
---
title: Add search param to Users GraphQL type
merge_request: 46609
author:
type: added
---
title: Add `has_vulnerabilities` column into project_settings table
merge_request: 45944
author:
type: added
# frozen_string_literal: true
class AddHasVulnerabilitiesIntoProjectSettings < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
with_lock_retries do
add_column :project_settings, :has_vulnerabilities, :boolean, default: false, null: false
end
end
def down
with_lock_retries do
remove_column :project_settings, :has_vulnerabilities
end
end
end
# frozen_string_literal: true
class IndexProjectSettingsOnProjectIdPartially < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'index_project_settings_on_project_id_partially'
disable_ddl_transaction!
def up
add_concurrent_index :project_settings, :project_id, name: INDEX_NAME, where: 'has_vulnerabilities IS TRUE'
end
def down
remove_concurrent_index_by_name :project_settings, INDEX_NAME
end
end
205cb628e9637bcd1acb90c5211b71b51015fa5f50aadcacd5fbafc4f09c00d0
\ No newline at end of file
9f942de6f83629a144e9d460b4bed7a246afe95275b5913745109fc0ab9dacc1
\ No newline at end of file
......@@ -15191,6 +15191,7 @@ CREATE TABLE project_settings (
allow_merge_on_skipped_pipeline boolean,
squash_option smallint DEFAULT 3,
has_confluence boolean DEFAULT false NOT NULL,
has_vulnerabilities boolean DEFAULT false NOT NULL,
CONSTRAINT check_bde223416c CHECK ((show_default_award_emojis IS NOT NULL))
);
......@@ -21461,6 +21462,8 @@ CREATE UNIQUE INDEX index_project_repository_states_on_project_id ON project_rep
CREATE INDEX index_project_repository_storage_moves_on_project_id ON project_repository_storage_moves USING btree (project_id);
CREATE INDEX index_project_settings_on_project_id_partially ON project_settings USING btree (project_id) WHERE (has_vulnerabilities IS TRUE);
CREATE UNIQUE INDEX index_project_settings_on_push_rule_id ON project_settings USING btree (push_rule_id);
CREATE INDEX index_project_statistics_on_namespace_id ON project_statistics USING btree (namespace_id);
......
......@@ -9271,7 +9271,42 @@ type HttpIntegrationCreatePayload {
errors: [String!]!
"""
The updated HTTP integration
The HTTP integration
"""
integration: AlertManagementHttpIntegration
}
"""
Autogenerated input type of HttpIntegrationDestroy
"""
input HttpIntegrationDestroyInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
The id of the integration to remove
"""
id: AlertManagementHttpIntegrationID!
}
"""
Autogenerated return type of HttpIntegrationDestroy
"""
type HttpIntegrationDestroyPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
"""
The HTTP integration
"""
integration: AlertManagementHttpIntegration
}
......@@ -9306,7 +9341,7 @@ type HttpIntegrationResetTokenPayload {
errors: [String!]!
"""
The updated HTTP integration
The HTTP integration
"""
integration: AlertManagementHttpIntegration
}
......@@ -9351,7 +9386,7 @@ type HttpIntegrationUpdatePayload {
errors: [String!]!
"""
The updated HTTP integration
The HTTP integration
"""
integration: AlertManagementHttpIntegration
}
......@@ -12950,6 +12985,7 @@ type Mutation {
epicSetSubscription(input: EpicSetSubscriptionInput!): EpicSetSubscriptionPayload
epicTreeReorder(input: EpicTreeReorderInput!): EpicTreeReorderPayload
httpIntegrationCreate(input: HttpIntegrationCreateInput!): HttpIntegrationCreatePayload
httpIntegrationDestroy(input: HttpIntegrationDestroyInput!): HttpIntegrationDestroyPayload
httpIntegrationResetToken(input: HttpIntegrationResetTokenInput!): HttpIntegrationResetTokenPayload
httpIntegrationUpdate(input: HttpIntegrationUpdateInput!): HttpIntegrationUpdatePayload
issueMove(input: IssueMoveInput!): IssueMovePayload
......@@ -16763,6 +16799,11 @@ type Query {
"""
last: Int
"""
Query to search users by name, username, or primary email.
"""
search: String
"""
Sort users by this criteria
"""
......
......@@ -25260,7 +25260,109 @@
},
{
"name": "integration",
"description": "The updated HTTP integration",
"description": "The HTTP integration",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "AlertManagementHttpIntegration",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "HttpIntegrationDestroyInput",
"description": "Autogenerated input type of HttpIntegrationDestroy",
"fields": null,
"inputFields": [
{
"name": "id",
"description": "The id of the integration to remove",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "AlertManagementHttpIntegrationID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "HttpIntegrationDestroyPayload",
"description": "Autogenerated return type of HttpIntegrationDestroy",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "integration",
"description": "The HTTP integration",
"args": [
],
......@@ -25362,7 +25464,7 @@
},
{
"name": "integration",
"description": "The updated HTTP integration",
"description": "The HTTP integration",
"args": [
],
......@@ -25484,7 +25586,7 @@
},
{
"name": "integration",
"description": "The updated HTTP integration",
"description": "The HTTP integration",
"args": [
],
......@@ -36844,6 +36946,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "httpIntegrationDestroy",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "HttpIntegrationDestroyInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "HttpIntegrationDestroyPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "httpIntegrationResetToken",
"description": null,
......@@ -48497,6 +48626,16 @@
},
"defaultValue": "created_desc"
},
{
"name": "search",
"description": "Query to search users by name, username, or primary email.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
......@@ -1335,7 +1335,17 @@ Autogenerated return type of HttpIntegrationCreate.
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `integration` | AlertManagementHttpIntegration | The updated HTTP integration |
| `integration` | AlertManagementHttpIntegration | The HTTP integration |
### HttpIntegrationDestroyPayload
Autogenerated return type of HttpIntegrationDestroy.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `integration` | AlertManagementHttpIntegration | The HTTP integration |
### HttpIntegrationResetTokenPayload
......@@ -1345,7 +1355,7 @@ Autogenerated return type of HttpIntegrationResetToken.
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `integration` | AlertManagementHttpIntegration | The updated HTTP integration |
| `integration` | AlertManagementHttpIntegration | The HTTP integration |
### HttpIntegrationUpdatePayload
......@@ -1355,7 +1365,7 @@ Autogenerated return type of HttpIntegrationUpdate.
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `integration` | AlertManagementHttpIntegration | The updated HTTP integration |
| `integration` | AlertManagementHttpIntegration | The HTTP integration |
### InstanceSecurityDashboard
......
---
stage: none
group: unassigned
stage: Configure
group: Configure
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
......
......@@ -49,7 +49,7 @@ Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" \
--upload-file path/to/file.txt \
https://gitlab.example.com/api/v4/projects/24/generic/my_package/0.0.1/file.txt
https://gitlab.example.com/api/v4/projects/24/packages/generic/my_package/0.0.1/file.txt
```
Example response:
......@@ -85,7 +85,7 @@ Example request that uses a personal access token:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" \
https://gitlab.example.com/api/v4/projects/24/generic/my_package/0.0.1/file.txt
https://gitlab.example.com/api/v4/projects/24/packages/generic/my_package/0.0.1/file.txt
```
## Publish a generic package by using CI/CD
......
<script>
import CEMembersTableCell from '~/vue_shared/components/members/table/members_table_cell.vue';
import { canOverride } from '../utils';
export default {
name: 'MembersTableCell',
......@@ -11,7 +12,7 @@ export default {
},
computed: {
canOverride() {
return this.member.canOverride;
return canOverride(this.member);
},
},
render(createElement) {
......
import { __ } from '~/locale';
import { generateBadges as CEGenerateBadges } from '~/vue_shared/components/members/utils';
export {
isGroup,
isDirectMember,
isCurrentUser,
canRemove,
canResend,
canUpdate,
} from '~/vue_shared/components/members/utils';
export const generateBadges = (member, isCurrentUser) => [
...CEGenerateBadges(member, isCurrentUser),
{
......@@ -24,3 +33,5 @@ export const generateBadges = (member, isCurrentUser) => [
variant: 'info',
},
];
export const canOverride = member => member.canOverride;
......@@ -6,20 +6,35 @@ module Security
class StoreReportsService < ::BaseService
def initialize(pipeline)
@pipeline = pipeline
@errors = []
end
def execute
errors = []
@pipeline.security_reports.reports.each do |report_type, report|
result = StoreReportService.new(@pipeline, report).execute
store_reports
mark_project_as_vulnerable!
errors.any? ? error(full_errors) : success
end
private
attr_reader :pipeline, :errors
delegate :project, to: :pipeline, private: true
def store_reports
pipeline.security_reports.reports.each do |report_type, report|
result = StoreReportService.new(pipeline, report).execute
errors << result[:message] if result[:status] == :error
end
end
if errors.any?
error(errors.join(", "))
else
success
end
def mark_project_as_vulnerable!
project.project_setting.update!(has_vulnerabilities: true)
end
def full_errors
errors.join(", ")
end
end
end
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { within } from '@testing-library/dom';
import { member as memberMock, members } from 'jest/vue_shared/components/members/mock_data';
import MembersTable from '~/vue_shared/components/members/table/members_table.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('MemberList', () => {
let wrapper;
const createStore = (state = {}) => {
return new Vuex.Store({
state: {
members: [],
tableFields: [],
sourceId: 1,
currentUserId: 1,
...state,
},
});
};
const createComponent = state => {
wrapper = mount(MembersTable, {
localVue,
store: createStore(state),
stubs: [
'member-avatar',
'member-source',
'expires-at',
'created-at',
'member-action-buttons',
'role-dropdown',
'remove-group-link-modal',
'expiration-datepicker',
],
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('fields', () => {
describe('"Actions" field', () => {
const memberCanOverride = {
...memberMock,
source: { ...memberMock.source, id: 1 },
canOverride: true,
};
describe('when one of the members has `canOverride` permissions', () => {
it('renders the "Actions" field', () => {
createComponent({ members: [memberCanOverride], tableFields: ['actions'] });
expect(within(wrapper.element).queryByTestId('col-actions')).not.toBe(null);
});
});
describe('when none of the members have `canOverride` permissions', () => {
it('does not render the "Actions" field', () => {
createComponent({ members, tableFields: ['actions'] });
expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null);
});
});
});
});
});
import { member as memberMock } from 'jest/vue_shared/components/members/mock_data';
import { generateBadges } from 'ee/vue_shared/components/members/utils';
import { generateBadges, canOverride } from 'ee/vue_shared/components/members/utils';
describe('Members Utils', () => {
describe('generateBadges', () => {
......@@ -27,4 +27,14 @@ describe('Members Utils', () => {
expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected));
});
});
describe('canOverride', () => {
test.each`
member | expected
${{ ...memberMock, canOverride: true }} | ${true}
${memberMock} | ${false}
`('returns $expected', ({ member, expected }) => {
expect(canOverride(member)).toBe(expected);
});
});
});
---
extends: ../../../spec/frontend_integration/.eslintrc.yml
## Frontend Integration Specs
This directory contains Frontend integration specs. Go to `ee/spec/frontend` if you're looking for Frontend unit tests.
Please see [the CE README for more info](../../../spec/frontend_integration/README.md).
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Vulnerability Report error states has unavailable pages 1`] = `
<div>
<section
class="row empty-state text-center"
>
<div
class="col-12"
>
<div
class="svg-250 svg-content"
>
<img
alt="Oops, something doesn't seem right."
class="gl-max-w-full"
src="/test/empty_state.svg"
/>
</div>
</div>
<div
class="col-12"
>
<div
class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
<h1
class="h4"
>
Oops, something doesn't seem right.
</h1>
<p>
Either you don't have permission to view this dashboard or the dashboard has not been setup. Please check your permission settings with your administrator or check your dashboard configurations to proceed.
</p>
<div>
<a
class="btn btn-success btn-md gl-button"
href="/test/dashboard_page"
>
<span
class="gl-button-text"
>
Learn more about setting up your dashboard
</span>
</a>
</div>
</div>
</div>
</section>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Security Charts default states sets up group-level 1`] = `
<div>
<div
data-testid="security-charts-layout"
>
<h2>
Security Dashboard
</h2>
<div
class="gl-spinner-container gl-mt-6"
>
<span
aria-label="Loading"
class="align-text-bottom gl-spinner gl-spinner-dark gl-spinner-lg"
/>
</div>
<div
class="security-charts gl-display-flex gl-flex-wrap"
/>
</div>
</div>
`;
exports[`Security Charts default states sets up instance-level 1`] = `
<div>
<div
data-testid="security-charts-layout"
>
<h2>
Security Dashboard
</h2>
<div
class="gl-spinner-container gl-mt-6"
>
<span
aria-label="Loading"
class="align-text-bottom gl-spinner gl-spinner-dark gl-spinner-lg"
/>
</div>
<div
class="security-charts gl-display-flex gl-flex-wrap"
/>
</div>
</div>
`;
exports[`Security Charts error states has unavailable pages 1`] = `
<div>
<section
class="row empty-state text-center"
>
<div
class="col-12"
>
<div
class="svg-250 svg-content"
>
<img
alt="Oops, something doesn't seem right."
class="gl-max-w-full"
src="/test/empty_state.svg"
/>
</div>
</div>
<div
class="col-12"
>
<div
class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
<h1
class="h4"
>
Oops, something doesn't seem right.
</h1>
<p>
Either you don't have permission to view this dashboard or the dashboard has not been setup. Please check your permission settings with your administrator or check your dashboard configurations to proceed.
</p>
<div>
<a
class="btn btn-success btn-md gl-button"
href="/test/dashboard_page"
>
<span
class="gl-button-text"
>
Learn more about setting up your dashboard
</span>
</a>
</div>
</div>
</div>
</section>
</div>
`;
import { TEST_HOST } from 'jest/helpers/test_constants';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import initVulnerabilityReport from 'ee/security_dashboard/first_class_init';
const EMPTY_DIV = document.createElement('div');
const TEST_DATASET = {
dashboardDocumentation: '/test/dashboard_page',
emptyStateSvgPath: '/test/empty_state.svg',
hasVulnerabilities: true,
link: '/test/link',
noPipelineRunScannersHelpPath: '/test/no_pipeline_run_page',
notEnabledScannersHelpPath: '/test/security_dashboard_not_enabled_page',
noVulnerabilitiesSvgPath: '/test/no_vulnerability_state.svg',
projectAddEndpoint: '/test/add-projects',
projectListEndpoint: '/test/list-projects',
securityDashboardHelpPath: '/test/security_dashboard_page',
svgPath: '/test/no_changes_state.svg',
vulnerabilitiesExportEndpoint: '/test/export-vulnerabilities',
};
describe('Vulnerability Report', () => {
let vm;
let root;
beforeEach(() => {
root = document.createElement('div');
document.body.appendChild(root);
global.jsdom.reconfigure({
url: `${TEST_HOST}/-/security/vulnerabilities`,
});
});
afterEach(() => {
if (vm) {
vm.$destroy();
}
vm = null;
root.remove();
});
const createComponent = ({ data, type }) => {
const el = document.createElement('div');
Object.assign(el.dataset, { ...TEST_DATASET, ...data });
root.appendChild(el);
vm = initVulnerabilityReport(el, type);
};
const createEmptyComponent = () => {
vm = initVulnerabilityReport(null, null);
};
describe('default states', () => {
it('sets up project-level', () => {
createComponent({
data: {
autoFixDocumentation: '/test/auto_fix_page',
pipelineSecurityBuildsFailedCount: 1,
pipelineSecurityBuildsFailedPath: '/test/faild_pipeline_02',
projectFullPath: '/test/project',
},
type: DASHBOARD_TYPES.PROJECT,
});
// These assertions will be expanded in issue #220290
expect(root).not.toStrictEqual(EMPTY_DIV);
});
it('sets up group-level', () => {
createComponent({ data: { groupFullPath: '/test/' }, type: DASHBOARD_TYPES.GROUP });
// These assertions will be expanded in issue #220290
expect(root).not.toStrictEqual(EMPTY_DIV);
});
it('sets up instance-level', () => {
createComponent({
data: { instanceDashboardSettingsPath: '/instance/settings_page' },
type: DASHBOARD_TYPES.INSTANCE,
});
// These assertions will be expanded in issue #220290
expect(root).not.toStrictEqual(EMPTY_DIV);
});
});
describe('error states', () => {
it('does not have an element', () => {
createEmptyComponent();
expect(root).toStrictEqual(EMPTY_DIV);
});
it('has unavailable pages', () => {
createComponent({ data: { isUnavailable: true } });
expect(root).toMatchSnapshot();
});
});
});
import { TEST_HOST } from 'jest/helpers/test_constants';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import initSecurityCharts from 'ee/security_dashboard/security_charts_init';
const EMPTY_DIV = document.createElement('div');
const TEST_DATASET = {
link: '/test/link',
svgPath: '/test/no_changes_state.svg',
dashboardDocumentation: '/test/dashboard_page',
emptyStateSvgPath: '/test/empty_state.svg',
};
describe('Security Charts', () => {
let vm;
let root;
beforeEach(() => {
root = document.createElement('div');
document.body.appendChild(root);
global.jsdom.reconfigure({
url: `${TEST_HOST}/-/security/dashboard`,
});
});
afterEach(() => {
if (vm) {
vm.$destroy();
}
vm = null;
root.remove();
});
const createComponent = ({ data, type }) => {
const el = document.createElement('div');
Object.assign(el.dataset, { ...TEST_DATASET, ...data });
root.appendChild(el);
vm = initSecurityCharts(el, type);
};
const createEmptyComponent = () => {
vm = initSecurityCharts(null, null);
};
describe('default states', () => {
it('sets up group-level', () => {
createComponent({ data: { groupFullPath: '/test/' }, type: DASHBOARD_TYPES.GROUP });
expect(root).toMatchSnapshot();
});
it('sets up instance-level', () => {
createComponent({
data: { instanceDashboardSettingsPath: '/instance/settings_page' },
type: DASHBOARD_TYPES.INSTANCE,
});
expect(root).toMatchSnapshot();
});
});
describe('error states', () => {
it('does not have an element', () => {
createEmptyComponent();
expect(root).toStrictEqual(EMPTY_DIV);
});
it('has unavailable pages', () => {
createComponent({ data: { isUnavailable: true } });
expect(root).toMatchSnapshot();
});
});
});
......@@ -33,6 +33,10 @@ RSpec.describe Security::StoreReportsService do
subject
end
it 'marks the project as vulnerable' do
expect { subject }.to change { project.project_setting.has_vulnerabilities }.from(false).to(true)
end
context 'when StoreReportService returns an error for a report' do
let(:reports) { Gitlab::Ci::Reports::Security::Reports.new(pipeline) }
let(:sast_report) { reports.get_report('sast', sast_artifact) }
......
......@@ -22384,6 +22384,9 @@ msgstr ""
msgid "Removes time estimate."
msgstr ""
msgid "Removing integrations is not supported for this project"
msgstr ""
msgid "Removing this group also removes all child projects, including archived projects, and their resources."
msgstr ""
......@@ -30692,6 +30695,9 @@ msgstr ""
msgid "You have insufficient permissions to create an HTTP integration for this project"
msgstr ""
msgid "You have insufficient permissions to remove this HTTP integration"
msgstr ""
msgid "You have insufficient permissions to update this HTTP integration"
msgstr ""
......
......@@ -3,6 +3,7 @@ import Vuex from 'vuex';
import {
getByText as getByTextHelper,
getByTestId as getByTestIdHelper,
within,
} from '@testing-library/dom';
import { GlBadge } from '@gitlab/ui';
import MembersTable from '~/vue_shared/components/members/table/members_table.vue';
......@@ -28,6 +29,7 @@ describe('MemberList', () => {
members: [],
tableFields: [],
sourceId: 1,
currentUserId: 1,
...state,
},
});
......@@ -62,12 +64,16 @@ describe('MemberList', () => {
});
describe('fields', () => {
const memberCanUpdate = {
const directMember = {
...memberMock,
canUpdate: true,
source: { ...memberMock.source, id: 1 },
};
const memberCanUpdate = {
...directMember,
canUpdate: true,
};
it.each`
field | label | member | expectedComponent
${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
......@@ -96,19 +102,60 @@ describe('MemberList', () => {
}
});
it('renders "Actions" field for screen readers', () => {
createComponent({ members: [memberMock], tableFields: ['actions'] });
describe('"Actions" field', () => {
it('renders "Actions" field for screen readers', () => {
createComponent({ members: [memberCanUpdate], tableFields: ['actions'] });
const actionField = getByTestId('col-actions');
const actionField = getByTestId('col-actions');
expect(actionField.exists()).toBe(true);
expect(actionField.classes('gl-sr-only')).toBe(true);
expect(
wrapper
.find(`[data-label="Actions"][role="cell"]`)
.find(MemberActionButtons)
.exists(),
).toBe(true);
expect(actionField.exists()).toBe(true);
expect(actionField.classes('gl-sr-only')).toBe(true);
expect(
wrapper
.find(`[data-label="Actions"][role="cell"]`)
.find(MemberActionButtons)
.exists(),
).toBe(true);
});
describe('when user is not logged in', () => {
it('does not render the "Actions" field', () => {
createComponent({ currentUserId: null, tableFields: ['actions'] });
expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null);
});
});
const memberCanRemove = {
...directMember,
canRemove: true,
};
describe.each`
permission | members
${'canUpdate'} | ${[memberCanUpdate]}
${'canRemove'} | ${[memberCanRemove]}
${'canResend'} | ${[invite]}
`('when one of the members has $permission permissions', ({ members }) => {
it('renders the "Actions" field', () => {
createComponent({ members, tableFields: ['actions'] });
expect(getByTestId('col-actions').exists()).toBe(true);
});
});
describe.each`
permission | members
${'canUpdate'} | ${[memberMock]}
${'canRemove'} | ${[memberMock]}
${'canResend'} | ${[{ ...invite, invite: { ...invite.invite, canResend: false } }]}
`('when none of the members have $permission permissions', ({ members }) => {
it('does not render the "Actions" field', () => {
createComponent({ members, tableFields: ['actions'] });
expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null);
});
});
});
});
......
import { generateBadges } from '~/vue_shared/components/members/utils';
import { member as memberMock } from './mock_data';
import {
generateBadges,
isGroup,
isDirectMember,
isCurrentUser,
canRemove,
canResend,
canUpdate,
canOverride,
} from '~/vue_shared/components/members/utils';
import { member as memberMock, group, invite } from './mock_data';
const DIRECT_MEMBER_ID = 178;
const INHERITED_MEMBER_ID = 179;
const IS_CURRENT_USER_ID = 123;
const IS_NOT_CURRENT_USER_ID = 124;
describe('Members Utils', () => {
describe('generateBadges', () => {
......@@ -26,4 +40,83 @@ describe('Members Utils', () => {
expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected));
});
});
describe('isGroup', () => {
test.each`
member | expected
${group} | ${true}
${memberMock} | ${false}
`('returns $expected', ({ member, expected }) => {
expect(isGroup(member)).toBe(expected);
});
});
describe('isDirectMember', () => {
test.each`
sourceId | expected
${DIRECT_MEMBER_ID} | ${true}
${INHERITED_MEMBER_ID} | ${false}
`('returns $expected', ({ sourceId, expected }) => {
expect(isDirectMember(memberMock, sourceId)).toBe(expected);
});
});
describe('isCurrentUser', () => {
test.each`
currentUserId | expected
${IS_CURRENT_USER_ID} | ${true}
${IS_NOT_CURRENT_USER_ID} | ${false}
`('returns $expected', ({ currentUserId, expected }) => {
expect(isCurrentUser(memberMock, currentUserId)).toBe(expected);
});
});
describe('canRemove', () => {
const memberCanRemove = {
...memberMock,
canRemove: true,
};
test.each`
member | sourceId | expected
${memberCanRemove} | ${DIRECT_MEMBER_ID} | ${true}
${memberCanRemove} | ${INHERITED_MEMBER_ID} | ${false}
${memberMock} | ${INHERITED_MEMBER_ID} | ${false}
`('returns $expected', ({ member, sourceId, expected }) => {
expect(canRemove(member, sourceId)).toBe(expected);
});
});
describe('canResend', () => {
test.each`
member | expected
${invite} | ${true}
${{ ...invite, invite: { ...invite.invite, canResend: false } }} | ${false}
`('returns $expected', ({ member, sourceId, expected }) => {
expect(canResend(member, sourceId)).toBe(expected);
});
});
describe('canUpdate', () => {
const memberCanUpdate = {
...memberMock,
canUpdate: true,
};
test.each`
member | currentUserId | sourceId | expected
${memberCanUpdate} | ${IS_NOT_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${true}
${memberCanUpdate} | ${IS_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${false}
${memberCanUpdate} | ${IS_CURRENT_USER_ID} | ${INHERITED_MEMBER_ID} | ${false}
${memberMock} | ${IS_NOT_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${false}
`('returns $expected', ({ member, currentUserId, sourceId, expected }) => {
expect(canUpdate(member, currentUserId, sourceId)).toBe(expected);
});
});
describe('canOverride', () => {
it('returns `false`', () => {
expect(canOverride(memberMock)).toBe(false);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::AlertManagement::HttpIntegration::Destroy do
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let(:integration) { create(:alert_management_http_integration, project: project) }
let(:args) { { id: GitlabSchema.id_from_object(integration) } }
specify { expect(described_class).to require_graphql_authorizations(:admin_operations) }
describe '#resolve' do
subject(:resolve) { mutation_for(project, current_user).resolve(args) }
context 'user has access to project' do
before do
project.add_maintainer(current_user)
end
context 'when HttpIntegrations::DestroyService responds with success' do
it 'returns the integration with no errors' do
expect(resolve).to eq(
integration: integration,
errors: []
)
end
end
context 'when HttpIntegrations::DestroyService responds with an error' do
before do
allow_any_instance_of(::AlertManagement::HttpIntegrations::DestroyService)
.to receive(:execute)
.and_return(ServiceResponse.error(payload: { integration: nil }, message: 'An error has occurred'))
end
it 'returns errors' do
expect(resolve).to eq(
integration: nil,
errors: ['An error has occurred']
)
end
end
end
context 'when resource is not accessible to the user' do
it 'raises an error if the resource is not accessible to the user' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
private
def mutation_for(project, user)
described_class.new(object: project, context: { current_user: user }, field: nil)
end
end
......@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe Resolvers::UsersResolver do
include GraphqlHelpers
let_it_be(:user1) { create(:user) }
let_it_be(:user2) { create(:user) }
let_it_be(:user1) { create(:user, name: "SomePerson") }
let_it_be(:user2) { create(:user, username: "someone123784") }
describe '#resolve' do
it 'raises an error when read_users_list is not authorized' do
......@@ -43,6 +43,14 @@ RSpec.describe Resolvers::UsersResolver do
).to contain_exactly(user1, user2)
end
end
context 'when a search term is passed' do
it 'returns all users who match', :aggregate_failures do
expect(resolve_users(search: "some")).to contain_exactly(user1, user2)
expect(resolve_users(search: "123784")).to contain_exactly(user2)
expect(resolve_users(search: "someperson")).to contain_exactly(user1)
end
end
end
def resolve_users(args = {})
......
......@@ -683,6 +683,7 @@ ProjectCiCdSetting:
ProjectSetting:
- allow_merge_on_skipped_pipeline
- has_confluence
- has_vulnerabilities
ProtectedEnvironment:
- id
- project_id
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Removing an HTTP Integration' do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:integration) { create(:alert_management_http_integration, project: project) }
let(:mutation) do
variables = {
id: GitlabSchema.id_from_object(integration).to_s
}
graphql_mutation(:http_integration_destroy, variables) do
<<~QL
clientMutationId
errors
integration {
id
type
name
active
token
url
apiUrl
}
QL
end
end
let(:mutation_response) { graphql_mutation_response(:http_integration_destroy) }
before do
project.add_maintainer(user)
end
it 'removes the integration' do
post_graphql_mutation(mutation, current_user: user)
integration_response = mutation_response['integration']
expect(response).to have_gitlab_http_status(:success)
expect(integration_response['id']).to eq(GitlabSchema.id_from_object(integration).to_s)
expect(integration_response['type']).to eq('HTTP')
expect(integration_response['name']).to eq(integration.name)
expect(integration_response['active']).to eq(integration.active)
expect(integration_response['token']).to eq(integration.token)
expect(integration_response['url']).to eq(integration.url)
expect(integration_response['apiUrl']).to eq(nil)
expect { integration.reload }.to raise_error ActiveRecord::RecordNotFound
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe AlertManagement::HttpIntegrations::DestroyService do
let_it_be(:user_with_permissions) { create(:user) }
let_it_be(:user_without_permissions) { create(:user) }
let_it_be(:project) { create(:project) }
let!(:integration) { create(:alert_management_http_integration, project: project) }
let(:current_user) { user_with_permissions }
let(:params) { {} }
let(:service) { described_class.new(integration, current_user) }
before_all do
project.add_maintainer(user_with_permissions)
end
describe '#execute' do
shared_examples 'error response' do |message|
it 'has an informative message' do
expect(response).to be_error
expect(response.message).to eq(message)
end
end
subject(:response) { service.execute }
context 'when the current_user is anonymous' do
let(:current_user) { nil }
it_behaves_like 'error response', 'You have insufficient permissions to remove this HTTP integration'
end
context 'when current_user does not have permission to create integrations' do
let(:current_user) { user_without_permissions }
it_behaves_like 'error response', 'You have insufficient permissions to remove this HTTP integration'
end
context 'when feature flag is not enabled' do
before do
stub_feature_flags(multiple_http_integrations: false)
end
it_behaves_like 'error response', 'Removing integrations is not supported for this project'
end
context 'when an error occurs during removal' do
before do
allow(integration).to receive(:destroy).and_return(false)
integration.errors.add(:name, 'cannot be removed')
end
it_behaves_like 'error response', 'Name cannot be removed'
end
it 'successfully returns the integration' do
expect(response).to be_success
integration_result = response.payload[:integration]
expect(integration_result).to be_a(::AlertManagement::HttpIntegration)
expect(integration_result.name).to eq(integration.name)
expect(integration_result.active).to eq(integration.active)
expect(integration_result.token).to eq(integration.token)
expect(integration_result.endpoint_identifier).to eq(integration.endpoint_identifier)
expect { integration.reload }.to raise_error ActiveRecord::RecordNotFound
end
end
end
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