diff --git a/Gemfile b/Gemfile index 8570ae96550..06d09b1abca 100644 --- a/Gemfile +++ b/Gemfile @@ -341,7 +341,7 @@ gem 'gitlab_chronic_duration', '~> 0.12' # rubocop:todo Gemfile/MissingFeatureCa gem 'rack-proxy', '~> 0.7.7' # rubocop:todo Gemfile/MissingFeatureCategory -gem 'cssbundling-rails', '1.3.3', feature_category: :shared +gem 'cssbundling-rails', '1.4.0', feature_category: :shared gem 'autoprefixer-rails', '10.2.5.1' # rubocop:todo Gemfile/MissingFeatureCategory gem 'terser', '1.0.2' # rubocop:todo Gemfile/MissingFeatureCategory diff --git a/Gemfile.checksum b/Gemfile.checksum index 6b8b1ec22fd..bd5e0cd2a9d 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -96,7 +96,7 @@ {"name":"creole","version":"0.5.0","platform":"ruby","checksum":"951701e2d80760f156b1cb2a93471ca97c076289becc067a33b745133ed32c03"}, {"name":"crystalball","version":"0.7.0","platform":"ruby","checksum":"6e729f372a5071daec877adb40c5df4cb25fe21f350635e2a9624373fc151ef2"}, {"name":"css_parser","version":"1.14.0","platform":"ruby","checksum":"f2ce6148cd505297b07bdbe7a5db4cce5cf530071f9b732b9a23538d6cdc0113"}, -{"name":"cssbundling-rails","version":"1.3.3","platform":"ruby","checksum":"4aa13311e52a40bc0eb32ca651f44db8df03df273553cf9aeb022570607e1855"}, +{"name":"cssbundling-rails","version":"1.4.0","platform":"ruby","checksum":"082034653af0ec53d7662e4cd2f518f36167fe7c014dbcf37a941a4a8324f7db"}, {"name":"cvss-suite","version":"3.0.1","platform":"ruby","checksum":"b5ca9e9e94032a42fd0dc28c1e305378b62c949e35ed7111fc4a1d76f68ad3f9"}, {"name":"danger","version":"9.4.2","platform":"ruby","checksum":"43e552c6731030235a30fdeafe703d2e2ab9c30917154489cb0ecd9ad3259d80"}, {"name":"danger-gitlab","version":"8.0.0","platform":"ruby","checksum":"497dd7d0f6513913de651019223d8058cf494df10acbd17de92b175dfa04a3a8"}, diff --git a/Gemfile.lock b/Gemfile.lock index 1808663e510..001210ea374 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -435,7 +435,7 @@ GEM git css_parser (1.14.0) addressable - cssbundling-rails (1.3.3) + cssbundling-rails (1.4.0) railties (>= 6.0.0) cvss-suite (3.0.1) danger (9.4.2) @@ -1861,7 +1861,7 @@ DEPENDENCIES countries (~> 4.0.0) creole (~> 0.5.0) crystalball (~> 0.7.0) - cssbundling-rails (= 1.3.3) + cssbundling-rails (= 1.4.0) csv_builder! cvss-suite (~> 3.0.1) database_cleaner-active_record (~> 2.1.0) diff --git a/app/assets/javascripts/ci/runner/components/registration/google_cloud_registration_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/google_cloud_registration_instructions.vue index 901062b7ee0..dadd1ba4e76 100644 --- a/app/assets/javascripts/ci/runner/components/registration/google_cloud_registration_instructions.vue +++ b/app/assets/javascripts/ci/runner/components/registration/google_cloud_registration_instructions.vue @@ -16,6 +16,7 @@ import { s__, __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants'; import runnerForRegistrationQuery from '../../graphql/register/runner_for_registration.query.graphql'; +import provisionGoogleCloudRunnerGroup from '../../graphql/register/provision_google_cloud_runner_group.query.graphql'; import provisionGoogleCloudRunnerProject from '../../graphql/register/provision_google_cloud_runner_project.query.graphql'; import { I18N_FETCH_ERROR, @@ -159,6 +160,8 @@ export default { provisioningSteps: [], setupBashScript: '', showAlert: false, + group: null, + project: null, }; }, apollo: { @@ -215,6 +218,29 @@ export default { return !this.projectPath || this.invalidFields.length > 0; }, }, + group: { + query: provisionGoogleCloudRunnerGroup, + variables() { + return { + fullPath: this.groupPath, + cloudProjectId: this.projectId, + region: this.region, + zone: this.zone, + machineType: this.machineType, + runnerToken: this.token, + }; + }, + result({ data }) { + this.provisioningSteps = data.group.runnerCloudProvisioning?.provisioningSteps; + this.setupBashScript = data.group.runnerCloudProvisioning?.projectSetupShellScript; + }, + error(error) { + captureException({ error, component: this.$options.name }); + }, + skip() { + return !this.groupPath || this.invalidFields.length > 0; + }, + }, }, computed: { isRunnerOnline() { diff --git a/app/assets/javascripts/ci/runner/graphql/register/provision_google_cloud_runner_group.query.graphql b/app/assets/javascripts/ci/runner/graphql/register/provision_google_cloud_runner_group.query.graphql new file mode 100644 index 00000000000..016039cd955 --- /dev/null +++ b/app/assets/javascripts/ci/runner/graphql/register/provision_google_cloud_runner_group.query.graphql @@ -0,0 +1,27 @@ +query provisionGoogleCloudRunnerGroup( + $fullPath: ID! + $cloudProjectId: GoogleCloudProject! + $region: GoogleCloudRegion! + $zone: GoogleCloudZone! + $machineType: GoogleCloudMachineType! + $runnerToken: String +) { + group(fullPath: $fullPath) { + id + runnerCloudProvisioning(provider: GOOGLE_CLOUD, cloudProjectId: $cloudProjectId) { + ... on CiRunnerGoogleCloudProvisioning { + projectSetupShellScript + provisioningSteps( + region: $region + zone: $zone + ephemeralMachineType: $machineType + runnerToken: $runnerToken + ) { + title + languageIdentifier + instructions + } + } + } + } +} diff --git a/app/graphql/resolvers/projects/deploy_key_resolver.rb b/app/graphql/resolvers/projects/deploy_key_resolver.rb new file mode 100644 index 00000000000..1dc6dbe2427 --- /dev/null +++ b/app/graphql/resolvers/projects/deploy_key_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + module Projects + class DeployKeyResolver < BaseResolver + include LooksAhead + type Types::AccessLevels::DeployKeyType, null: true + + def resolve_with_lookahead(**args) + apply_lookahead(Autocomplete::DeployKeysWithWriteAccessFinder.new(current_user, + object).execute(title_search_term: args[:title_query])) + end + + def preloads + { + user: [:user] + } + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 76faea29f79..4ee6e2429b7 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -704,6 +704,17 @@ module Types alpha: { milestone: '16.9' }, null: true + field :available_deploy_keys, Types::AccessLevels::DeployKeyType.connection_type, + resolver: Resolvers::Projects::DeployKeyResolver, + description: 'List of available deploy keys', + extras: [:lookahead], + null: true, + authorize: :admin_project do + argument :title_query, GraphQL::Types::String, + required: false, + description: 'Term by which to search deploy key titles' + end + def protectable_branches ProtectableDropdown.new(project, :branches).protectable_ref_names end diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 14c750072c2..142308db6c4 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -28,7 +28,6 @@ module Avatarable mount_uploader :avatar, AvatarUploader after_initialize :add_avatar_to_batch - after_commit :clear_avatar_caches end module ShadowMethods @@ -130,10 +129,4 @@ module Avatarable def avatar_mounter strong_memoize(:avatar_mounter) { _mounter(:avatar) } end - - def clear_avatar_caches - return unless respond_to?(:verified_emails) && verified_emails.any? && avatar_changed? - - Gitlab::AvatarCache.delete_by_email(*verified_emails) - end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 1ede7749791..805a7f906db 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1117,23 +1117,7 @@ class MergeRequest < ApplicationRecord merge_request_diff.persisted? || create_merge_request_diff end - def eager_fetch_ref! - return unless valid? - - # has_internal_id normally attempts to allocate the iid in the - # before_create hook, but we need the iid to be available before - # that to fetch the ref into the target project. - track_target_project_iid! - ensure_target_project_iid! - - fetch_ref! - # Prevent the after_create hook from fetching the source branch again. - @skip_fetch_ref = true - end - def create_merge_request_diff - # Callers such as MergeRequests::BuildService may not call eager_fetch_ref!. Just - # in case they haven't, we fetch the ref. fetch_ref! unless skip_fetch_ref # n+1: https://gitlab.com/gitlab-org/gitlab/-/issues/19377 diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index c81a879ad1a..8c99d0f6856 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -73,6 +73,7 @@ module Groups end end + transfer_labels if Feature.enabled?(:group_labels_transfer) remove_paid_features_for_projects(old_root_ancestor_id) post_update_hooks(@updated_project_ids, old_root_ancestor_id) propagate_integrations @@ -81,6 +82,14 @@ module Groups true end + def transfer_labels + @group.all_projects.each_batch(of: 10) do |projects| + projects.each do |project| + Labels::TransferService.new(current_user, @group, project).execute + end + end + end + # Overridden in EE def post_update_hooks(updated_project_ids, old_root_ancestor_id) refresh_project_authorizations diff --git a/app/services/project_access_tokens/rotate_service.rb b/app/services/resource_access_tokens/rotate_service.rb similarity index 56% rename from app/services/project_access_tokens/rotate_service.rb rename to app/services/resource_access_tokens/rotate_service.rb index 63d8d2a82cc..a55ddcb795f 100644 --- a/app/services/project_access_tokens/rotate_service.rb +++ b/app/services/resource_access_tokens/rotate_service.rb @@ -1,20 +1,20 @@ # frozen_string_literal: true -module ProjectAccessTokens +module ResourceAccessTokens class RotateService < ::PersonalAccessTokens::RotateService extend ::Gitlab::Utils::Override def initialize(current_user, token, resource = nil) @current_user = current_user @token = token - @project = resource + @resource = resource end def execute(params = {}) super end - attr_reader :project + attr_reader :resource private @@ -44,15 +44,34 @@ module ProjectAccessTokens end def valid_access_level? - return true if current_user.can_admin_all_resources? - return false unless current_user.can?(:manage_resource_access_tokens, project) + return true if admin_all_resources? + return false unless can_manage_tokens? - token_access_level = project.team.max_member_access(token.user.id).to_i - current_user_access_level = project.team.max_member_access(current_user.id).to_i + token_access_level <= current_user_access_level + end - return true if token_access_level.to_i <= current_user_access_level + def admin_all_resources? + current_user.can_admin_all_resources? + end - false + def can_manage_tokens? + current_user.can?(:manage_resource_access_tokens, resource) + end + + def token_access_level + if resource.is_a? Project + resource.team.max_member_access(token.user.id).to_i + else + resource.max_member_access_for_user(token.user).to_i + end + end + + def current_user_access_level + if resource.is_a? Project + resource.team.max_member_access(current_user.id).to_i + else + resource.max_member_access_for_user(current_user).to_i + end end end end diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index ac7b05bc7ea..4ccd508184b 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -8,6 +8,9 @@ class AvatarUploader < GitlabUploader MIME_ALLOWLIST = %w[image/png image/jpeg image/gif image/bmp image/tiff image/vnd.microsoft.icon].freeze + after :store, :clear_avatar_caches + after :remove, :clear_avatar_caches + def exists? model.avatar.file && model.avatar.file.present? end @@ -37,4 +40,10 @@ class AvatarUploader < GitlabUploader def dynamic_segment File.join(model.class.underscore, mounted_as.to_s, model.id.to_s) end + + def clear_avatar_caches(*) + return unless model.respond_to?(:verified_emails) && model.verified_emails.any? + + Gitlab::AvatarCache.delete_by_email(*model.verified_emails) + end end diff --git a/config/feature_flags/gitlab_com_derisk/group_labels_transfer.yml b/config/feature_flags/gitlab_com_derisk/group_labels_transfer.yml new file mode 100644 index 00000000000..d569c524963 --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/group_labels_transfer.yml @@ -0,0 +1,9 @@ +--- +name: group_labels_transfer +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/354890 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/146292 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/443874 +milestone: '16.10' +group: group::project management +type: gitlab_com_derisk +default_enabled: false diff --git a/doc/administration/license.md b/doc/administration/license.md index 53d44bf6dbf..9ec30a925e1 100644 --- a/doc/administration/license.md +++ b/doc/administration/license.md @@ -110,15 +110,15 @@ You may have connectivity issues due to the following reasons: curl --verbose "https://customers.gitlab.com/" ``` - - Use `nslookup` to identify the target IP addresses that your GitLab instance will need to access. - - ```shell - nslookup customers.gitlab.com - ``` - - If the curl command returns an error, either: - - [Configure a proxy](https://docs.gitlab.com/omnibus/settings/environment-variables.html) in `gitlab.rb` to point to your server. - - Contact your network administrator to make changes to an existing proxy or firewall. + - Check your firewall or proxy. The domain `https://customers.gitlab.com` is + fronted by Cloudflare. Ensure your firewall or proxy allows traffic to the Cloudflare + [IPv4](https://www.cloudflare.com/ips-v4/) and + [IPv6](https://www.cloudflare.com/ips-v6/) ranges for activation to work. + - [Configure a proxy](https://docs.gitlab.com/omnibus/settings/environment-variables.html) + in `gitlab.rb` to point to your server. + + Contact your network administrator to make changes to an existing proxy or firewall. - If an SSL inspection appliance is used, you must add the appliance's root CA certificate to `/etc/gitlab/trusted-certs` on your instance, then run `gitlab-ctl reconfigure`. - **Customers Portal is not operational**: diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 3fbf53bc454..55780c7c05f 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -9052,6 +9052,29 @@ Some of the types in the schema exist solely to model connections. Each connecti has a distinct, named type, with a distinct named edge type. These are listed separately below. +#### `AccessLevelDeployKeyConnection` + +The connection type for [`AccessLevelDeployKey`](#accessleveldeploykey). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `edges` | [`[AccessLevelDeployKeyEdge]`](#accessleveldeploykeyedge) | A list of edges. | +| `nodes` | [`[AccessLevelDeployKey]`](#accessleveldeploykey) | A list of nodes. | +| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `AccessLevelDeployKeyEdge` + +The edge type for [`AccessLevelDeployKey`](#accessleveldeploykey). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `cursor` | [`String!`](#string) | A cursor for use in pagination. | +| `node` | [`AccessLevelDeployKey`](#accessleveldeploykey) | The item at the end of the edge. | + #### `AchievementConnection` The connection type for [`Achievement`](#achievement). @@ -25377,6 +25400,22 @@ Returns [`[AutocompletedUser!]`](#autocompleteduser). | ---- | ---- | ----------- | | `search` | [`String`](#string) | Query to search users by name, username, or public email. | +##### `Project.availableDeployKeys` + +List of available deploy keys. + +Returns [`AccessLevelDeployKeyConnection`](#accessleveldeploykeyconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#pagination-arguments): +`before: String`, `after: String`, `first: Int`, and `last: Int`. + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `titleQuery` | [`String`](#string) | Term by which to search deploy key titles. | + ##### `Project.board` A single board of the project. diff --git a/doc/integration/azure.md b/doc/integration/azure.md index 333ab263553..dce98a52b5c 100644 --- a/doc/integration/azure.md +++ b/doc/integration/azure.md @@ -12,7 +12,7 @@ DETAILS: You can enable the Microsoft Azure OAuth 2.0 OmniAuth provider and sign in to GitLab with your Microsoft Azure credentials. You can configure the provider that uses -[the earlier Azure Active Directory v1.0 endpoint](https://learn.microsoft.com/en-us/azure/active-directory/azuread-dev/v1-protocols-oauth-code), +[the earlier Azure Active Directory v1.0 endpoint](https://learn.microsoft.com/en-us/previous-versions/azure/active-directory/azuread-dev/v1-protocols-oauth-code), or the provider that uses the v2.0 endpoint. NOTE: @@ -177,7 +177,7 @@ an Azure application and get a client ID and secret key. 1. Sign in to the [Azure portal](https://portal.azure.com). 1. If you have multiple Azure Active Directory tenants, switch to the desired tenant. Note the tenant ID. -1. [Register an application](https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app) +1. [Register an application](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) and provide the following information: - The redirect URI, which requires the URL of the Azure OAuth callback of your GitLab installation. For example: @@ -195,7 +195,7 @@ In some Microsoft documentation, the terms are named `Application ID` and ## Add API permissions (scopes) -If you're using the v2.0 endpoint, after you create the application, [configure it to expose a web API](https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-configure-app-expose-web-apis). +If you're using the v2.0 endpoint, after you create the application, [configure it to expose a web API](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-configure-app-expose-web-apis). Add the following delegated permissions under the Microsoft Graph API: - `email` @@ -263,7 +263,7 @@ Alternatively, add the `User.Read.All` application permission. ] ``` - For [alternative Azure clouds](https://learn.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud), + For [alternative Azure clouds](https://learn.microsoft.com/en-us/entra/identity-platform/authentication-national-cloud), configure `base_azure_url` under the `args` section. For example, for Azure Government Community Cloud (GCC): ```ruby @@ -303,7 +303,7 @@ Alternatively, add the `User.Read.All` application permission. tenant_id: "" } } ``` - For [alternative Azure clouds](https://learn.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud), + For [alternative Azure clouds](https://learn.microsoft.com/en-us/entra/identity-platform/authentication-national-cloud), configure `base_azure_url` under the `args` section. For example, for Azure Government Community Cloud (GCC): ```yaml @@ -315,7 +315,7 @@ Alternatively, add the `User.Read.All` application permission. base_azure_url: "https://login.microsoftonline.us" } } ``` - You can also optionally add the `scope` for [OAuth 2.0 scopes](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow) parameter to the `args` section. The default is `openid profile email`. + You can also optionally add the `scope` for [OAuth 2.0 scopes](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow) parameter to the `args` section. The default is `openid profile email`. 1. Save the configuration file. diff --git a/doc/integration/github.md b/doc/integration/github.md index dab94fc91c2..a72ae880ebb 100644 --- a/doc/integration/github.md +++ b/doc/integration/github.md @@ -20,7 +20,7 @@ To enable the GitHub OmniAuth provider, you need an OAuth 2.0 client ID and clie secret from GitHub: 1. Sign in to GitHub. -1. [Create an OAuth App](https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app) +1. [Create an OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) and provide the following information: - The URL of your GitLab instance, such as `https://gitlab.example.com`. - The authorization callback URL, such as, `https://gitlab.example.com/users/auth`. diff --git a/doc/integration/gitpod.md b/doc/integration/gitpod.md index a670b70f476..53b08e7154b 100644 --- a/doc/integration/gitpod.md +++ b/doc/integration/gitpod.md @@ -30,7 +30,7 @@ To use the GitLab Gitpod integration, it must be enabled for your GitLab instanc 1. It's [enabled in their user settings](#enable-gitpod-in-your-user-settings). For more information about Gitpod, see the Gitpod [features](https://www.gitpod.io/) and -[documentation](https://www.gitpod.io/docs/). +[documentation](https://www.gitpod.io/docs). ## Enable Gitpod in your user settings diff --git a/doc/integration/index.md b/doc/integration/index.md index 6d35221e600..4dfd35ead89 100644 --- a/doc/integration/index.md +++ b/doc/integration/index.md @@ -41,15 +41,15 @@ You can also integrate GitLab with the following security partners: - [Anchore](https://docs.anchore.com/current/docs/configuration/integration/ci_cd/gitlab/) - [Bridgecrew](https://docs.bridgecrew.io/docs/integrate-with-gitlab-self-managed) - [Checkmarx](https://checkmarx.atlassian.net/wiki/spaces/SD/pages/1929937052/GitLab+Integration) +- [CodeSecure](https://codesecure.com/our-integrations/codesonar-sast-gitlab-ci-pipeline/) - [Deepfactor](https://www.deepfactor.io/docs/integrate-deepfactor-scanner-in-your-ci-cd-pipelines/#gitlab) - [Fortify](https://www.microfocus.com/en-us/fortify-integrations/gitlab) -- [GrammaTech](https://www.grammatech.com/codesonar-gitlab-integration) - [Indeni](https://docs.cloudrail.app/#/integrations/gitlab) - [Jscrambler](https://docs.jscrambler.com/code-integrity/documentation/gitlab-ci-integration) - [Mend](https://www.mend.io/gitlab/) -- [Semgrep](https://semgrep.dev/for/gitlab) +- [Semgrep](https://semgrep.dev/for/gitlab/) - [StackHawk](https://docs.stackhawk.com/continuous-integration/gitlab.html) -- [Tenable](https://docs.tenable.com/tenableio/Content/ContainerSecurity/GetStarted.htm) +- [Tenable](https://docs.tenable.com/vulnerability-management/Content/ContainerSecurity/GetStarted.htm) - [Venafi](https://marketplace.venafi.com/xchange/620d2d6ed419fb06a5c5bd36/solution/6292c2ef7550f2ee553cf223) - [Veracode](https://community.veracode.com/s/knowledgeitem/gitlab-ci-MCEKSYPRWL35BRTGOVI55SK5RI4A) diff --git a/doc/integration/jira/connect-app.md b/doc/integration/jira/connect-app.md index 18ef46f38e6..d6434a0c5cc 100644 --- a/doc/integration/jira/connect-app.md +++ b/doc/integration/jira/connect-app.md @@ -19,7 +19,7 @@ You can use the GitLab for Jira Cloud app to link top-level groups or subgroups. To set up the GitLab for Jira Cloud app on GitLab.com, [install the GitLab for Jira Cloud app](#install-the-gitlab-for-jira-cloud-app). -After you set up the app, you can use the [project toolchain](https://support.atlassian.com/jira-software-cloud/docs/what-is-the-project-toolchain-in-jira) +After you set up the app, you can use the [project toolchain](https://support.atlassian.com/jira-software-cloud/docs/what-is-the-project-toolchain-in-jira/) developed and maintained by Atlassian to [link GitLab repositories to Jira projects](https://support.atlassian.com/jira-software-cloud/docs/link-repositories-to-a-project/#Link-repositories-using-the-toolchain-feature). The project toolchain does not affect how development information is synced between GitLab and Jira Cloud. diff --git a/doc/integration/omniauth.md b/doc/integration/omniauth.md index 4e1cc0b4107..e18fe3e7f97 100644 --- a/doc/integration/omniauth.md +++ b/doc/integration/omniauth.md @@ -515,7 +515,7 @@ then override the icon in one of two ways: - **Embed an image directly in a configuration file**: This example creates a Base64-encoded version of your image you can serve through a - [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs): + [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs): 1. Encode your image file with a GNU `base64` command (such as `base64 -w 0 `) which returns a single-line `` string. diff --git a/doc/integration/openid_connect_provider.md b/doc/integration/openid_connect_provider.md index ff73c0d6fbf..55fa4f81262 100644 --- a/doc/integration/openid_connect_provider.md +++ b/doc/integration/openid_connect_provider.md @@ -15,7 +15,7 @@ to sign in to other services. ## Introduction to OpenID Connect -[OpenID Connect](https://openid.net/connect/) \(OIDC) is a simple identity layer on top of the +[OpenID Connect](https://openid.net/developers/how-connect-works/) \(OIDC) is a simple identity layer on top of the OAuth 2.0 protocol. It allows clients to: - Verify the identity of the end-user based on the authentication performed by GitLab. @@ -25,7 +25,7 @@ OIDC performs many of the same tasks as OpenID 2.0, but is API-friendly and usab mobile applications. On the client side, you can use [OmniAuth::OpenIDConnect](https://github.com/omniauth/omniauth_openid_connect) for Rails -applications, or any of the other available [client implementations](https://openid.net/developers/libraries/#connect). +applications, or any of the other available [client implementations](https://openid.net/developers/certified-openid-connect-implementations/). The GitLab implementation uses the [doorkeeper-openid_connect](https://github.com/doorkeeper-gem/doorkeeper-openid_connect "Doorkeeper::OpenidConnect website") gem, refer to its README for more details about which parts of the specifications diff --git a/doc/integration/saml.md b/doc/integration/saml.md index 8980e96da63..091c28c5563 100644 --- a/doc/integration/saml.md +++ b/doc/integration/saml.md @@ -716,7 +716,7 @@ Some IdPs have documentation on how to use them as the IdP in SAML configuration For example: - [Active Directory Federation Services (ADFS)](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-relying-party-trust) -- [Auth0](https://auth0.com/docs/authenticate/protocols/saml/saml-sso-integrations/configure-auth0-saml-identity-provider) +- [Auth0](https://auth0.com/docs/authenticate/single-sign-on/outbound-single-sign-on/configure-auth0-saml-identity-provider) If you have any questions on configuring your IdP in a SAML configuration, contact your provider's support. diff --git a/doc/user/ai_features.md b/doc/user/ai_features.md index c258542a51a..4f34a6c57f2 100644 --- a/doc/user/ai_features.md +++ b/doc/user/ai_features.md @@ -19,7 +19,7 @@ Some features are still in development. View details about [support for each sta | Goal | Feature | Tier/Offering/Status | |---|---|---| | Helps you write code more efficiently by showing code suggestions as you type.

