From c00e3e49ef33b44d5fe1bd34a396bb3dfe2cca65 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Sat, 8 Jan 2022 00:14:32 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .../filtered_search_bar_root.vue | 2 +- app/controllers/groups_controller.rb | 3 +- .../mutations/issues/set_crm_contacts.rb | 9 +- app/helpers/application_settings_helper.rb | 31 +-- app/helpers/groups/crm_settings_helper.rb | 9 + app/models/group.rb | 5 + app/models/project.rb | 1 + app/policies/group_policy.rb | 11 +- app/services/groups/update_service.rb | 10 + .../resource_access_tokens/create_service.rb | 2 +- ...ernal_authorization_service_form.html.haml | 23 +- app/views/groups/edit.html.haml | 4 +- .../groups/settings/_permissions.html.haml | 9 + doc/api/api_resources.md | 3 +- doc/api/group_access_tokens.md | 112 +++++++++ doc/api/tags.md | 3 +- .../testing_guide/end_to_end/index.md | 7 + doc/integration/elasticsearch.md | 2 + doc/security/two_factor_authentication.md | 4 +- .../settings/external_authorization.md | 38 +-- ...xternal_authorization_service_settings.png | Bin 74753 -> 0 bytes doc/user/crm/index.md | 18 +- doc/user/group/index.md | 24 +- .../group/saml_sso/group_managed_accounts.md | 2 +- doc/user/group/subgroups/index.md | 2 +- .../project/settings/project_access_tokens.md | 2 +- lib/api/entities/resource_access_token.rb | 2 +- lib/api/resource_access_tokens.rb | 10 +- locale/gitlab.pot | 103 ++++---- spec/factories/groups.rb | 6 + spec/features/groups/navbar_spec.rb | 7 +- .../issues/user_bulk_edits_issues_spec.rb | 20 ++ .../contacts/create_spec.rb | 15 +- .../contacts/update_spec.rb | 2 +- .../organizations/create_spec.rb | 2 +- .../organizations/update_spec.rb | 13 +- .../groups/crm_settings_helper_spec.rb | 25 ++ spec/models/group_spec.rb | 17 ++ spec/policies/group_policy_spec.rb | 59 ++++- .../mutations/issues/set_crm_contacts_spec.rb | 236 ++++++++++-------- .../api/resource_access_tokens_spec.rb | 187 +++++++------- .../groups/crm/contacts_controller_spec.rb | 14 +- .../crm/organizations_controller_spec.rb | 14 +- .../contacts/create_service_spec.rb | 4 +- .../contacts/update_service_spec.rb | 4 +- .../organizations/create_service_spec.rb | 2 +- .../organizations/update_service_spec.rb | 4 +- spec/services/groups/update_service_spec.rb | 64 +++++ spec/services/issues/create_service_spec.rb | 2 +- .../issues/set_crm_contacts_service_spec.rb | 2 +- spec/services/issues/update_service_spec.rb | 2 +- .../quick_actions/interpret_service_spec.rb | 2 +- .../create_service_spec.rb | 49 ++-- .../revoke_service_spec.rb | 116 +++++---- .../policies/group_policy_shared_context.rb | 2 +- 55 files changed, 886 insertions(+), 435 deletions(-) create mode 100644 app/helpers/groups/crm_settings_helper.rb create mode 100644 doc/api/group_access_tokens.md delete mode 100644 doc/user/admin_area/settings/img/external_authorization_service_settings.png create mode 100644 spec/helpers/groups/crm_settings_helper_spec.rb diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 7c1828f2294..5cdf7b6a3b2 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -332,7 +332,7 @@ export default { v-if="showCheckbox" class="gl-align-self-center" :checked="checkboxChecked" - @input="$emit('checked-input', $event)" + @change="$emit('checked-input', $event)" > {{ __('Select all') }} diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 62336c7eede..0ab51357e13 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -276,7 +276,8 @@ class GroupsController < Groups::ApplicationController :resource_access_token_creation_allowed, :prevent_sharing_groups_outside_hierarchy, :setup_for_company, - :jobs_to_be_done + :jobs_to_be_done, + :crm_enabled ] end diff --git a/app/graphql/mutations/issues/set_crm_contacts.rb b/app/graphql/mutations/issues/set_crm_contacts.rb index 4e49a45d52a..62990fc67f1 100644 --- a/app/graphql/mutations/issues/set_crm_contacts.rb +++ b/app/graphql/mutations/issues/set_crm_contacts.rb @@ -18,7 +18,8 @@ module Mutations def resolve(project_path:, iid:, contact_ids:, operation_mode: Types::MutationOperationModeEnum.enum[:replace]) issue = authorized_find!(project_path: project_path, iid: iid) project = issue.project - raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, project.group, default_enabled: :yaml) + + raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled?(project) contact_ids = contact_ids.compact.map do |contact_id| raise Gitlab::Graphql::Errors::ArgumentError, "Contact #{contact_id} is invalid." unless contact_id.respond_to?(:model_id) @@ -43,6 +44,12 @@ module Mutations errors: response.errors } end + + private + + def feature_enabled?(project) + Feature.enabled?(:customer_relations, project.group, default_enabled: :yaml) && project.group&.crm_enabled? + end end end end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 86a8d48a1f1..90861e440fb 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -144,36 +144,39 @@ module ApplicationSettingsHelper end def external_authorization_description - _("If enabled, access to projects will be validated on an external service"\ + s_("ExternalAuthorization|Access to projects is validated on an external service"\ " using their classification label.") end def external_authorization_timeout_help_text - _("Time in seconds GitLab will wait for a response from the external "\ - "service. When the service does not respond in time, access will be "\ - "denied.") + s_("ExternalAuthorization|Period GitLab waits for a response from the external "\ + "service. If there is no response, access is denied. Default: 0.5 seconds.") end def external_authorization_url_help_text - _("When leaving the URL blank, classification labels can still be "\ - "specified without disabling cross project features or performing "\ - "external authorization checks.") + s_("ExternalAuthorization|URL to which the projects make authorization requests. If the URL is blank, cross-project "\ + "features are available and can still specify classification "\ + "labels for projects.") end def external_authorization_client_certificate_help_text - _("The X509 Certificate to use when mutual TLS is required to communicate "\ - "with the external authorization service. If left blank, the server "\ - "certificate is still validated when accessing over HTTPS.") + s_("ExternalAuthorization|Certificate used to authenticate with the external authorization service. "\ + "If blank, the server certificate is validated when accessing over HTTPS.") end def external_authorization_client_key_help_text - _("The private key to use when a client certificate is provided. This value "\ - "is encrypted at rest.") + s_("ExternalAuthorization|Private key of client authentication certificate. "\ + "Encrypted when stored.") end def external_authorization_client_pass_help_text - _("The passphrase required to decrypt the private key. This is optional "\ - "and the value is encrypted at rest.") + s_("ExternalAuthorization|Passphrase required to decrypt the private key. "\ + "Encrypted when stored.") + end + + def external_authorization_client_url_help_text + s_("ExternalAuthorization|Classification label to use when requesting authorization if no specific "\ + " label is defined on the project.") end def sidekiq_job_limiter_mode_help_text diff --git a/app/helpers/groups/crm_settings_helper.rb b/app/helpers/groups/crm_settings_helper.rb new file mode 100644 index 00000000000..ab47ec40b13 --- /dev/null +++ b/app/helpers/groups/crm_settings_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Groups + module CrmSettingsHelper + def crm_feature_flag_enabled?(group) + Feature.enabled?(:customer_relations, group) + end + end +end diff --git a/app/models/group.rb b/app/models/group.rb index 19eac7af761..28b945f7c03 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -634,11 +634,16 @@ class Group < Namespace group_members.find_by(user_id: user) end end + alias_method :resource_member, :group_member def highest_group_member(user) GroupMember.where(source_id: self_and_ancestors_ids, user_id: user.id).order(:access_level).last end + def bots + users.project_bot + end + def related_group_ids [id, *ancestors.pluck(:id), diff --git a/app/models/project.rb b/app/models/project.rb index 0b33f28f82c..5c4ffd08304 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1667,6 +1667,7 @@ class Project < ApplicationRecord project_members.find_by(user_id: user) end end + alias_method :resource_member, :project_member def membership_locked? false diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 5c4990ffd9b..71133008565 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -23,6 +23,9 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy condition(:parent_share_with_group_locked, scope: :subject) { @subject.parent&.share_with_group_lock? } condition(:can_change_parent_share_with_group_lock) { can?(:change_share_with_group_lock, @subject.parent) } + desc "User is a project bot" + condition(:project_bot) { user.project_bot? && access_level >= GroupMember::GUEST } + condition(:has_projects) do group_projects_for(user: @user, group: @subject).any? end @@ -75,7 +78,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy with_scope :subject condition(:has_project_with_service_desk_enabled) { @subject.has_project_with_service_desk_enabled? } - condition(:crm_enabled, score: 0, scope: :subject) { Feature.enabled?(:customer_relations, @subject) } + condition(:crm_enabled, score: 0, scope: :subject) { Feature.enabled?(:customer_relations, @subject) && @subject.crm_enabled? } with_scope :subject condition(:group_runner_registration_allowed, score: 0, scope: :subject) do @@ -250,6 +253,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :admin_dependency_proxy end + rule { project_bot }.enable :project_bot_access + rule { can?(:admin_group) & resource_access_token_feature_available }.policy do enable :read_resource_access_tokens enable :destroy_resource_access_tokens @@ -260,6 +265,10 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :create_resource_access_tokens end + rule { can?(:project_bot_access) }.policy do + prevent :create_resource_access_tokens + end + rule { support_bot & has_project_with_service_desk_enabled }.policy do enable :read_label end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 2d6334251ad..b3b0397eac3 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -107,6 +107,7 @@ module Groups def handle_changes handle_settings_update + handle_crm_settings_update unless params[:crm_enabled].nil? end def handle_settings_update @@ -116,6 +117,15 @@ module Groups ::NamespaceSettings::UpdateService.new(current_user, group, settings_params).execute end + def handle_crm_settings_update + crm_enabled = params.delete(:crm_enabled) + return if group.crm_enabled? == crm_enabled + + crm_settings = group.crm_settings || group.build_crm_settings + crm_settings.enabled = crm_enabled + crm_settings.save + end + def allowed_settings_params SETTINGS_PARAMS end diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index e0371e5d80f..af0feef4eaa 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -63,7 +63,7 @@ module ResourceAccessTokens name: params[:name] || "#{resource.name.to_s.humanize} bot", email: generate_email, username: generate_username, - user_type: "#{resource_type}_bot".to_sym, + user_type: :project_bot, skip_confirmation: true # Bot users should always have their emails confirmed. } end diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml index 23484eaec32..4fb10d48540 100644 --- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml +++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml @@ -1,11 +1,12 @@ %section.settings.as-external-auth.no-animate#js-external-auth-settings{ class: ('expanded' if expanded) } .settings-header %h4 - = _('External authentication') + = s_('ExternalAuthorization|External authorization') %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' %p - = _('External Classification Policy Authorization') + = s_('ExternalAuthorization|External classification policy authorization.') + = link_to _('Learn more.'), help_page_path('user/admin_area/settings/external_authorization'), target: '_blank', rel: 'noopener noreferrer' .settings-content = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form', id: 'external-auth-settings' } do |f| @@ -16,35 +17,37 @@ .form-check = f.check_box :external_authorization_service_enabled, class: 'form-check-input' = f.label :external_authorization_service_enabled, class: 'form-check-label' do - = _('Enable classification control using an external service') + = s_('ExternalAuthorization|Enable classification control using an external service') %span.form-text.text-muted = external_authorization_description - = link_to sprite_icon('question-o'), help_page_path('user/admin_area/settings/external_authorization') .form-group - = f.label :external_authorization_service_url, _('Service URL'), class: 'label-bold' + = f.label :external_authorization_service_url, s_('ExternalAuthorization|Service URL'), class: 'label-bold' = f.text_field :external_authorization_service_url, class: 'form-control gl-form-input' %span.form-text.text-muted = external_authorization_url_help_text .form-group - = f.label :external_authorization_service_timeout, _('External authorization request timeout'), class: 'label-bold' + = f.label :external_authorization_service_timeout, s_('ExternalAuthorization|External authorization request timeout (seconds)'), class: 'label-bold' = f.number_field :external_authorization_service_timeout, class: 'form-control gl-form-input', min: 0.001, max: 10, step: 0.001 %span.form-text.text-muted = external_authorization_timeout_help_text - = f.label :external_auth_client_cert, _('Client authentication certificate'), class: 'label-bold' + .form-group + = f.label :external_auth_client_cert, s_('ExternalAuthorization|Client authorization certificate'), class: 'label-bold' = f.text_area :external_auth_client_cert, class: 'form-control gl-form-input' %span.form-text.text-muted = external_authorization_client_certificate_help_text .form-group - = f.label :external_auth_client_key, _('Client authentication key'), class: 'label-bold' + = f.label :external_auth_client_key, s_('ExternalAuthorization|Client authorization key'), class: 'label-bold' = f.text_area :external_auth_client_key, class: 'form-control gl-form-input' %span.form-text.text-muted = external_authorization_client_key_help_text .form-group - = f.label :external_auth_client_key_pass, _('Client authentication key password'), class: 'label-bold' + = f.label :external_auth_client_key_pass, s_('ExternalAuthorization|Client authorization key password (optional)'), class: 'label-bold' = f.password_field :external_auth_client_key_pass, class: 'form-control gl-form-input' %span.form-text.text-muted = external_authorization_client_pass_help_text .form-group - = f.label :external_authorization_service_default_label, _('Default classification label'), class: 'label-bold' + = f.label :external_authorization_service_default_label, s_('ExternalAuthorization|Default classification label'), class: 'label-bold' = f.text_field :external_authorization_service_default_label, class: 'form-control gl-form-input' + %span.form-text.text-muted + = external_authorization_client_url_help_text = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 79e023e2589..f3494149087 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -20,11 +20,11 @@ %section.settings.gs-permissions.no-animate#js-permissions-settings{ class: ('expanded' if expanded), data: { qa_selector: 'permission_lfs_2fa_content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' } - = _('Permissions, LFS, 2FA') + = _('Permissions and group features') %button.btn.gl-button.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _('Configure advanced permissions, Large File Storage, and two-factor authentication settings.') + = _('Configure advanced permissions, Large File Storage, two-factor authentication, and customer relations settings.') .settings-content = render 'groups/settings/permissions' diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index eb38aa43881..59c47634c2d 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -41,4 +41,13 @@ = render 'groups/settings/two_factor_auth', f: f, group: @group = render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group = render 'groups/settings/membership', f: f, group: @group + + - if crm_feature_flag_enabled?(@group) + %h5= _('Customer relations') + .form-group.gl-mb-3 + = f.gitlab_ui_checkbox_component :crm_enabled, + s_('GroupSettings|Enable customer relations'), + checkbox_options: { checked: @group.crm_enabled? }, + help_text: s_('GroupSettings|Allows creating organizations and contacts and associating them with issues.') + = f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' } diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md index b5fac5019d9..783823f80fb 100644 --- a/doc/api/api_resources.md +++ b/doc/api/api_resources.md @@ -25,7 +25,7 @@ The following API resources are available in the project context: | Resource | Available endpoints | |:------------------------------------------------------------------------|:--------------------| | [Access requests](access_requests.md) | `/projects/:id/access_requests` (also available for groups) | -| [Access tokens](resource_access_tokens.md) | `/projects/:id/access_tokens` | +| [Access tokens](resource_access_tokens.md) | `/projects/:id/access_tokens` (also available for groups) | | [Award emoji](award_emoji.md) | `/projects/:id/issues/.../award_emoji`, `/projects/:id/merge_requests/.../award_emoji`, `/projects/:id/snippets/.../award_emoji` | | [Branches](branches.md) | `/projects/:id/repository/branches/`, `/projects/:id/repository/merged_branches` | | [Commits](commits.md) | `/projects/:id/repository/commits`, `/projects/:id/statuses` | @@ -100,6 +100,7 @@ The following API resources are available in the group context: | Resource | Available endpoints | |:-----------------------------------------------------------------|:--------------------| | [Access requests](access_requests.md) | `/groups/:id/access_requests/` (also available for projects) | +| [Access tokens](group_access_tokens.md) | `/groups/:id/access_tokens` (also available for projects) | | [Custom attributes](custom_attributes.md) | `/groups/:id/custom_attributes` (also available for projects and users) | | [Debian distributions](packages/debian_group_distributions.md) | `/groups/:id/-/packages/debian` (also available for projects) | | [Deploy tokens](deploy_tokens.md) | `/groups/:id/deploy_tokens` (also available for projects and standalone) | diff --git a/doc/api/group_access_tokens.md b/doc/api/group_access_tokens.md new file mode 100644 index 00000000000..71c6828de49 --- /dev/null +++ b/doc/api/group_access_tokens.md @@ -0,0 +1,112 @@ +--- +stage: Manage +group: Authentication & Authorization +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/#assignments +--- + +# Group access tokens API **(FREE)** + +You can read more about [group access tokens](../user/project/settings/project_access_tokens.md#group-access-tokens). + +## List group access tokens + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/77236) in GitLab 14.7. + +Get a list of [group access tokens](../user/project/settings/project_access_tokens.md#group-access-tokens). + +```plaintext +GET groups/:id/access_tokens +``` + +| Attribute | Type | required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer or string | yes | The ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) | + +```shell +curl --header "PRIVATE-TOKEN: " "https://gitlab.example.com/api/v4/groups//access_tokens" +``` + +```json +[ + { + "user_id" : 141, + "scopes" : [ + "api" + ], + "name" : "token", + "expires_at" : "2021-01-31", + "id" : 42, + "active" : true, + "created_at" : "2021-01-20T22:11:48.151Z", + "revoked" : false, + "access_level": 40 + } +] +``` + +## Create a group access token + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/77236) in GitLab 14.7. + +Create a [group access token](../user/project/settings/project_access_tokens.md#group-access-tokens). + +```plaintext +POST groups/:id/access_tokens +``` + +| Attribute | Type | required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer or string | yes | The ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) | +| `name` | String | yes | The name of the group access token | +| `scopes` | `Array[String]` | yes | [List of scopes](../user/project/settings/project_access_tokens.md#scopes-for-a-project-access-token) | +| `access_level` | Integer | no | A valid access level. Default value is 40 (Maintainer). Other allowed values are 10 (Guest), 20 (Reporter), and 30 (Developer). | +| `expires_at` | Date | no | The token expires at midnight UTC on that date | + +```shell +curl --request POST --header "PRIVATE-TOKEN: " \ +--header "Content-Type:application/json" \ +--data '{ "name":"test_token", "scopes":["api", "read_repository"], "expires_at":"2021-01-31", "access_level": 30 }' \ +"https://gitlab.example.com/api/v4/groups//access_tokens" +``` + +```json +{ + "scopes" : [ + "api", + "read_repository" + ], + "active" : true, + "name" : "test", + "revoked" : false, + "created_at" : "2021-01-21T19:35:37.921Z", + "user_id" : 166, + "id" : 58, + "expires_at" : "2021-01-31", + "token" : "D4y...Wzr", + "access_level": 30 +} +``` + +## Revoke a group access token + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/77236) in GitLab 14.7. + +Revoke a [group access token](../user/project/settings/project_access_tokens.md#group-access-tokens). + +```plaintext +DELETE groups/:id/access_tokens/:token_id +``` + +| Attribute | Type | required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer or string | yes | The ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) | +| `token_id` | integer or string | yes | The ID of the group access token | + +```shell +curl --request DELETE --header "PRIVATE-TOKEN: " "https://gitlab.example.com/api/v4/groups//access_tokens/" +``` + +### Responses + +- `204: No Content` if successfully revoked. +- `400 Bad Request` or `404 Not Found` if not revoked successfully. diff --git a/doc/api/tags.md b/doc/api/tags.md index 527ad07565b..6aa40cf476d 100644 --- a/doc/api/tags.md +++ b/doc/api/tags.md @@ -8,8 +8,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w ## List project repository tags -Get a list of repository tags from a project, sorted by name in reverse -alphabetical order. This endpoint can be accessed without authentication if the +Get a list of repository tags from a project, sorted by update date and time in descending order. This endpoint can be accessed without authentication if the repository is publicly accessible. ```plaintext diff --git a/doc/development/testing_guide/end_to_end/index.md b/doc/development/testing_guide/end_to_end/index.md index eb6d66ca8c9..1fc9bc8258a 100644 --- a/doc/development/testing_guide/end_to_end/index.md +++ b/doc/development/testing_guide/end_to_end/index.md @@ -170,6 +170,13 @@ Helm chart](https://gitlab.com/gitlab-org/charts/gitlab/), itself deployed with See [Review Apps](../review_apps.md) for more details about Review Apps. +### Run tests in parallel + +To run tests in parallel on CI, the [Knapsack](https://github.com/KnapsackPro/knapsack) +gem is used. Knapsack reports are generated automatically and stored in the `GCS` bucket +`knapsack-reports` in the `gitlab-qa-resources` project. The [`KnapsackReport`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/qa/qa/tools/knapsack_report.rb) +helper handles automated report generation and upload. + ## Test metrics For additional test health visibility, use a custom setup to export test execution diff --git a/doc/integration/elasticsearch.md b/doc/integration/elasticsearch.md index 8461aca8c8d..7356574a33e 100644 --- a/doc/integration/elasticsearch.md +++ b/doc/integration/elasticsearch.md @@ -478,6 +478,8 @@ The following are some available Rake tasks: | [`sudo gitlab-rake gitlab:elastic:mark_reindex_failed`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Mark the most recent re-index job as failed. | | [`sudo gitlab-rake gitlab:elastic:list_pending_migrations`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | List pending migrations. Pending migrations include those that have not yet started, have started but not finished, and those that are halted. | | [`sudo gitlab-rake gitlab:elastic:estimate_cluster_size`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Get an estimate of cluster size based on the total repository size. | +| [`sudo gitlab-rake gitlab:elastic:enable_search_with_elasticsearch`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Enable advanced search with Elasticsearch. | +| [`sudo gitlab-rake gitlab:elastic:disable_search_with_elasticsearch`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Disables advanced search with Elasticsearch. | ### Environment variables diff --git a/doc/security/two_factor_authentication.md b/doc/security/two_factor_authentication.md index a884c6d2098..b83d81722fa 100644 --- a/doc/security/two_factor_authentication.md +++ b/doc/security/two_factor_authentication.md @@ -49,7 +49,7 @@ Gitlab::CurrentSettings.update!('require_two_factor_authentication': false) To enforce 2FA only for certain groups: 1. Go to the group's **Settings > General** page. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. Select the **Require all users in this group to set up two-factor authentication** option. You can also specify a grace period in the **Time before enforced** option. @@ -76,7 +76,7 @@ The following are important notes about 2FA: groups) the shortest grace period is used. - It is possible to disallow subgroups from setting up their own 2FA requirements: 1. Go to the top-level group's **Settings > General**. - 1. Expand the **Permissions, LFS, 2FA** section. + 1. Expand the **Permissions and group features** section. 1. Uncheck the **Allow subgroups to set up their own two-factor authentication rule** field. This action causes all subgroups with 2FA requirements to stop requiring that from their members. diff --git a/doc/user/admin_area/settings/external_authorization.md b/doc/user/admin_area/settings/external_authorization.md index 62328aa4f68..4fd7c59ef24 100644 --- a/doc/user/admin_area/settings/external_authorization.md +++ b/doc/user/admin_area/settings/external_authorization.md @@ -29,39 +29,13 @@ functionality that render cross-project data. That includes: Labels, Milestones, Merge requests). - Global and Group search are disabled. -This is to prevent performing to many requests at once to the external +This is to prevent performing too many requests at once to the external authorization service. Whenever access is granted or denied this is logged in a log file called `external-policy-access-control.log`. Read more about the logs GitLab keeps in the [Omnibus GitLab documentation](https://docs.gitlab.com/omnibus/settings/logs.html). -## Configuration - -The external authorization service can be enabled by an administrator: - -1. On the top bar, select **Menu > Admin**. -1. On the left sidebar, select **Settings > General**: - ![Enable external authorization service](img/external_authorization_service_settings.png) - -The available required properties are: - -- **Service URL**: The URL to make authorization requests to. When leaving the - URL blank, cross project features remain available while still being able - to specify classification labels for projects. -- **External authorization request timeout**: The timeout after which an - authorization request is aborted. When a request times out, access is denied - to the user. -- **Client authentication certificate**: The certificate to use to authenticate - with the external authorization service. -- **Client authentication key**: Private key for the certificate when - authentication is required for the external authorization service, this is - encrypted when stored. -- **Client authentication key password**: Passphrase to use for the private key - when authenticating with the external service this is encrypted when stored. -- **Default classification label**: The classification label to use when - requesting authorization if no specific label is defined on the project - When using TLS Authentication with a self signed certificate, the CA certificate needs to be trusted by the OpenSSL installation. When using GitLab installed using Omnibus, learn to install a custom CA in the @@ -69,6 +43,16 @@ using Omnibus, learn to install a custom CA in the Alternatively, learn where to install custom certificates by using `openssl version -d`. +## Configuration + +The external authorization service can be enabled by an administrator: + +1. On the top bar, select **Menu > Admin**. +1. On the left sidebar, select **Settings > General**. +1. Expand **External authorization**. +1. Complete the fields. +1. Select **Save changes**. + ## How it works When GitLab requests access, it sends a JSON POST request to the external diff --git a/doc/user/admin_area/settings/img/external_authorization_service_settings.png b/doc/user/admin_area/settings/img/external_authorization_service_settings.png deleted file mode 100644 index 9b8658fd1a19a929f8eeafbaa49322a9e7ee022d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 74753 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 u)GZ{cAcc;s>H{VB1}+LOy` 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 diff --git a/doc/user/crm/index.md b/doc/user/crm/index.md index d68ce0a4f7a..5bd93a172f9 100644 --- a/doc/user/crm/index.md +++ b/doc/user/crm/index.md @@ -6,13 +6,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w # Customer relations management (CRM) **(FREE)** -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2256) in GitLab 14.6 [with a flag](../../administration/feature_flags.md) named `customer_relations`. Disabled by default. - -FLAG: -On self-managed GitLab, by default this feature is not available. To make it available, -ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `customer_relations`. -On GitLab.com, this feature is not available. -You should not use this feature for production environments. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2256) in GitLab 14.6 [with a flag](../../administration/feature_flags.md) named `customer_relations`. Disabled by default. With customer relations management (CRM) you can create a record of contacts (individuals) and organizations (companies) and relate them to issues. @@ -20,6 +14,16 @@ With customer relations management (CRM) you can create a record of contacts You can use contacts and organizations to tie work to customers for billing and reporting purposes. To read more about what is planned for the future, see [issue 2256](https://gitlab.com/gitlab-org/gitlab/-/issues/2256). +## Enable customer relations management (CRM) + +To enable customer relations management in a group: + +1. On the top bar, select **Menu > Groups** and find your group. +1. On the left sidebar, select **Settings > General**. +1. Expand the **Permissions and group features** section. +1. Select **Enable customer relations**. +1. Select **Save changes**. + ## Contacts ### View contacts linked to a group diff --git a/doc/user/group/index.md b/doc/user/group/index.md index 323c0341bfc..4102b4ffbf1 100644 --- a/doc/user/group/index.md +++ b/doc/user/group/index.md @@ -123,7 +123,7 @@ your group. 1. Select **Your Groups**. 1. Find the group and select it. 1. From the left menu, select **Settings > General**. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. Clear the **Allow users to request access** checkbox. 1. Select **Save changes**. @@ -219,7 +219,7 @@ To change this setting for a specific group: 1. Select **Your Groups**. 1. Find the group and select it. 1. From the left menu, select **Settings > General**. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. Select the desired option in the **Default branch protection** dropdown list. 1. Select **Save changes**. @@ -250,7 +250,7 @@ To change this setting for a specific group: 1. Select **Your Groups**. 1. Find the group and select it. 1. From the left menu, select **Settings > General**. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. Select the desired option in the **Allowed to create projects** dropdown list. 1. Select **Save changes**. @@ -489,7 +489,7 @@ If you select this setting in the **Animals** group: To prevent sharing outside of the group's hierarchy: 1. Go to the group's **Settings > General** page. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. Select **Prevent members from sending invitations to groups outside of `` and its subgroups**. 1. Select **Save changes**. @@ -501,7 +501,7 @@ a project with another group](../project/members/share_project_with_groups.md) t To prevent a project from being shared with other groups: 1. Go to the group's **Settings > General** page. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. Select **Prevent sharing a project within `` with other groups**. 1. Select **Save changes**. @@ -523,7 +523,7 @@ The setting does not cascade. Projects in subgroups observe the subgroup configu To prevent members from being added to projects in a group: 1. Go to the group's **Settings > General** page. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. Under **Member lock**, select **Prevent adding new members to project membership within this group**. 1. Select **Save changes**. @@ -574,7 +574,7 @@ You should consider these security implications before configuring IP address re To restrict group access by IP address: 1. Go to the group's **Settings > General** page. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. In the **Allow access to the following IP addresses** field, enter IP address ranges in CIDR notation. 1. Select **Save changes**. @@ -591,7 +591,7 @@ You can prevent users with email addresses in specific domains from being added To restrict group access by domain: 1. Go to the group's **Settings > General** page. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. In the **Restrict membership by email** field, enter the domain names. 1. Select **Save changes**. @@ -645,7 +645,7 @@ You can disable all email notifications related to the group, which includes its To disable email notifications: 1. Go to the group's **Settings > General** page. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. Select **Disable email notifications**. 1. Select **Save changes**. @@ -663,7 +663,7 @@ This is particularly helpful for groups with a large number of users. To disable group mentions: 1. Go to the group's **Settings > General** page. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. Select **Disable group mentions**. 1. Select **Save changes**. @@ -688,7 +688,7 @@ the default setting. To enable delayed deletion of projects in a group: 1. Go to the group's **Settings > General** page. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. Check **Enable delayed project deletion**. 1. Optional. To prevent subgroups from changing this setting, select **Enforce for all subgroups**. 1. Select **Save changes**. @@ -713,7 +713,7 @@ If even one is set to `true`, then the group does not allow outside forks. To prevent projects from being forked outside the group: 1. Go to the top-level group's **Settings > General** page. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. Check **Prevent project forking outside current group**. 1. Select **Save changes**. diff --git a/doc/user/group/saml_sso/group_managed_accounts.md b/doc/user/group/saml_sso/group_managed_accounts.md index 09a3b5b7e21..06e666f4d24 100644 --- a/doc/user/group/saml_sso/group_managed_accounts.md +++ b/doc/user/group/saml_sso/group_managed_accounts.md @@ -113,7 +113,7 @@ on the lifetime of personal access tokens apply. To set a limit on how long personal access tokens are valid for users in a group managed account: 1. Navigate to the **Settings > General** page in your group's sidebar. -1. Expand the **Permissions, LFS, 2FA** section. +1. Expand the **Permissions and group features** section. 1. Fill in the **Maximum allowable lifetime for personal access tokens (days)** field. 1. Click **Save changes**. diff --git a/doc/user/group/subgroups/index.md b/doc/user/group/subgroups/index.md index 822e9bc945f..ef984a76a7d 100644 --- a/doc/user/group/subgroups/index.md +++ b/doc/user/group/subgroups/index.md @@ -101,7 +101,7 @@ You can change this setting: - As group owner: 1. Select the group. 1. On the left sidebar, select **Settings > General**. - 1. Expand **Permissions, LFS, 2FA**. + 1. Expand **Permissions and group features**. - As an administrator: 1. On the top bar, select **Menu > Admin**. 1. On the left sidebar, select **Overview > Groups**. diff --git a/doc/user/project/settings/project_access_tokens.md b/doc/user/project/settings/project_access_tokens.md index eb6e363c2ab..e401e20d85e 100644 --- a/doc/user/project/settings/project_access_tokens.md +++ b/doc/user/project/settings/project_access_tokens.md @@ -78,7 +78,7 @@ To enable or disable project access token creation for all projects in a top-lev 1. On the top bar, select **Menu > Groups** and find your group. 1. On the left sidebar, select **Settings > General**. -1. Expand **Permissions, LFS, 2FA**. +1. Expand **Permissions and group features**. 1. Under **Permissions**, turn on or off **Allow project access token creation**. Even when creation is disabled, you can still use and revoke existing project access tokens. diff --git a/lib/api/entities/resource_access_token.rb b/lib/api/entities/resource_access_token.rb index a1c7b28af45..d16baed38f0 100644 --- a/lib/api/entities/resource_access_token.rb +++ b/lib/api/entities/resource_access_token.rb @@ -4,7 +4,7 @@ module API module Entities class ResourceAccessToken < Entities::PersonalAccessToken expose :access_level do |token, options| - options[:project].project_member(token.user).access_level + options[:resource].resource_member(token.user).access_level end end end diff --git a/lib/api/resource_access_tokens.rb b/lib/api/resource_access_tokens.rb index f42acc6b2eb..e52f8fd9111 100644 --- a/lib/api/resource_access_tokens.rb +++ b/lib/api/resource_access_tokens.rb @@ -8,7 +8,7 @@ module API feature_category :authentication_and_authorization - %w[project].each do |source_type| + %w[project group].each do |source_type| resource source_type.pluralize, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get list of all access tokens for the specified resource' do detail 'This feature was introduced in GitLab 13.9.' @@ -23,8 +23,8 @@ module API tokens = PersonalAccessTokensFinder.new({ user: resource.bots, impersonation: false }).execute.preload_users - resource.project_members.load - present paginate(tokens), with: Entities::ResourceAccessToken, project: resource + resource.members.load + present paginate(tokens), with: Entities::ResourceAccessToken, resource: resource end desc 'Revoke a resource access token' do @@ -58,7 +58,7 @@ module API requires :id, type: String, desc: "The #{source_type} ID" requires :name, type: String, desc: "Resource access token name" requires :scopes, type: Array[String], desc: "The permissions of the token" - optional :access_level, type: Integer, desc: "The access level of the token in the project" + optional :access_level, type: Integer, desc: "The access level of the token in the #{source_type}" optional :expires_at, type: Date, desc: "The expiration date of the token" end post ':id/access_tokens' do @@ -71,7 +71,7 @@ module API ).execute if token_response.success? - present token_response.payload[:access_token], with: Entities::ResourceAccessTokenWithToken, project: resource + present token_response.payload[:access_token], with: Entities::ResourceAccessTokenWithToken, resource: resource else bad_request!(token_response.message) end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f58d0924e99..f2162598bc9 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -7386,15 +7386,6 @@ msgstr "" msgid "Click to reveal" msgstr "" -msgid "Client authentication certificate" -msgstr "" - -msgid "Client authentication key" -msgstr "" - -msgid "Client authentication key password" -msgstr "" - msgid "Client request timeout" msgstr "" @@ -9034,7 +9025,7 @@ msgstr "" msgid "Configure a %{codeStart}.gitlab-webide.yml%{codeEnd} file in the %{codeStart}.gitlab%{codeEnd} directory to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}" msgstr "" -msgid "Configure advanced permissions, Large File Storage, and two-factor authentication settings." +msgid "Configure advanced permissions, Large File Storage, two-factor authentication, and customer relations settings." msgstr "" msgid "Configure existing installation" @@ -11335,9 +11326,6 @@ msgstr "" msgid "Default branch protection" msgstr "" -msgid "Default classification label" -msgstr "" - msgid "Default delayed project deletion" msgstr "" @@ -13207,9 +13195,6 @@ msgstr "" msgid "Enable automatic repository housekeeping" msgstr "" -msgid "Enable classification control using an external service" -msgstr "" - msgid "Enable container expiration and retention policies for projects created earlier than GitLab 12.7." msgstr "" @@ -14492,9 +14477,6 @@ msgstr "" msgid "Exported requirements" msgstr "" -msgid "External Classification Policy Authorization" -msgstr "" - msgid "External ID" msgstr "" @@ -14504,15 +14486,9 @@ msgstr "" msgid "External User:" msgstr "" -msgid "External authentication" -msgstr "" - msgid "External authorization denied access to this project" msgstr "" -msgid "External authorization request timeout" -msgstr "" - msgid "External storage URL" msgstr "" @@ -14528,6 +14504,54 @@ msgstr "" msgid "ExternalAuthorizationService|When no classification label is set the default label `%{default_label}` will be used." msgstr "" +msgid "ExternalAuthorization|Access to projects is validated on an external service using their classification label." +msgstr "" + +msgid "ExternalAuthorization|Certificate used to authenticate with the external authorization service. If blank, the server certificate is validated when accessing over HTTPS." +msgstr "" + +msgid "ExternalAuthorization|Classification label to use when requesting authorization if no specific label is defined on the project." +msgstr "" + +msgid "ExternalAuthorization|Client authorization certificate" +msgstr "" + +msgid "ExternalAuthorization|Client authorization key" +msgstr "" + +msgid "ExternalAuthorization|Client authorization key password (optional)" +msgstr "" + +msgid "ExternalAuthorization|Default classification label" +msgstr "" + +msgid "ExternalAuthorization|Enable classification control using an external service" +msgstr "" + +msgid "ExternalAuthorization|External authorization" +msgstr "" + +msgid "ExternalAuthorization|External authorization request timeout (seconds)" +msgstr "" + +msgid "ExternalAuthorization|External classification policy authorization." +msgstr "" + +msgid "ExternalAuthorization|Passphrase required to decrypt the private key. Encrypted when stored." +msgstr "" + +msgid "ExternalAuthorization|Period GitLab waits for a response from the external service. If there is no response, access is denied. Default: 0.5 seconds." +msgstr "" + +msgid "ExternalAuthorization|Private key of client authentication certificate. Encrypted when stored." +msgstr "" + +msgid "ExternalAuthorization|Service URL" +msgstr "" + +msgid "ExternalAuthorization|URL to which the projects make authorization requests. If the URL is blank, cross-project features are available and can still specify classification labels for projects." +msgstr "" + msgid "ExternalIssueIntegration|Not all data may be displayed here. To view more details or make changes to this issue, go to %{linkStart}%{trackerName}%{linkEnd}." msgstr "" @@ -17078,6 +17102,9 @@ msgstr "" msgid "GroupSettings|Allow project access token creation" msgstr "" +msgid "GroupSettings|Allows creating organizations and contacts and associating them with issues." +msgstr "" + msgid "GroupSettings|Applied to all subgroups unless overridden by a group owner. Groups already added to the project lose access." msgstr "" @@ -17123,6 +17150,9 @@ msgstr "" msgid "GroupSettings|Disable group mentions" msgstr "" +msgid "GroupSettings|Enable customer relations" +msgstr "" + msgid "GroupSettings|Enable delayed project deletion" msgstr "" @@ -17823,9 +17853,6 @@ msgstr "" msgid "If enabled, GitLab will handle Object Storage replication using Geo. %{linkStart}Learn more%{linkEnd}" msgstr "" -msgid "If enabled, access to projects will be validated on an external service using their classification label." -msgstr "" - msgid "If enabled, only protected branches will be mirrored." msgstr "" @@ -25810,7 +25837,7 @@ msgstr "" msgid "Permissions Help" msgstr "" -msgid "Permissions, LFS, 2FA" +msgid "Permissions and group features" msgstr "" msgid "Personal Access Token" @@ -32377,9 +32404,6 @@ msgstr "" msgid "Service Desk allows people to create issues in your GitLab instance without their own user account. It provides a unique email address for end users to create issues in a project. Replies can be sent either through the GitLab interface or by email. End users only see threads through email." msgstr "" -msgid "Service URL" -msgstr "" - msgid "Service account generated successfully" msgstr "" @@ -35268,9 +35292,6 @@ msgstr "" msgid "The URLs for connecting to Elasticsearch. For clustering, add the URLs separated by commas." msgstr "" -msgid "The X509 Certificate to use when mutual TLS is required to communicate with the external authorization service. If left blank, the server certificate is still validated when accessing over HTTPS." -msgstr "" - msgid "The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential." msgstr "" @@ -35546,9 +35567,6 @@ msgstr "" msgid "The parent epic is confidential and can only contain confidential epics and issues" msgstr "" -msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest." -msgstr "" - msgid "The password for the Jenkins server." msgstr "" @@ -35564,9 +35582,6 @@ msgstr "" msgid "The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user." msgstr "" -msgid "The private key to use when a client certificate is provided. This value is encrypted at rest." -msgstr "" - msgid "The project can be accessed by any logged in user except external users." msgstr "" @@ -36785,9 +36800,6 @@ msgstr "" msgid "Time in seconds" msgstr "" -msgid "Time in seconds GitLab will wait for a response from the external service. When the service does not respond in time, access will be denied." -msgstr "" - msgid "Time of import: %{importTime}" msgstr "" @@ -40028,9 +40040,6 @@ msgstr "" msgid "When inactive, an external authentication provider must be used." msgstr "" -msgid "When leaving the URL blank, classification labels can still be specified without disabling cross project features or performing external authorization checks." -msgstr "" - msgid "When merge requests and commits in the default branch close, any issues they reference also close." msgstr "" diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb index 859f381e4c1..152ae061605 100644 --- a/spec/factories/groups.rb +++ b/spec/factories/groups.rb @@ -112,5 +112,11 @@ FactoryBot.define do ) end end + + trait :crm_enabled do + after(:create) do |group| + create(:crm_settings, group: group, enabled: true) + end + end end end diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb index da8032dc4dd..c5d2f5e6733 100644 --- a/spec/features/groups/navbar_spec.rb +++ b/spec/features/groups/navbar_spec.rb @@ -9,7 +9,8 @@ RSpec.describe 'Group navbar' do include_context 'group navbar structure' let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + + let(:group) { create(:group) } before do insert_package_nav(_('Kubernetes')) @@ -40,7 +41,9 @@ RSpec.describe 'Group navbar' do it_behaves_like 'verified navigation bar' end - context 'when customer_relations feature flag is enabled' do + context 'when customer_relations feature and flag is enabled' do + let(:group) { create(:group, :crm_enabled) } + before do stub_feature_flags(customer_relations: true) diff --git a/spec/features/issues/user_bulk_edits_issues_spec.rb b/spec/features/issues/user_bulk_edits_issues_spec.rb index 44c23813e3c..625303f89e4 100644 --- a/spec/features/issues/user_bulk_edits_issues_spec.rb +++ b/spec/features/issues/user_bulk_edits_issues_spec.rb @@ -104,6 +104,26 @@ RSpec.describe 'Multiple issue updating from issues#index', :js do end end + describe 'select all issues' do + let!(:issue_2) { create(:issue, project: project) } + + before do + stub_feature_flags(vue_issues_list: true) + end + + it 'after selecting all issues, unchecking one issue only unselects that one issue' do + visit project_issues_path(project) + + click_button 'Edit issues' + check 'Select all' + uncheck issue.title + + expect(page).to have_unchecked_field 'Select all' + expect(page).to have_unchecked_field issue.title + expect(page).to have_checked_field issue_2.title + end + end + def create_closed create(:issue, project: project, state: :closed) end diff --git a/spec/graphql/mutations/customer_relations/contacts/create_spec.rb b/spec/graphql/mutations/customer_relations/contacts/create_spec.rb index 0f05504d4f2..d17d11305b1 100644 --- a/spec/graphql/mutations/customer_relations/contacts/create_spec.rb +++ b/spec/graphql/mutations/customer_relations/contacts/create_spec.rb @@ -4,8 +4,8 @@ require 'spec_helper' RSpec.describe Mutations::CustomerRelations::Contacts::Create do let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + let(:group) { create(:group, :crm_enabled) } let(:not_found_or_does_not_belong) { 'The specified organization was not found or does not belong to this group' } let(:valid_params) do attributes_for(:contact, @@ -34,11 +34,11 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Create do end context 'when the user has permission' do - before_all do + before do group.add_developer(user) end - context 'when the feature is disabled' do + context 'when the feature flag is disabled' do before do stub_feature_flags(customer_relations: false) end @@ -49,6 +49,15 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Create do end end + context 'when crm_enabled is false' do + let(:group) { create(:group) } + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") + end + end + context 'when the params are invalid' do it 'returns the validation error' do valid_params[:first_name] = nil diff --git a/spec/graphql/mutations/customer_relations/contacts/update_spec.rb b/spec/graphql/mutations/customer_relations/contacts/update_spec.rb index 4f59de194fd..c8206eca442 100644 --- a/spec/graphql/mutations/customer_relations/contacts/update_spec.rb +++ b/spec/graphql/mutations/customer_relations/contacts/update_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Mutations::CustomerRelations::Contacts::Update do let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let(:first_name) { 'Lionel' } let(:last_name) { 'Smith' } diff --git a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb index 9be0f5d4289..ee78d2b16f6 100644 --- a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb +++ b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Mutations::CustomerRelations::Organizations::Create do let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let(:valid_params) do attributes_for(:organization, diff --git a/spec/graphql/mutations/customer_relations/organizations/update_spec.rb b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb index e3aa8eafe0c..90fd7a0a9f1 100644 --- a/spec/graphql/mutations/customer_relations/organizations/update_spec.rb +++ b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Mutations::CustomerRelations::Organizations::Update do let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let(:name) { 'GitLab' } let(:default_rate) { 1000.to_f } @@ -56,7 +56,7 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do expect(resolve_mutation[:organization]).to have_attributes(attributes) end - context 'when the feature is disabled' do + context 'when the feature flag is disabled' do before do stub_feature_flags(customer_relations: false) end @@ -66,6 +66,15 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") end end + + context 'when the feature is disabled' do + let_it_be(:group) { create(:group) } + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") + end + end end end diff --git a/spec/helpers/groups/crm_settings_helper_spec.rb b/spec/helpers/groups/crm_settings_helper_spec.rb new file mode 100644 index 00000000000..6376cabda3a --- /dev/null +++ b/spec/helpers/groups/crm_settings_helper_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::CrmSettingsHelper do + let_it_be(:group) { create(:group) } + + describe '#crm_feature_flag_enabled?' do + subject do + helper.crm_feature_flag_enabled?(group) + end + + context 'when feature flag is enabled' do + it { is_expected.to be_truthy } + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(customer_relations: false) + end + + it { is_expected.to be_falsy } + end + end +end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 84b125964c2..6a1cac898fa 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -2086,6 +2086,23 @@ RSpec.describe Group do end end + describe '#bots' do + subject { group.bots } + + let_it_be(:group) { create(:group) } + let_it_be(:project_bot) { create(:user, :project_bot) } + let_it_be(:user) { create(:user) } + + before_all do + [project_bot, user].each do |member| + group.add_maintainer(member) + end + end + + it { is_expected.to contain_exactly(project_bot) } + it { is_expected.not_to include(user) } + end + describe '#related_group_ids' do let(:nested_group) { create(:group, parent: group) } let(:shared_with_group) { create(:group, parent: group) } diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index 7822ee2b92e..08fc8d2e77c 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -6,7 +6,7 @@ RSpec.describe GroupPolicy do include_context 'GroupPolicy context' context 'public group with no user' do - let(:group) { create(:group, :public) } + let(:group) { create(:group, :public, :crm_enabled) } let(:current_user) { nil } it do @@ -975,7 +975,7 @@ RSpec.describe GroupPolicy do it { expect_disallowed(:read_label) } context 'when group hierarchy has a project with service desk enabled' do - let_it_be(:subgroup) { create(:group, :private, parent: group)} + let_it_be(:subgroup) { create(:group, :private, parent: group) } let_it_be(:project) { create(:project, group: subgroup, service_desk_enabled: true) } it { expect_allowed(:read_label) } @@ -983,6 +983,49 @@ RSpec.describe GroupPolicy do end end + context "project bots" do + let(:project_bot) { create(:user, :project_bot) } + let(:user) { create(:user) } + + context "project_bot_access" do + context "when regular user and part of the group" do + let(:current_user) { user } + + before do + group.add_developer(user) + end + + it { is_expected.not_to be_allowed(:project_bot_access) } + end + + context "when project bot and not part of the project" do + let(:current_user) { project_bot } + + it { is_expected.not_to be_allowed(:project_bot_access) } + end + + context "when project bot and part of the project" do + let(:current_user) { project_bot } + + before do + group.add_developer(project_bot) + end + + it { is_expected.to be_allowed(:project_bot_access) } + end + end + + context 'with resource access tokens' do + let(:current_user) { project_bot } + + before do + group.add_maintainer(project_bot) + end + + it { is_expected.not_to be_allowed(:create_resource_access_tokens) } + end + end + describe 'update_runners_registration_token' do context 'admin' do let(:current_user) { admin } @@ -1113,7 +1156,7 @@ RSpec.describe GroupPolicy do end end - context 'with customer_relations feature flag disabled' do + context 'with customer relations feature flag disabled' do let(:current_user) { owner } before do @@ -1125,4 +1168,14 @@ RSpec.describe GroupPolicy do it { is_expected.to be_disallowed(:admin_crm_contact) } it { is_expected.to be_disallowed(:admin_crm_organization) } end + + context 'when crm_enabled is false' do + let(:group) { create(:group) } + let(:current_user) { owner } + + it { is_expected.to be_disallowed(:read_crm_contact) } + it { is_expected.to be_disallowed(:read_crm_organization) } + it { is_expected.to be_disallowed(:admin_crm_contact) } + it { is_expected.to be_disallowed(:admin_crm_organization) } + end end diff --git a/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb b/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb index 2da69509ad6..62bb665274d 100644 --- a/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb @@ -6,7 +6,7 @@ RSpec.describe 'Setting issues crm contacts' do include GraphqlHelpers let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let_it_be(:project) { create(:project, group: group) } let_it_be(:contacts) { create_list(:contact, 4, group: group) } @@ -42,120 +42,134 @@ RSpec.describe 'Setting issues crm contacts' do graphql_mutation_response(:issue_set_crm_contacts) end - before do - create(:issue_customer_relations_contact, issue: issue, contact: contacts[0]) - create(:issue_customer_relations_contact, issue: issue, contact: contacts[1]) + context 'when the feature is enabled' do + before do + create(:issue_customer_relations_contact, issue: issue, contact: contacts[0]) + create(:issue_customer_relations_contact, issue: issue, contact: contacts[1]) + end + + context 'when the user has no permission' do + it 'returns expected error' do + error = Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_errors).to include(a_hash_including('message' => error)) + end + end + + context 'when the user has permission' do + before do + group.add_reporter(user) + end + + context 'when the feature is disabled' do + before do + stub_feature_flags(customer_relations: false) + end + + it 'raises expected error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_errors).to include(a_hash_including('message' => 'Feature disabled')) + end + end + + context 'replace' do + it 'updates the issue with correct contacts' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) + .to match_array([global_id_of(contacts[1]), global_id_of(contacts[2])]) + end + end + + context 'append' do + let(:contact_ids) { [global_id_of(contacts[3])] } + let(:operation_mode) { Types::MutationOperationModeEnum.enum[:append] } + + it 'updates the issue with correct contacts' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) + .to match_array([global_id_of(contacts[0]), global_id_of(contacts[1]), global_id_of(contacts[3])]) + end + end + + context 'remove' do + let(:contact_ids) { [global_id_of(contacts[0])] } + let(:operation_mode) { Types::MutationOperationModeEnum.enum[:remove] } + + it 'updates the issue with correct contacts' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) + .to match_array([global_id_of(contacts[1])]) + end + end + + context 'when the contact does not exist' do + let(:contact_ids) { ["gid://gitlab/CustomerRelations::Contact/#{non_existing_record_id}"] } + + it 'returns expected error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :errors)) + .to match_array(["Issue customer relations contacts #{non_existing_record_id}: #{does_not_exist_or_no_permission}"]) + end + end + + context 'when the contact belongs to a different group' do + let(:group2) { create(:group, :crm_enabled) } + let(:contact) { create(:contact, group: group2) } + let(:contact_ids) { [global_id_of(contact)] } + + before do + group2.add_reporter(user) + end + + it 'returns expected error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :errors)) + .to match_array(["Issue customer relations contacts #{contact.id}: #{does_not_exist_or_no_permission}"]) + end + end + + context 'when attempting to add more than 6' do + let(:operation_mode) { Types::MutationOperationModeEnum.enum[:append] } + let(:gid) { global_id_of(contacts[0]) } + let(:contact_ids) { [gid, gid, gid, gid, gid, gid, gid] } + + it 'returns expected error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :errors)) + .to match_array(["You can only add up to 6 contacts at one time"]) + end + end + + context 'when trying to remove non-existent contact' do + let(:operation_mode) { Types::MutationOperationModeEnum.enum[:remove] } + let(:contact_ids) { ["gid://gitlab/CustomerRelations::Contact/#{non_existing_record_id}"] } + + it 'raises expected error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :errors)).to be_empty + end + end + end end - context 'when the user has no permission' do - it 'returns expected error' do - error = Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR + context 'when crm_enabled is false' do + let(:issue) { create(:issue) } + + it 'raises expected error' do + issue.project.add_reporter(user) + post_graphql_mutation(mutation, current_user: user) - expect(graphql_errors).to include(a_hash_including('message' => error)) - end - end - - context 'when the user has permission' do - before do - group.add_reporter(user) - end - - context 'when the feature is disabled' do - before do - stub_feature_flags(customer_relations: false) - end - - it 'raises expected error' do - post_graphql_mutation(mutation, current_user: user) - - expect(graphql_errors).to include(a_hash_including('message' => 'Feature disabled')) - end - end - - context 'replace' do - it 'updates the issue with correct contacts' do - post_graphql_mutation(mutation, current_user: user) - - expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) - .to match_array([global_id_of(contacts[1]), global_id_of(contacts[2])]) - end - end - - context 'append' do - let(:contact_ids) { [global_id_of(contacts[3])] } - let(:operation_mode) { Types::MutationOperationModeEnum.enum[:append] } - - it 'updates the issue with correct contacts' do - post_graphql_mutation(mutation, current_user: user) - - expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) - .to match_array([global_id_of(contacts[0]), global_id_of(contacts[1]), global_id_of(contacts[3])]) - end - end - - context 'remove' do - let(:contact_ids) { [global_id_of(contacts[0])] } - let(:operation_mode) { Types::MutationOperationModeEnum.enum[:remove] } - - it 'updates the issue with correct contacts' do - post_graphql_mutation(mutation, current_user: user) - - expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) - .to match_array([global_id_of(contacts[1])]) - end - end - - context 'when the contact does not exist' do - let(:contact_ids) { ["gid://gitlab/CustomerRelations::Contact/#{non_existing_record_id}"] } - - it 'returns expected error' do - post_graphql_mutation(mutation, current_user: user) - - expect(graphql_data_at(:issue_set_crm_contacts, :errors)) - .to match_array(["Issue customer relations contacts #{non_existing_record_id}: #{does_not_exist_or_no_permission}"]) - end - end - - context 'when the contact belongs to a different group' do - let(:group2) { create(:group) } - let(:contact) { create(:contact, group: group2) } - let(:contact_ids) { [global_id_of(contact)] } - - before do - group2.add_reporter(user) - end - - it 'returns expected error' do - post_graphql_mutation(mutation, current_user: user) - - expect(graphql_data_at(:issue_set_crm_contacts, :errors)) - .to match_array(["Issue customer relations contacts #{contact.id}: #{does_not_exist_or_no_permission}"]) - end - end - - context 'when attempting to add more than 6' do - let(:operation_mode) { Types::MutationOperationModeEnum.enum[:append] } - let(:gid) { global_id_of(contacts[0]) } - let(:contact_ids) { [gid, gid, gid, gid, gid, gid, gid] } - - it 'returns expected error' do - post_graphql_mutation(mutation, current_user: user) - - expect(graphql_data_at(:issue_set_crm_contacts, :errors)) - .to match_array(["You can only add up to 6 contacts at one time"]) - end - end - - context 'when trying to remove non-existent contact' do - let(:operation_mode) { Types::MutationOperationModeEnum.enum[:remove] } - let(:contact_ids) { ["gid://gitlab/CustomerRelations::Contact/#{non_existing_record_id}"] } - - it 'raises expected error' do - post_graphql_mutation(mutation, current_user: user) - - expect(graphql_data_at(:issue_set_crm_contacts, :errors)).to be_empty - end + expect(graphql_errors).to include(a_hash_including('message' => 'Feature disabled')) end end end diff --git a/spec/requests/api/resource_access_tokens_spec.rb b/spec/requests/api/resource_access_tokens_spec.rb index 23061ab4bf0..7e3e682767f 100644 --- a/spec/requests/api/resource_access_tokens_spec.rb +++ b/spec/requests/api/resource_access_tokens_spec.rb @@ -3,25 +3,27 @@ require "spec_helper" RSpec.describe API::ResourceAccessTokens do - context "when the resource is a project" do - let_it_be(:project) { create(:project) } - let_it_be(:other_project) { create(:project) } - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user) } + let_it_be(:user_non_priviledged) { create(:user) } - describe "GET projects/:id/access_tokens" do - subject(:get_tokens) { get api("/projects/#{project_id}/access_tokens", user) } + shared_examples 'resource access token API' do |source_type| + context "GET #{source_type}s/:id/access_tokens" do + subject(:get_tokens) { get api("/#{source_type}s/#{resource_id}/access_tokens", user) } - context "when the user has maintainer permissions" do + context "when the user has valid permissions" do let_it_be(:project_bot) { create(:user, :project_bot) } let_it_be(:access_tokens) { create_list(:personal_access_token, 3, user: project_bot) } - let_it_be(:project_id) { project.id } + let_it_be(:resource_id) { resource.id } before do - project.add_maintainer(user) - project.add_maintainer(project_bot) + if source_type == 'project' + resource.add_maintainer(project_bot) + else + resource.add_owner(project_bot) + end end - it "gets a list of access tokens for the specified project" do + it "gets a list of access tokens for the specified #{source_type}" do get_tokens token_ids = json_response.map { |token| token['id'] } @@ -38,16 +40,22 @@ RSpec.describe API::ResourceAccessTokens do expect(api_get_token["name"]).to eq(token.name) expect(api_get_token["scopes"]).to eq(token.scopes) - expect(api_get_token["access_level"]).to eq(project.team.max_member_access(token.user.id)) + + if source_type == 'project' + expect(api_get_token["access_level"]).to eq(resource.team.max_member_access(token.user.id)) + else + expect(api_get_token["access_level"]).to eq(resource.max_member_access_for_user(token.user)) + end + expect(api_get_token["expires_at"]).to eq(token.expires_at.to_date.iso8601) expect(api_get_token).not_to have_key('token') end - context "when using a project access token to GET other project access tokens" do + context "when using a #{source_type} access token to GET other #{source_type} access tokens" do let_it_be(:token) { access_tokens.first } - it "gets a list of access tokens for the specified project" do - get api("/projects/#{project_id}/access_tokens", personal_access_token: token) + it "gets a list of access tokens for the specified #{source_type}" do + get api("/#{source_type}s/#{resource_id}/access_tokens", personal_access_token: token) token_ids = json_response.map { |token| token['id'] } @@ -56,16 +64,15 @@ RSpec.describe API::ResourceAccessTokens do end end - context "when tokens belong to a different project" do + context "when tokens belong to a different #{source_type}" do let_it_be(:bot) { create(:user, :project_bot) } let_it_be(:token) { create(:personal_access_token, user: bot) } before do - other_project.add_maintainer(bot) - other_project.add_maintainer(user) + other_resource.add_maintainer(bot) end - it "does not return tokens from a different project" do + it "does not return tokens from a different #{source_type}" do get_tokens token_ids = json_response.map { |token| token['id'] } @@ -74,12 +81,8 @@ RSpec.describe API::ResourceAccessTokens do end end - context "when the project has no access tokens" do - let(:project_id) { other_project.id } - - before do - other_project.add_maintainer(user) - end + context "when the #{source_type} has no access tokens" do + let(:resource_id) { other_resource.id } it 'returns an empty array' do get_tokens @@ -89,8 +92,8 @@ RSpec.describe API::ResourceAccessTokens do end end - context "when trying to get the tokens of a different project" do - let_it_be(:project_id) { other_project.id } + context "when trying to get the tokens of a different #{source_type}" do + let_it_be(:resource_id) { unknown_resource.id } it "returns 404" do get_tokens @@ -99,8 +102,8 @@ RSpec.describe API::ResourceAccessTokens do end end - context "when the project does not exist" do - let(:project_id) { non_existing_record_id } + context "when the #{source_type} does not exist" do + let(:resource_id) { non_existing_record_id } it "returns 404" do get_tokens @@ -111,13 +114,13 @@ RSpec.describe API::ResourceAccessTokens do end context "when the user does not have valid permissions" do + let_it_be(:user) { user_non_priviledged } let_it_be(:project_bot) { create(:user, :project_bot) } let_it_be(:access_tokens) { create_list(:personal_access_token, 3, user: project_bot) } - let_it_be(:project_id) { project.id } + let_it_be(:resource_id) { resource.id } before do - project.add_developer(user) - project.add_maintainer(project_bot) + resource.add_maintainer(project_bot) end it "returns 401" do @@ -128,40 +131,36 @@ RSpec.describe API::ResourceAccessTokens do end end - describe "DELETE projects/:id/access_tokens/:token_id", :sidekiq_inline do - subject(:delete_token) { delete api("/projects/#{project_id}/access_tokens/#{token_id}", user) } + context "DELETE #{source_type}s/:id/access_tokens/:token_id", :sidekiq_inline do + subject(:delete_token) { delete api("/#{source_type}s/#{resource_id}/access_tokens/#{token_id}", user) } let_it_be(:project_bot) { create(:user, :project_bot) } let_it_be(:token) { create(:personal_access_token, user: project_bot) } - let_it_be(:project_id) { project.id } + let_it_be(:resource_id) { resource.id } let_it_be(:token_id) { token.id } before do - project.add_maintainer(project_bot) + resource.add_maintainer(project_bot) end - context "when the user has maintainer permissions" do - before do - project.add_maintainer(user) - end - - it "deletes the project access token from the project" do + context "when the user has valid permissions" do + it "deletes the #{source_type} access token from the #{source_type}" do delete_token expect(response).to have_gitlab_http_status(:no_content) expect(User.exists?(project_bot.id)).to be_falsy end - context "when using project access token to DELETE other project access token" do + context "when using #{source_type} access token to DELETE other #{source_type} access token" do let_it_be(:other_project_bot) { create(:user, :project_bot) } let_it_be(:other_token) { create(:personal_access_token, user: other_project_bot) } let_it_be(:token_id) { other_token.id } before do - project.add_maintainer(other_project_bot) + resource.add_maintainer(other_project_bot) end - it "deletes the project access token from the project" do + it "deletes the #{source_type} access token from the #{source_type}" do delete_token expect(response).to have_gitlab_http_status(:no_content) @@ -169,37 +168,31 @@ RSpec.describe API::ResourceAccessTokens do end end - context "when attempting to delete a non-existent project access token" do + context "when attempting to delete a non-existent #{source_type} access token" do let_it_be(:token_id) { non_existing_record_id } it "does not delete the token, and returns 404" do delete_token expect(response).to have_gitlab_http_status(:not_found) - expect(response.body).to include("Could not find project access token with token_id: #{token_id}") + expect(response.body).to include("Could not find #{source_type} access token with token_id: #{token_id}") end end - context "when attempting to delete a token that does not belong to the specified project" do - let_it_be(:project_id) { other_project.id } - - before do - other_project.add_maintainer(user) - end + context "when attempting to delete a token that does not belong to the specified #{source_type}" do + let_it_be(:resource_id) { other_resource.id } it "does not delete the token, and returns 404" do delete_token expect(response).to have_gitlab_http_status(:not_found) - expect(response.body).to include("Could not find project access token with token_id: #{token_id}") + expect(response.body).to include("Could not find #{source_type} access token with token_id: #{token_id}") end end end context "when the user does not have valid permissions" do - before do - project.add_developer(user) - end + let_it_be(:user) { user_non_priviledged } it "does not delete the token, and returns 400", :aggregate_failures do delete_token @@ -211,23 +204,19 @@ RSpec.describe API::ResourceAccessTokens do end end - describe "POST projects/:id/access_tokens" do + context "POST #{source_type}s/:id/access_tokens" do let(:params) { { name: "test", scopes: ["api"], expires_at: expires_at, access_level: access_level } } let(:expires_at) { 1.month.from_now } let(:access_level) { 20 } - subject(:create_token) { post api("/projects/#{project_id}/access_tokens", user), params: params } + subject(:create_token) { post api("/#{source_type}s/#{resource_id}/access_tokens", user), params: params } - context "when the user has maintainer permissions" do - let_it_be(:project_id) { project.id } - - before do - project.add_maintainer(user) - end + context "when the user has valid permissions" do + let_it_be(:resource_id) { resource.id } context "with valid params" do context "with full params" do - it "creates a project access token with the params", :aggregate_failures do + it "creates a #{source_type} access token with the params", :aggregate_failures do create_token expect(response).to have_gitlab_http_status(:created) @@ -242,7 +231,7 @@ RSpec.describe API::ResourceAccessTokens do context "when 'expires_at' is not set" do let(:expires_at) { nil } - it "creates a project access token with the params", :aggregate_failures do + it "creates a #{source_type} access token with the params", :aggregate_failures do create_token expect(response).to have_gitlab_http_status(:created) @@ -255,7 +244,7 @@ RSpec.describe API::ResourceAccessTokens do context "when 'access_level' is not set" do let(:access_level) { nil } - it 'creates a project access token with the default access level', :aggregate_failures do + it "creates a #{source_type} access token with the default access level", :aggregate_failures do create_token expect(response).to have_gitlab_http_status(:created) @@ -272,7 +261,7 @@ RSpec.describe API::ResourceAccessTokens do context "when missing the 'name' param" do let_it_be(:params) { { scopes: ["api"], expires_at: 5.days.from_now } } - it "does not create a project access token without 'name'" do + it "does not create a #{source_type} access token without 'name'" do create_token expect(response).to have_gitlab_http_status(:bad_request) @@ -283,7 +272,7 @@ RSpec.describe API::ResourceAccessTokens do context "when missing the 'scopes' param" do let_it_be(:params) { { name: "test", expires_at: 5.days.from_now } } - it "does not create a project access token without 'scopes'" do + it "does not create a #{source_type} access token without 'scopes'" do create_token expect(response).to have_gitlab_http_status(:bad_request) @@ -292,50 +281,80 @@ RSpec.describe API::ResourceAccessTokens do end end - context "when trying to create a token in a different project" do - let_it_be(:project_id) { other_project.id } + context "when trying to create a token in a different #{source_type}" do + let_it_be(:resource_id) { unknown_resource.id } - it "does not create the token, and returns the project not found error" do + it "does not create the token, and returns the #{source_type} not found error" do create_token expect(response).to have_gitlab_http_status(:not_found) - expect(response.body).to include("Project Not Found") + expect(response.body).to include("#{source_type.capitalize} Not Found") end end end context "when the user does not have valid permissions" do - let_it_be(:project_id) { project.id } + let_it_be(:resource_id) { resource.id } - context "when the user is a developer" do - before do - project.add_developer(user) - end + context "when the user role is too low" do + let_it_be(:user) { user_non_priviledged } it "does not create the token, and returns the permission error" do create_token expect(response).to have_gitlab_http_status(:bad_request) - expect(response.body).to include("User does not have permission to create project access token") + expect(response.body).to include("User does not have permission to create #{source_type} access token") end end - context "when a project access token tries to create another project access token" do + context "when a #{source_type} access token tries to create another #{source_type} access token" do let_it_be(:project_bot) { create(:user, :project_bot) } let_it_be(:user) { project_bot } before do - project.add_maintainer(user) + if source_type == 'project' + resource.add_maintainer(project_bot) + else + resource.add_owner(project_bot) + end end - it "does not allow a project access token to create another project access token" do + it "does not allow a #{source_type} access token to create another #{source_type} access token" do create_token expect(response).to have_gitlab_http_status(:bad_request) - expect(response.body).to include("User does not have permission to create project access token") + expect(response.body).to include("User does not have permission to create #{source_type} access token") end end end end end + + context 'when the resource is a project' do + let_it_be(:resource) { create(:project) } + let_it_be(:other_resource) { create(:project) } + let_it_be(:unknown_resource) { create(:project) } + + before_all do + resource.add_maintainer(user) + other_resource.add_maintainer(user) + resource.add_developer(user_non_priviledged) + end + + it_behaves_like 'resource access token API', 'project' + end + + context 'when the resource is a group' do + let_it_be(:resource) { create(:group) } + let_it_be(:other_resource) { create(:group) } + let_it_be(:unknown_resource) { create(:project) } + + before_all do + resource.add_owner(user) + other_resource.add_owner(user) + resource.add_maintainer(user_non_priviledged) + end + + it_behaves_like 'resource access token API', 'group' + end end diff --git a/spec/requests/groups/crm/contacts_controller_spec.rb b/spec/requests/groups/crm/contacts_controller_spec.rb index a4b2a28e77a..589834a07db 100644 --- a/spec/requests/groups/crm/contacts_controller_spec.rb +++ b/spec/requests/groups/crm/contacts_controller_spec.rb @@ -24,7 +24,7 @@ RSpec.describe Groups::Crm::ContactsController do shared_examples 'ok response with index template if authorized' do context 'private group' do - let(:group) { create(:group, :private) } + let(:group) { create(:group, :private, :crm_enabled) } context 'with authorized user' do before do @@ -32,11 +32,17 @@ RSpec.describe Groups::Crm::ContactsController do sign_in(user) end - context 'when feature flag is enabled' do + context 'when crm_enabled is true' do it_behaves_like 'ok response with index template' end - context 'when feature flag is not enabled' do + context 'when crm_enabled is false' do + let(:group) { create(:group, :private) } + + it_behaves_like 'response with 404 status' + end + + context 'when feature flag is disabled' do before do stub_feature_flags(customer_relations: false) end @@ -64,7 +70,7 @@ RSpec.describe Groups::Crm::ContactsController do end context 'public group' do - let(:group) { create(:group, :public) } + let(:group) { create(:group, :public, :crm_enabled) } context 'with anonymous user' do it_behaves_like 'ok response with index template' diff --git a/spec/requests/groups/crm/organizations_controller_spec.rb b/spec/requests/groups/crm/organizations_controller_spec.rb index 7595950350d..899f223cb79 100644 --- a/spec/requests/groups/crm/organizations_controller_spec.rb +++ b/spec/requests/groups/crm/organizations_controller_spec.rb @@ -24,7 +24,7 @@ RSpec.describe Groups::Crm::OrganizationsController do shared_examples 'ok response with index template if authorized' do context 'private group' do - let(:group) { create(:group, :private) } + let(:group) { create(:group, :private, :crm_enabled) } context 'with authorized user' do before do @@ -32,11 +32,17 @@ RSpec.describe Groups::Crm::OrganizationsController do sign_in(user) end - context 'when feature flag is enabled' do + context 'when crm_enabled is true' do it_behaves_like 'ok response with index template' end - context 'when feature flag is not enabled' do + context 'when crm_enabled is false' do + let(:group) { create(:group, :private) } + + it_behaves_like 'response with 404 status' + end + + context 'when feature flag is disabled' do before do stub_feature_flags(customer_relations: false) end @@ -64,7 +70,7 @@ RSpec.describe Groups::Crm::OrganizationsController do end context 'public group' do - let(:group) { create(:group, :public) } + let(:group) { create(:group, :public, :crm_enabled) } context 'with anonymous user' do it_behaves_like 'ok response with index template' diff --git a/spec/services/customer_relations/contacts/create_service_spec.rb b/spec/services/customer_relations/contacts/create_service_spec.rb index 71eb447055e..567e1c91e78 100644 --- a/spec/services/customer_relations/contacts/create_service_spec.rb +++ b/spec/services/customer_relations/contacts/create_service_spec.rb @@ -12,7 +12,7 @@ RSpec.describe CustomerRelations::Contacts::CreateService do subject(:response) { described_class.new(group: group, current_user: user, params: params).execute } context 'when user does not have permission' do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } before_all do group.add_reporter(user) @@ -25,7 +25,7 @@ RSpec.describe CustomerRelations::Contacts::CreateService do end context 'when user has permission' do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } before_all do group.add_developer(user) diff --git a/spec/services/customer_relations/contacts/update_service_spec.rb b/spec/services/customer_relations/contacts/update_service_spec.rb index 7c5fbabb600..253bbc23226 100644 --- a/spec/services/customer_relations/contacts/update_service_spec.rb +++ b/spec/services/customer_relations/contacts/update_service_spec.rb @@ -11,7 +11,7 @@ RSpec.describe CustomerRelations::Contacts::UpdateService do describe '#execute' do context 'when the user has no permission' do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let(:params) { { first_name: 'Gary' } } @@ -24,7 +24,7 @@ RSpec.describe CustomerRelations::Contacts::UpdateService do end context 'when user has permission' do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } before_all do group.add_developer(user) diff --git a/spec/services/customer_relations/organizations/create_service_spec.rb b/spec/services/customer_relations/organizations/create_service_spec.rb index d8985d8d90b..18eefdd716e 100644 --- a/spec/services/customer_relations/organizations/create_service_spec.rb +++ b/spec/services/customer_relations/organizations/create_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe CustomerRelations::Organizations::CreateService do describe '#execute' do let_it_be(:user) { create(:user) } - let(:group) { create(:group) } + let(:group) { create(:group, :crm_enabled) } let(:params) { attributes_for(:organization, group: group) } subject(:response) { described_class.new(group: group, current_user: user, params: params).execute } diff --git a/spec/services/customer_relations/organizations/update_service_spec.rb b/spec/services/customer_relations/organizations/update_service_spec.rb index bc40cb3e8e7..8461c98ef0e 100644 --- a/spec/services/customer_relations/organizations/update_service_spec.rb +++ b/spec/services/customer_relations/organizations/update_service_spec.rb @@ -11,7 +11,7 @@ RSpec.describe CustomerRelations::Organizations::UpdateService do describe '#execute' do context 'when the user has no permission' do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let(:params) { { name: 'GitLab' } } @@ -24,7 +24,7 @@ RSpec.describe CustomerRelations::Organizations::UpdateService do end context 'when user has permission' do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } before_all do group.add_developer(user) diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb index e1bd3732820..46c5e2a9818 100644 --- a/spec/services/groups/update_service_spec.rb +++ b/spec/services/groups/update_service_spec.rb @@ -163,6 +163,70 @@ RSpec.describe Groups::UpdateService do expect(updated_group.parent_id).to be_nil end end + + context 'crm_enabled param' do + context 'when no existing crm_settings' do + it 'when param not present, leave crm disabled' do + params = {} + + described_class.new(public_group, user, params).execute + updated_group = public_group.reload + + expect(updated_group.crm_enabled?).to be_falsey + end + + it 'when param set true, enables crm' do + params = { crm_enabled: true } + + described_class.new(public_group, user, params).execute + updated_group = public_group.reload + + expect(updated_group.crm_enabled?).to be_truthy + end + end + + context 'with existing crm_settings' do + it 'when param set true, enables crm' do + params = { crm_enabled: true } + create(:crm_settings, group: public_group) + + described_class.new(public_group, user, params).execute + + updated_group = public_group.reload + expect(updated_group.crm_enabled?).to be_truthy + end + + it 'when param set false, disables crm' do + params = { crm_enabled: false } + create(:crm_settings, group: public_group, enabled: true) + + described_class.new(public_group, user, params).execute + + updated_group = public_group.reload + expect(updated_group.crm_enabled?).to be_falsy + end + + it 'when param not present, crm remains disabled' do + params = {} + create(:crm_settings, group: public_group) + + described_class.new(public_group, user, params).execute + + updated_group = public_group.reload + expect(updated_group.crm_enabled?).to be_falsy + end + + it 'when param not present, crm remains enabled' do + params = {} + create(:crm_settings, group: public_group, enabled: true) + + described_class.new(public_group, user, params).execute + + updated_group = public_group.reload + expect(updated_group.crm_enabled?).to be_truthy + end + end + end end context "unauthorized visibility_level validation" do diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 732900a53d3..b841c84a446 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Issues::CreateService do include AfterNextHelpers - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let_it_be_with_reload(:project) { create(:project, group: group) } let_it_be(:user) { create(:user) } diff --git a/spec/services/issues/set_crm_contacts_service_spec.rb b/spec/services/issues/set_crm_contacts_service_spec.rb index 628f70efad6..2418f317551 100644 --- a/spec/services/issues/set_crm_contacts_service_spec.rb +++ b/spec/services/issues/set_crm_contacts_service_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Issues::SetCrmContactsService do let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let_it_be(:project) { create(:project, group: group) } let_it_be(:contacts) { create_list(:contact, 4, group: group) } diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 59a7182e8aa..48b61814142 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Issues::UpdateService, :mailer do let_it_be(:user2) { create(:user) } let_it_be(:user3) { create(:user) } let_it_be(:guest) { create(:user) } - let_it_be(:group) { create(:group, :public) } + let_it_be(:group) { create(:group, :public, :crm_enabled) } let_it_be(:project, reload: true) { create(:project, :repository, group: group) } let_it_be(:label) { create(:label, project: project) } let_it_be(:label2) { create(:label, project: project) } diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index 77d263f4b70..e56e54db6f4 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe QuickActions::InterpretService do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let_it_be(:public_project) { create(:project, :public, group: group) } let_it_be(:repository_project) { create(:project, :repository) } let_it_be(:project) { public_project } diff --git a/spec/services/resource_access_tokens/create_service_spec.rb b/spec/services/resource_access_tokens/create_service_spec.rb index 42520ea26b2..218bff7ed04 100644 --- a/spec/services/resource_access_tokens/create_service_spec.rb +++ b/spec/services/resource_access_tokens/create_service_spec.rb @@ -7,10 +7,10 @@ RSpec.describe ResourceAccessTokens::CreateService do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project, :private) } + let_it_be(:group) { create(:group, :private) } let_it_be(:params) { {} } describe '#execute' do - # Created shared_examples as it will easy to include specs for group bots in https://gitlab.com/gitlab-org/gitlab/-/issues/214046 shared_examples 'token creation fails' do let(:resource) { create(:project)} @@ -31,7 +31,7 @@ RSpec.describe ResourceAccessTokens::CreateService do access_token = response.payload[:access_token] - expect(access_token.user.reload.user_type).to eq("#{resource_type}_bot") + expect(access_token.user.reload.user_type).to eq("project_bot") expect(access_token.user.created_by_id).to eq(user.id) end @@ -112,10 +112,8 @@ RSpec.describe ResourceAccessTokens::CreateService do end context 'when user is external' do - let(:user) { create(:user, :external) } - before do - project.add_maintainer(user) + user.update!(external: true) end it 'creates resource bot user with external status' do @@ -162,7 +160,7 @@ RSpec.describe ResourceAccessTokens::CreateService do access_token = response.payload[:access_token] project_bot = access_token.user - expect(project.members.find_by(user_id: project_bot.id).expires_at).to eq(nil) + expect(resource.members.find_by(user_id: project_bot.id).expires_at).to eq(nil) end end end @@ -183,7 +181,7 @@ RSpec.describe ResourceAccessTokens::CreateService do access_token = response.payload[:access_token] project_bot = access_token.user - expect(project.members.find_by(user_id: project_bot.id).expires_at).to eq(params[:expires_at]) + expect(resource.members.find_by(user_id: project_bot.id).expires_at).to eq(params[:expires_at]) end end end @@ -234,20 +232,22 @@ RSpec.describe ResourceAccessTokens::CreateService do end end + shared_examples 'when user does not have permission to create a resource bot' do + it_behaves_like 'token creation fails' + + it 'returns the permission error message' do + response = subject + + expect(response.error?).to be true + expect(response.errors).to include("User does not have permission to create #{resource_type} access token") + end + end + context 'when resource is a project' do let_it_be(:resource_type) { 'project' } let_it_be(:resource) { project } - context 'when user does not have permission to create a resource bot' do - it_behaves_like 'token creation fails' - - it 'returns the permission error message' do - response = subject - - expect(response.error?).to be true - expect(response.errors).to include("User does not have permission to create #{resource_type} access token") - end - end + it_behaves_like 'when user does not have permission to create a resource bot' context 'user with valid permission' do before_all do @@ -257,5 +257,20 @@ RSpec.describe ResourceAccessTokens::CreateService do it_behaves_like 'allows creation of bot with valid params' end end + + context 'when resource is a project' do + let_it_be(:resource_type) { 'group' } + let_it_be(:resource) { group } + + it_behaves_like 'when user does not have permission to create a resource bot' + + context 'user with valid permission' do + before_all do + resource.add_owner(user) + end + + it_behaves_like 'allows creation of bot with valid params' + end + end end end diff --git a/spec/services/resource_access_tokens/revoke_service_spec.rb b/spec/services/resource_access_tokens/revoke_service_spec.rb index 4f4e2ab0c99..3d724a79fef 100644 --- a/spec/services/resource_access_tokens/revoke_service_spec.rb +++ b/spec/services/resource_access_tokens/revoke_service_spec.rb @@ -6,11 +6,12 @@ RSpec.describe ResourceAccessTokens::RevokeService do subject { described_class.new(user, resource, access_token).execute } let_it_be(:user) { create(:user) } + let_it_be(:user_non_priviledged) { create(:user) } + let_it_be(:resource_bot) { create(:user, :project_bot) } let(:access_token) { create(:personal_access_token, user: resource_bot) } describe '#execute', :sidekiq_inline do - # Created shared_examples as it will easy to include specs for group bots in https://gitlab.com/gitlab-org/gitlab/-/issues/214046 shared_examples 'revokes access token' do it { expect(subject.success?).to be true } @@ -79,71 +80,80 @@ RSpec.describe ResourceAccessTokens::RevokeService do end end + shared_examples 'revoke fails' do |resource_type| + let_it_be(:other_user) { create(:user) } + + context "when access token does not belong to this #{resource_type}" do + it 'does not find the bot' do + other_access_token = create(:personal_access_token, user: other_user) + + response = described_class.new(user, resource, other_access_token).execute + + expect(response.success?).to be false + expect(response.message).to eq("Failed to find bot user") + expect(access_token.reload.revoked?).to be false + end + end + + context 'when user does not have permission to destroy bot' do + context "when non-#{resource_type} member tries to delete project bot" do + it 'does not allow other user to delete bot' do + response = described_class.new(other_user, resource, access_token).execute + + expect(response.success?).to be false + expect(response.message).to eq("#{other_user.name} cannot delete #{access_token.user.name}") + expect(access_token.reload.revoked?).to be false + end + end + + context "when non-priviledged #{resource_type} member tries to delete project bot" do + it 'does not allow developer to delete bot' do + response = described_class.new(user_non_priviledged, resource, access_token).execute + + expect(response.success?).to be false + expect(response.message).to eq("#{user_non_priviledged.name} cannot delete #{access_token.user.name}") + expect(access_token.reload.revoked?).to be false + end + end + end + + context 'when deletion of bot user fails' do + before do + allow_next_instance_of(::ResourceAccessTokens::RevokeService) do |service| + allow(service).to receive(:execute).and_return(false) + end + end + + it_behaves_like 'rollback revoke steps' + end + end + context 'when resource is a project' do let_it_be(:resource) { create(:project, :private) } - let(:resource_bot) { create(:user, :project_bot) } - before do resource.add_maintainer(user) + resource.add_developer(user_non_priviledged) resource.add_maintainer(resource_bot) end it_behaves_like 'revokes access token' - context 'revoke fails' do - let_it_be(:other_user) { create(:user) } + it_behaves_like 'revoke fails', 'project' + end - context 'when access token does not belong to this project' do - it 'does not find the bot' do - other_access_token = create(:personal_access_token, user: other_user) + context 'when resource is a group' do + let_it_be(:resource) { create(:group, :private) } - response = described_class.new(user, resource, other_access_token).execute - - expect(response.success?).to be false - expect(response.message).to eq("Failed to find bot user") - expect(access_token.reload.revoked?).to be false - end - end - - context 'when user does not have permission to destroy bot' do - context 'when non-project member tries to delete project bot' do - it 'does not allow other user to delete bot' do - response = described_class.new(other_user, resource, access_token).execute - - expect(response.success?).to be false - expect(response.message).to eq("#{other_user.name} cannot delete #{access_token.user.name}") - expect(access_token.reload.revoked?).to be false - end - end - - context 'when non-maintainer project member tries to delete project bot' do - let(:developer) { create(:user) } - - before do - resource.add_developer(developer) - end - - it 'does not allow developer to delete bot' do - response = described_class.new(developer, resource, access_token).execute - - expect(response.success?).to be false - expect(response.message).to eq("#{developer.name} cannot delete #{access_token.user.name}") - expect(access_token.reload.revoked?).to be false - end - end - end - - context 'when deletion of bot user fails' do - before do - allow_next_instance_of(::ResourceAccessTokens::RevokeService) do |service| - allow(service).to receive(:execute).and_return(false) - end - end - - it_behaves_like 'rollback revoke steps' - end + before do + resource.add_owner(user) + resource.add_maintainer(user_non_priviledged) + resource.add_maintainer(resource_bot) end + + it_behaves_like 'revokes access token' + + it_behaves_like 'revoke fails', 'group' end end end diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb index ad6462dc367..88b0d997a80 100644 --- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb @@ -8,7 +8,7 @@ RSpec.shared_context 'GroupPolicy context' do let_it_be(:owner) { create(:user) } let_it_be(:admin) { create(:admin) } let_it_be(:non_group_member) { create(:user) } - let_it_be(:group, refind: true) { create(:group, :private, :owner_subgroup_creation_only) } + let_it_be(:group, refind: true) { create(:group, :private, :owner_subgroup_creation_only, :crm_enabled) } let(:guest_permissions) do %i[