[Watch overview](https://www.youtube.com/watch?v=hCAyCTacdAQ) | [Code Suggestions](project/repository/code_suggestions/index.md) | **Tier:** Premium or Ultimate with [GitLab Duo Pro](../subscriptions/subscription-add-ons.md)
**Offering:** GitLab.com, Self-managed, GitLab Dedicated | -| Processes and generates text and code in a conversational manner. Helps you quickly identify useful information in large volumes of text in issues, epics, code, and GitLab documentation. | [Chat](gitlab_duo_chat.md) | **Beta Access** subject to the [Testing Agreement](https://handbook.gitlab.com/handbook/legal/testing-agreement/):
- GitLab.com, Self-managed, GitLab Dedicated
- Premium and Ultimate tiers

**Status:** Beta | +| Processes and generates text and code in a conversational manner. Helps you quickly identify useful information in large volumes of text in issues, epics, code, and GitLab documentation. | [Chat](gitlab_duo_chat.md) | **Tier:** Premium, Ultimate
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
**Status:** Beta (Subject to the [Testing Agreement](https://handbook.gitlab.com/handbook/legal/testing-agreement/)) | | Helps you discover or recall Git commands when and where you need them. | [Git suggestions](../editor_extensions/gitlab_cli/index.md#gitlab-duo-commands) | **Tier:** Ultimate
**Offering:** GitLab.com
**Status:** Experiment | | Assists with quickly getting everyone up to speed on lengthy conversations to help ensure you are all on the same page. | [Discussion summary](#summarize-issue-discussions-with-discussion-summary) | **Tier:** Ultimate
**Offering:** GitLab.com
**Status:** Experiment | | Generates issue descriptions. | [Issue description generation](#summarize-an-issue-with-issue-description-generation) | **Tier:** Ultimate
**Offering:** GitLab.com
**Status:** Experiment | diff --git a/lib/api/resource_access_tokens.rb b/lib/api/resource_access_tokens.rb index 1e1b5d77cfd..7a477c6fed5 100644 --- a/lib/api/resource_access_tokens.rb +++ b/lib/api/resource_access_tokens.rb @@ -153,13 +153,8 @@ module API token = find_token(resource, params[:token_id]) if resource_accessible if token - response = if source_type == "project" - ::ProjectAccessTokens::RotateService.new(current_user, token, resource) + response = ::ResourceAccessTokens::RotateService.new(current_user, token, resource) .execute(declared_params) - else - ::PersonalAccessTokens::RotateService.new(current_user, token) - .execute(declared_params) - end if response.success? status :ok diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 08d2f1dc6a4..9e80cb7b23a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -20841,6 +20841,9 @@ msgstr "" msgid "Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later." msgstr "" +msgid "Failed to fetch Namespace: %{fullPath}" +msgstr "" + msgid "Failed to fetch the iteration for this issue. Please try again." msgstr "" diff --git a/spec/frontend/ci/runner/components/registration/google_cloud_registration_instructions_spec.js b/spec/frontend/ci/runner/components/registration/google_cloud_registration_instructions_spec.js index 908a28e1d51..333e2e179b7 100644 --- a/spec/frontend/ci/runner/components/registration/google_cloud_registration_instructions_spec.js +++ b/spec/frontend/ci/runner/components/registration/google_cloud_registration_instructions_spec.js @@ -9,10 +9,12 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import GoogleCloudRegistrationInstructions from '~/ci/runner/components/registration/google_cloud_registration_instructions.vue'; import runnerForRegistrationQuery from '~/ci/runner/graphql/register/runner_for_registration.query.graphql'; import provisionGoogleCloudRunnerQueryProject from '~/ci/runner/graphql/register/provision_google_cloud_runner_project.query.graphql'; +import provisionGoogleCloudRunnerQueryGroup from '~/ci/runner/graphql/register/provision_google_cloud_runner_group.query.graphql'; import { runnerForRegistration, mockAuthenticationToken, - googleCloudRunnerProvisionResponse, + projectRunnerCloudProvisioningSteps, + groupRunnerCloudProvisioningSteps, } from '../../mock_data'; Vue.use(VueApollo); @@ -34,10 +36,18 @@ const mockRunnerWithoutTokenResponse = { }, }; -const mockGoogleCloudRunnerProvisionResponse = { +const mockProjectRunnerCloudSteps = { data: { project: { - ...googleCloudRunnerProvisionResponse, + ...projectRunnerCloudProvisioningSteps, + }, + }, +}; + +const mockGroupRunnerCloudSteps = { + data: { + group: { + ...groupRunnerCloudProvisioningSteps, }, }, }; @@ -64,20 +74,36 @@ describe('GoogleCloudRegistrationInstructions', () => { const findInstructionsButton = () => wrapper.findByTestId('show-instructions-button'); const findAlert = () => wrapper.findComponent(GlAlert); + const fillInGoogleForm = () => { + findProjectIdInput().vm.$emit('input', 'dev-gcp-xxx-integrati-xxxxxxxx'); + findRegionInput().vm.$emit('input', 'us-central1'); + findZoneInput().vm.$emit('input', 'us-central1'); + + findInstructionsButton().vm.$emit('click'); + + return waitForPromises(); + }; + const runnerWithTokenResolver = jest.fn().mockResolvedValue(mockRunnerResponse); const runnerWithoutTokenResolver = jest.fn().mockResolvedValue(mockRunnerWithoutTokenResponse); - const googleCloudRunnerResolver = jest - .fn() - .mockResolvedValue(mockGoogleCloudRunnerProvisionResponse); + const projectInstructionsResolver = jest.fn().mockResolvedValue(mockProjectRunnerCloudSteps); + const groupInstructionsResolver = jest.fn().mockResolvedValue(mockGroupRunnerCloudSteps); const defaultHandlers = [[runnerForRegistrationQuery, runnerWithTokenResolver]]; + const defaultProps = { + runnerId: mockRunnerId, + projectPath: 'test/project', + }; - const createComponent = (mountFn = shallowMountExtended, handlers = defaultHandlers) => { + const createComponent = ( + mountFn = shallowMountExtended, + handlers = defaultHandlers, + props = defaultProps, + ) => { wrapper = mountFn(GoogleCloudRegistrationInstructions, { apolloProvider: createMockApollo(handlers), propsData: { - runnerId: mockRunnerId, - projectPath: 'test/project', + ...props, }, }); }; @@ -149,7 +175,7 @@ describe('GoogleCloudRegistrationInstructions', () => { it('Hides an alert when the form is valid', async () => { createComponent(mountExtended, [ - [provisionGoogleCloudRunnerQueryProject, googleCloudRunnerResolver], + [provisionGoogleCloudRunnerQueryProject, projectInstructionsResolver], ]); findProjectIdInput().vm.$emit('input', 'dev-gcp-xxx-integrati-xxxxxxxx'); @@ -164,20 +190,32 @@ describe('GoogleCloudRegistrationInstructions', () => { expect(findAlert().exists()).toBe(false); }); - it('Shows a modal with the correspondent scripts', async () => { + it('Shows a modal with the correspondent scripts for a project', async () => { createComponent(shallowMountExtended, [ - [provisionGoogleCloudRunnerQueryProject, googleCloudRunnerResolver], + [provisionGoogleCloudRunnerQueryProject, projectInstructionsResolver], ]); - findProjectIdInput().vm.$emit('input', 'dev-gcp-xxx-integrati-xxxxxxxx'); - findRegionInput().vm.$emit('input', 'us-central1'); - findZoneInput().vm.$emit('input', 'us-central1'); + await fillInGoogleForm(); - findInstructionsButton().vm.$emit('click'); + expect(projectInstructionsResolver).toHaveBeenCalled(); + expect(groupInstructionsResolver).not.toHaveBeenCalled(); - await waitForPromises(); + expect(findModalBashInstructions().text()).not.toBeNull(); + expect(findModalTerrarformInstructions().text()).not.toBeNull(); + expect(findModalTerrarformApplyInstructions().text).not.toBeNull(); + }); - expect(googleCloudRunnerResolver).toHaveBeenCalled(); + it('Shows a modal with the correspondent scripts for a group', async () => { + createComponent( + shallowMountExtended, + [[provisionGoogleCloudRunnerQueryGroup, groupInstructionsResolver]], + { runnerId: mockRunnerId, groupPath: 'groups/test' }, + ); + + await fillInGoogleForm(); + + expect(groupInstructionsResolver).toHaveBeenCalled(); + expect(projectInstructionsResolver).not.toHaveBeenCalled(); expect(findModalBashInstructions().text()).not.toBeNull(); expect(findModalTerrarformInstructions().text()).not.toBeNull(); diff --git a/spec/frontend/ci/runner/mock_data.js b/spec/frontend/ci/runner/mock_data.js index 803da30e249..addfb121ebc 100644 --- a/spec/frontend/ci/runner/mock_data.js +++ b/spec/frontend/ci/runner/mock_data.js @@ -387,7 +387,7 @@ export const mockAuthenticationToken = 'MOCK_AUTHENTICATION_TOKEN'; export const newRunnerPath = '/runners/new'; export const runnerInstallHelpPage = 'https://docs.example.com/runner/install/'; -export const googleCloudRunnerProvisionResponse = { +export const projectRunnerCloudProvisioningSteps = { __typename: 'Project', id: 'gid://gitlab/Project/1', runnerCloudProvisioning: { @@ -410,6 +410,29 @@ export const googleCloudRunnerProvisionResponse = { }, }; +export const groupRunnerCloudProvisioningSteps = { + __typename: 'Group', + id: 'gid://gitlab/Group/24', + runnerCloudProvisioning: { + __typename: 'CiRunnerGoogleCloudProvisioning', + projectSetupShellScript: '#!/bin/bash echo "hello world!"', + provisioningSteps: [ + { + __typename: 'CiRunnerCloudProvisioningStep', + title: 'Save the Terraform script to a file', + languageIdentifier: 'terraform', + instructions: 'mock instructions...', + }, + { + __typename: 'CiRunnerCloudProvisioningStep', + title: 'Apply the Terraform script', + languageIdentifier: 'shell', + instructions: 'mock instructions...', + }, + ], + }, +}; + export { allRunnersData, allRunnersWithCreatorData, diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index 229d7cf99d1..42b84a76607 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -43,7 +43,7 @@ RSpec.describe GitlabSchema.types['Project'], feature_category: :groups_and_proj incident_management_timeline_event_tags visible_forks inherited_ci_variables autocomplete_users ci_cd_settings detailed_import_status value_streams ml_models allows_multiple_merge_request_assignees allows_multiple_merge_request_reviewers is_forked - protectable_branches + protectable_branches available_deploy_keys ] expect(described_class).to include_graphql_fields(*expected_fields) @@ -1219,4 +1219,59 @@ RSpec.describe GitlabSchema.types['Project'], feature_category: :groups_and_proj end end end + + describe 'available_deploy_keys' do + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + availableDeployKeys{ + nodes{ + id + title + user { + username + } + } + } + } + } + ) + end + + subject { GitlabSchema.execute(query, context: { current_user: current_user }).as_json } + + let_it_be(:project) { create :project } + let_it_be(:deploy_key) { create(:deploy_keys_project, :write_access, project: project).deploy_key } + let_it_be(:maintainer) { create(:user) } + let(:available_deploy_keys) { subject.dig('data', 'project', 'availableDeployKeys', 'nodes') } + + context 'when there are deploy keys' do + before_all do + project.add_maintainer(maintainer) + end + + context 'and the current user has access' do + let(:current_user) { maintainer } + + it 'returns the deploy keys' do + expect(available_deploy_keys[0]).to include({ + 'id' => deploy_key.to_global_id.to_s, + 'title' => deploy_key.title, + 'user' => { + 'username' => deploy_key.user.username + } + }) + end + end + + context 'and the current user does not have access' do + let(:current_user) { create(:user) } + + it 'does not return any deploy keys' do + expect(available_deploy_keys).to be_nil + end + end + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index e6dc1139363..f7a2daa6da1 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -4752,30 +4752,6 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev end end - describe '#eager_fetch_ref!' do - let(:project) { create(:project, :repository) } - - # We use build instead of create to test that an IID is allocated - subject { build(:merge_request, source_project: project) } - - it 'fetches the ref and expires the ancestor cache' do - expect(subject).to receive(:expire_ancestor_cache).and_call_original - expect(subject.iid).to be_nil - - expect { subject.eager_fetch_ref! }.to change { subject.iid.to_i }.by(1) - - expect(subject.target_project.repository.ref_exists?(subject.ref_path)).to be_truthy - end - - it 'only fetches the ref once after saved' do - expect(subject.target_project.repository).to receive(:fetch_source_branch!).once.and_call_original - - subject.save! - - expect(subject.target_project.repository.ref_exists?(subject.ref_path)).to be_truthy - end - end - describe 'removing a merge request' do it 'refreshes the number of open merge requests of the target project' do project = subject.target_project diff --git a/spec/requests/api/resource_access_tokens_spec.rb b/spec/requests/api/resource_access_tokens_spec.rb index 70d66538375..7497add0adc 100644 --- a/spec/requests/api/resource_access_tokens_spec.rb +++ b/spec/requests/api/resource_access_tokens_spec.rb @@ -575,21 +575,14 @@ RSpec.describe API::ResourceAccessTokens, feature_category: :system_access do context 'when service raises an error' do let(:error_message) { 'boom!' } - let(:personal_token_service) { PersonalAccessTokens::RotateService } - let(:project_token_service) { ProjectAccessTokens::RotateService } + let(:resource_token_service) { ResourceAccessTokens::RotateService } before do resource.add_maintainer(project_bot) resource.add_owner(user) - if source_type == 'project' - allow_next_instance_of(project_token_service) do |service| - allow(service).to receive(:execute).and_return(ServiceResponse.error(message: error_message)) - end - else - allow_next_instance_of(personal_token_service) do |service| - allow(service).to receive(:execute).and_return(ServiceResponse.error(message: error_message)) - end + allow_next_instance_of(resource_token_service) do |service| + allow(service).to receive(:execute).and_return(ServiceResponse.error(message: error_message)) end end diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb index 8a866b632bd..8f18013eaf8 100644 --- a/spec/services/groups/transfer_service_spec.rb +++ b/spec/services/groups/transfer_service_spec.rb @@ -141,6 +141,39 @@ RSpec.describe Groups::TransferService, :sidekiq_inline, feature_category: :grou end end + context 'transferring labels' do + let(:new_parent_group) { create(:group, :private) } + let(:parent_group) { create(:group) } + let(:group) { create(:group, parent: parent_group) } + let(:project) { create(:project, group: group) } + + before do + group.add_owner(user) + parent_group.add_owner(user) + new_parent_group.add_owner(user) + end + + context 'when the feature flag "group_labels_transfer" is disabled' do + before do + stub_feature_flags(group_labels_transfer: false) + end + + it 'does not use Labels::TransferService' do + expect(Labels::TransferService).not_to receive(:new) + + transfer_service.execute(new_parent_group) + end + end + + it 'delegates transfer to Labels::TransferService' do + expect_next_instance_of(Labels::TransferService, user, project.group, project) do |labels_transfer_service| + expect(labels_transfer_service).to receive(:execute).once.and_call_original + end + + transfer_service.execute(new_parent_group) + end + end + describe '#execute' do context 'when transforming a group into a root group' do let_it_be_with_reload(:group) { create(:group, :public, :nested) } diff --git a/spec/services/project_access_tokens/rotate_service_spec.rb b/spec/services/project_access_tokens/rotate_service_spec.rb deleted file mode 100644 index 10e29be4979..00000000000 --- a/spec/services/project_access_tokens/rotate_service_spec.rb +++ /dev/null @@ -1,189 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -RSpec.describe ProjectAccessTokens::RotateService, feature_category: :system_access do - describe '#execute' do - let_it_be(:token, reload: true) { create(:personal_access_token) } - let(:current_user) { create(:user) } - let(:project) { create(:project, group: create(:group)) } - let(:error_message) { 'Not eligible to rotate token with access level higher than the user' } - - subject(:response) { described_class.new(current_user, token, project).execute } - - shared_examples_for 'rotates token succesfully' do - it "rotates user's own token", :freeze_time do - expect(response).to be_success - - new_token = response.payload[:personal_access_token] - - expect(new_token.token).not_to eq(token.token) - expect(new_token.expires_at).to eq(Date.today + 1.week) - expect(new_token.user).to eq(token.user) - end - end - - context 'when user tries to rotate token with different access level' do - before do - project.add_guest(token.user) - end - - context 'when current user is an owner' do - before do - project.add_owner(current_user) - end - - it_behaves_like "rotates token succesfully" - - context 'when creating the new token fails' do - let(:error_message) { 'boom!' } - - before do - allow_next_instance_of(PersonalAccessToken) do |token| - allow(token).to receive_message_chain(:errors, :full_messages, :to_sentence).and_return(error_message) - allow(token).to receive_message_chain(:errors, :clear) - allow(token).to receive_message_chain(:errors, :empty?).and_return(false) - end - end - - it 'returns an error' do - expect(response).to be_error - expect(response.message).to eq(error_message) - end - - it 'reverts the changes' do - expect { response }.not_to change { token.reload.revoked? }.from(false) - end - end - end - - context 'when current user is not an owner' do - context 'when current user is maintainer' do - before do - project.add_maintainer(current_user) - end - - context 'when access level is not owner' do - it_behaves_like "rotates token succesfully" - end - - context 'when access level is owner' do - before do - project.add_owner(token.user) - end - - it "does not rotate token with higher priviledge" do - response - - expect(response).to be_error - expect(response.message).to eq(error_message) - end - end - end - - context 'when current user is not maintainer' do - before do - project.add_developer(current_user) - end - - it 'does not rotate the token' do - response - - expect(response).to be_error - expect(response.message).to eq(error_message) - end - end - end - - context 'when current user is admin' do - let(:current_user) { create(:admin) } - - context 'when admin mode enabled', :enable_admin_mode do - it_behaves_like "rotates token succesfully" - end - - context 'when admin mode not enabled' do - it 'does not rotate the token' do - response - - expect(response).to be_error - expect(response.message).to eq(error_message) - end - end - end - - context 'when nested membership' do - let_it_be(:project_bot) { create(:user, :project_bot) } - let(:token) { create(:personal_access_token, user: project_bot) } - let(:top_level_group) { create(:group) } - let(:sub_group) { create(:group, parent: top_level_group) } - let(:project) { create(:project, group: sub_group) } - - before do - project.add_maintainer(project_bot) - end - - context 'when current user is an owner' do - before do - project.add_owner(current_user) - end - - it_behaves_like "rotates token succesfully" - - context 'when its a bot user' do - let_it_be(:bot_user) { create(:user, :project_bot) } - let_it_be(:bot_user_membership) do - create(:project_member, :developer, user: bot_user, project: create(:project)) - end - - let_it_be(:token, reload: true) { create(:personal_access_token, user: bot_user) } - - it 'updates membership expires at' do - response - - new_token = response.payload[:personal_access_token] - expect(bot_user_membership.reload.expires_at).to eq(new_token.expires_at) - end - end - end - - context 'when current user is not an owner' do - context 'when current user is maintainer' do - before do - project.add_maintainer(current_user) - end - - context 'when access level is not owner' do - it_behaves_like "rotates token succesfully" - end - - context 'when access level is owner' do - before do - project.add_owner(token.user) - end - - it "does not rotate token with higher priviledge" do - response - - expect(response).to be_error - expect(response.message).to eq(error_message) - end - end - end - - context 'when current user is not maintainer' do - before do - project.add_developer(current_user) - end - - it 'does not rotate the token' do - response - - expect(response).to be_error - expect(response.message).to eq(error_message) - end - end - end - end - end - end -end diff --git a/spec/services/resource_access_tokens/rotate_service_spec.rb b/spec/services/resource_access_tokens/rotate_service_spec.rb new file mode 100644 index 00000000000..67ec8816c9c --- /dev/null +++ b/spec/services/resource_access_tokens/rotate_service_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceAccessTokens::RotateService, feature_category: :system_access do + shared_examples_for 'rotates token successfully' do + it "rotates user's own token", :freeze_time do + expect(response).to be_success + + new_token = response.payload[:personal_access_token] + + expect(new_token.token).not_to eq(token.token) + expect(new_token.expires_at).to eq(Date.today + 1.week) + expect(new_token.user).to eq(token.user) + end + + it 'updates membership expires_at' do + response + + new_token = response.payload[:personal_access_token] + expect(bot_user.members.first.reload.expires_at).to eq(new_token.expires_at) + end + end + + shared_examples 'token rotation access level check' do |source_type| + before do + resource.add_guest(token.user) + end + + context 'when current user is an owner' do + before do + resource.add_owner(current_user) + end + + it_behaves_like "rotates token successfully" + + context 'when creating the new token fails' do + let(:error_message) { 'boom!' } + + before do + allow_next_instance_of(PersonalAccessToken) do |token| + allow(token).to receive_message_chain(:errors, :full_messages, :to_sentence).and_return(error_message) + allow(token).to receive_message_chain(:errors, :clear) + allow(token).to receive_message_chain(:errors, :empty?).and_return(false) + end + end + + it 'returns an error' do + expect(response).to be_error + expect(response.message).to eq(error_message) + end + + it 'reverts the changes' do + expect { response }.not_to change { token.reload.revoked? }.from(false) + end + end + end + + context 'when current user is maintainer' do + before do + resource.add_maintainer(current_user) + end + + context 'and token user is not owner' do + if source_type == 'project' + it_behaves_like "rotates token successfully" + elsif source_type == 'group' + it 'cannot rotate the token' do + response + + expect(response).to be_error + expect(response.message).to eq(error_message) + end + end + end + + context 'and token user is owner' do + before do + resource.add_owner(token.user) + end + + it "cannot rotate token with higher privilege" do + response + + expect(response).to be_error + expect(response.message).to eq(error_message) + end + end + end + + context 'when current user is neither owner or maintainer' do + before do + resource.add_developer(current_user) + end + + it 'cannot rotate the token' do + response + + expect(response).to be_error + expect(response.message).to eq(error_message) + end + end + + context 'when current user is admin' do + let(:current_user) { create(:admin) } + + context 'with admin mode', :enable_admin_mode do + it_behaves_like "rotates token successfully" + end + + context 'without admin mode' do + it 'cannot rotate the token' do + response + + expect(response).to be_error + expect(response.message).to eq(error_message) + end + end + end + end + + describe '#execute' do + subject(:response) { described_class.new(current_user, token, resource).execute } + + let(:current_user) { create(:user) } + let(:error_message) { 'Not eligible to rotate token with access level higher than the user' } + let(:bot_user) { create(:user, :project_bot) } + let(:token) { create(:personal_access_token, user: bot_user) } + + context 'for project' do + let_it_be(:resource) { create(:project, group: create(:group)) } + + it_behaves_like 'token rotation access level check', 'project' + + context 'with a nested membership' do + let(:top_level_group) { create(:group) } + let(:sub_group) { create(:group, parent: top_level_group) } + let(:resource) { create(:project, group: sub_group) } + + it_behaves_like 'token rotation access level check', 'project' + end + end + + context 'for group' do + let(:resource) { create(:group) } + + it_behaves_like 'token rotation access level check', 'group' + + context 'with a nested membership' do + let(:top_level_group) { create(:group) } + let(:resource) { create(:group, parent: top_level_group) } + + it_behaves_like 'token rotation access level check', 'group' + end + end + end +end diff --git a/spec/uploaders/avatar_uploader_spec.rb b/spec/uploaders/avatar_uploader_spec.rb index 333f23d7947..79786d17de4 100644 --- a/spec/uploaders/avatar_uploader_spec.rb +++ b/spec/uploaders/avatar_uploader_spec.rb @@ -45,6 +45,32 @@ RSpec.describe AvatarUploader do expect(uploader.absolute_path.scan(storage_path).size).to eq(1) expect(uploader.absolute_path).to eq(absolute_path) end + + describe "avatar cache" do + subject(:user) { create(:user) } + + let(:file_path) do + File.join("spec", "fixtures", "rails_sample.png") + end + + let(:file) { fixture_file_upload(file_path) } + + it "clears the cache on upload" do + expect(Gitlab::AvatarCache).to receive(:delete_by_email).with(*user.verified_emails).once + + user.avatar = file + user.save! + end + + it "clears the cache on removal" do + user.avatar = file + user.save! + + expect(Gitlab::AvatarCache).to receive(:delete_by_email).with(*user.verified_emails).once + + user.avatar.remove! + end + end end context 'accept allowlist file content type' do