`.
## `A` record
-A DNS A record maps a host to an IPv4 IP address.
+A DNS `A` record maps a host to an IPv4 IP address.
It points a root domain as `example.com` to the host's IP address as
`192.192.192.192`.
@@ -61,10 +61,10 @@ Example:
- `example.com` => `A` => `192.192.192.192`
-## CNAME record
+## `CNAME` record
-CNAME records define an alias for canonical name for your server (one defined
-by an A record). It points a subdomain to another domain.
+`CNAME` records define an alias for canonical name for your server (one defined
+by an `A` record). It points a subdomain to another domain.
Example:
@@ -84,14 +84,14 @@ Example:
Then you can register emails for `users@mail.example.com`.
-## TXT record
+## `TXT` record
A `TXT` record can associate arbitrary text with a host or other name. A common
use is for site verification.
Example:
-- `example.com`=> TXT => `"google-site-verification=6P08Ow5E-8Q0m6vQ7FMAqAYIDprkVV8fUf_7hZ4Qvc8"`
+- `example.com`=> `TXT` => `"google-site-verification=6P08Ow5E-8Q0m6vQ7FMAqAYIDprkVV8fUf_7hZ4Qvc8"`
This way, you can verify the ownership for that domain name.
@@ -102,4 +102,4 @@ You can have one DNS record or more than one combined:
- `example.com` => `A` => `192.192.192.192`
- `www` => `CNAME` => `example.com`
- `MX` => `mail.example.com`
-- `example.com`=> TXT => `"google-site-verification=6P08Ow5E-8Q0m6vQ7FMAqAYIDprkVV8fUf_7hZ4Qvc8"`
+- `example.com`=> `TXT` => `"google-site-verification=6P08Ow5E-8Q0m6vQ7FMAqAYIDprkVV8fUf_7hZ4Qvc8"`
diff --git a/doc/user/project/pages/custom_domains_ssl_tls_certification/index.md b/doc/user/project/pages/custom_domains_ssl_tls_certification/index.md
index ce35f8c3ebe..2c668e2c409 100644
--- a/doc/user/project/pages/custom_domains_ssl_tls_certification/index.md
+++ b/doc/user/project/pages/custom_domains_ssl_tls_certification/index.md
@@ -82,20 +82,20 @@ Follow [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/214718) for de
Root domains (`example.com`) require:
-- A [DNS A record](dns_concepts.md#a-record) pointing your domain to the Pages server.
-- A [TXT record](dns_concepts.md#txt-record) to verify your domain's ownership.
+- A [DNS `A` record](dns_concepts.md#a-record) pointing your domain to the Pages server.
+- A [`TXT` record](dns_concepts.md#txt-record) to verify your domain's ownership.
| From | DNS Record | To |
| --------------------------------------------- | ---------- | --------------- |
-| `example.com` | A | `35.185.44.232` |
-| `_gitlab-pages-verification-code.example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
+| `example.com` | `A` | `35.185.44.232` |
+| `_gitlab-pages-verification-code.example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
For projects on GitLab.com, this IP is `35.185.44.232`.
For projects living in other GitLab instances (CE or EE), please contact
your sysadmin asking for this information (which IP address is Pages
server running on your instance).
-
+
WARNING:
Note that if you use your root domain for your GitLab Pages website
@@ -111,7 +111,7 @@ as it most likely doesn't work if you set an [`MX` record](dns_concepts.md#mx-re
Subdomains (`subdomain.example.com`) require:
- A DNS [`ALIAS` or `CNAME` record](dns_concepts.md#cname-record) pointing your subdomain to the Pages server.
-- A DNS [TXT record](dns_concepts.md#txt-record) to verify your domain's ownership.
+- A DNS [`TXT` record](dns_concepts.md#txt-record) to verify your domain's ownership.
| From | DNS Record | To |
|:--------------------------------------------------------|:----------------|:----------------------|
@@ -122,7 +122,7 @@ Note that, whether it's a user or a project website, the DNS record
should point to your Pages domain (`namespace.gitlab.io`),
without any `/project-name`.
-
+
##### For both root and subdomains
@@ -137,11 +137,11 @@ They require:
| From | DNS Record | To |
| ------------------------------------------------- | ---------- | ---------------------- |
-| `example.com` | A | `35.185.44.232` |
-| `_gitlab-pages-verification-code.example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
+| `example.com` | `A` | `35.185.44.232` |
+| `_gitlab-pages-verification-code.example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
|---------------------------------------------------+------------+------------------------|
-| `www.example.com` | CNAME | `namespace.gitlab.io` |
-| `_gitlab-pages-verification-code.www.example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
+| `www.example.com` | `CNAME` | `namespace.gitlab.io` |
+| `_gitlab-pages-verification-code.www.example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
If you're using Cloudflare, check
[Redirecting `www.domain.com` to `domain.com` with Cloudflare](#redirecting-wwwdomaincom-to-domaincom-with-cloudflare).
@@ -208,15 +208,15 @@ For a root domain:
| From | DNS Record | To |
| ------------------------------------------------- | ---------- | ---------------------- |
-| `example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
-| `_gitlab-pages-verification-code.example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
+| `example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
+| `_gitlab-pages-verification-code.example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
For a subdomain:
| From | DNS Record | To |
| ------------------------------------------------- | ---------- | ---------------------- |
-| `www.example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
-| `_gitlab-pages-verification-code.www.example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
+| `www.example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
+| `_gitlab-pages-verification-code.www.example.com` | `TXT` | `gitlab-pages-verification-code=00112233445566778899aabbccddeeff` |
### Adding more domain aliases
diff --git a/lib/api/ci/job_artifacts.rb b/lib/api/ci/job_artifacts.rb
index 0800993602b..657ceb44596 100644
--- a/lib/api/ci/job_artifacts.rb
+++ b/lib/api/ci/job_artifacts.rb
@@ -3,6 +3,8 @@
module API
module Ci
class JobArtifacts < ::API::Base
+ helpers ::API::Helpers::ProjectStatsRefreshConflictsHelpers
+
before { authenticate_non_get! }
feature_category :build_artifacts
@@ -137,6 +139,8 @@ module API
build = find_build!(params[:job_id])
authorize!(:destroy_artifacts, build)
+ reject_if_build_artifacts_size_refreshing!(build.project)
+
build.erase_erasable_artifacts!
status :no_content
@@ -146,6 +150,8 @@ module API
delete ':id/artifacts' do
authorize_destroy_artifacts!
+ reject_if_build_artifacts_size_refreshing!(user_project)
+
::Ci::JobArtifacts::DeleteProjectArtifactsService.new(project: user_project).execute
accepted!
diff --git a/lib/api/ci/jobs.rb b/lib/api/ci/jobs.rb
index 04999b5fb44..97e476f2c00 100644
--- a/lib/api/ci/jobs.rb
+++ b/lib/api/ci/jobs.rb
@@ -4,6 +4,9 @@ module API
module Ci
class Jobs < ::API::Base
include PaginationParams
+
+ helpers ::API::Helpers::ProjectStatsRefreshConflictsHelpers
+
before { authenticate! }
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
@@ -137,6 +140,8 @@ module API
authorize!(:erase_build, build)
break forbidden!('Job is not erasable!') unless build.erasable?
+ reject_if_build_artifacts_size_refreshing!(build.project)
+
build.erase(erased_by: current_user)
present build, with: Entities::Ci::Job
end
diff --git a/lib/api/ci/pipelines.rb b/lib/api/ci/pipelines.rb
index 4253a9eb4d7..cd686a28dd2 100644
--- a/lib/api/ci/pipelines.rb
+++ b/lib/api/ci/pipelines.rb
@@ -5,6 +5,8 @@ module API
class Pipelines < ::API::Base
include PaginationParams
+ helpers ::API::Helpers::ProjectStatsRefreshConflictsHelpers
+
before { authenticate_non_get! }
params do
@@ -208,6 +210,8 @@ module API
delete ':id/pipelines/:pipeline_id', urgency: :low, feature_category: :continuous_integration do
authorize! :destroy_pipeline, pipeline
+ reject_if_build_artifacts_size_refreshing!(pipeline.project)
+
destroy_conditionally!(pipeline) do
::Ci::DestroyPipelineService.new(user_project, current_user).execute(pipeline)
end
diff --git a/lib/api/helpers/project_stats_refresh_conflicts_helpers.rb b/lib/api/helpers/project_stats_refresh_conflicts_helpers.rb
new file mode 100644
index 00000000000..db464521033
--- /dev/null
+++ b/lib/api/helpers/project_stats_refresh_conflicts_helpers.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module API
+ module Helpers
+ module ProjectStatsRefreshConflictsHelpers
+ def reject_if_build_artifacts_size_refreshing!(project)
+ return unless project.refreshing_build_artifacts_size?
+
+ Gitlab::ProjectStatsRefreshConflictsLogger.warn_request_rejected_during_stats_refresh(project.id)
+
+ conflict!('Action temporarily disabled. The project this pipeline belongs to is undergoing stats refresh.')
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/fix_merge_request_diff_commit_users.rb b/lib/gitlab/background_migration/fix_merge_request_diff_commit_users.rb
index ea3e56cb14a..4df55a7b02a 100644
--- a/lib/gitlab/background_migration/fix_merge_request_diff_commit_users.rb
+++ b/lib/gitlab/background_migration/fix_merge_request_diff_commit_users.rb
@@ -5,12 +5,6 @@ module Gitlab
# Background migration for fixing merge_request_diff_commit rows that don't
# have committer/author details due to
# https://gitlab.com/gitlab-org/gitlab/-/issues/344080.
- #
- # This migration acts on a single project and corrects its data. Because
- # this process needs Git/Gitaly access, and duplicating all that code is far
- # too much, this migration relies on global models such as Project,
- # MergeRequest, etc.
- # rubocop: disable Metrics/ClassLength
class FixMergeRequestDiffCommitUsers
BATCH_SIZE = 100
@@ -20,137 +14,8 @@ module Gitlab
end
def perform(project_id)
- if (project = ::Project.find_by_id(project_id))
- process(project)
- end
-
- ::Gitlab::Database::BackgroundMigrationJob.mark_all_as_succeeded(
- 'FixMergeRequestDiffCommitUsers',
- [project_id]
- )
-
- schedule_next_job
- end
-
- def process(project)
- # Loading everything using one big query may result in timeouts (e.g.
- # for projects the size of gitlab-org/gitlab). So instead we query
- # data on a per merge request basis.
- project.merge_requests.each_batch(column: :iid) do |mrs|
- mrs.ids.each do |mr_id|
- each_row_to_check(mr_id) do |commit|
- update_commit(project, commit)
- end
- end
- end
- end
-
- def each_row_to_check(merge_request_id, &block)
- columns = %w[merge_request_diff_id relative_order].map do |col|
- Pagination::Keyset::ColumnOrderDefinition.new(
- attribute_name: col,
- order_expression: MergeRequestDiffCommit.arel_table[col.to_sym].asc,
- nullable: :not_nullable,
- distinct: false
- )
- end
-
- order = Pagination::Keyset::Order.build(columns)
- scope = MergeRequestDiffCommit
- .joins(:merge_request_diff)
- .where(merge_request_diffs: { merge_request_id: merge_request_id })
- .where('commit_author_id IS NULL OR committer_id IS NULL')
- .order(order)
-
- Pagination::Keyset::Iterator
- .new(scope: scope, use_union_optimization: true)
- .each_batch(of: BATCH_SIZE) do |rows|
- rows
- .select([
- :merge_request_diff_id,
- :relative_order,
- :sha,
- :committer_id,
- :commit_author_id
- ])
- .each(&block)
- end
- end
-
- # rubocop: disable Metrics/AbcSize
- def update_commit(project, row)
- commit = find_commit(project, row.sha)
- updates = []
-
- unless row.commit_author_id
- author_id = find_or_create_user(commit, :author_name, :author_email)
-
- updates << [arel_table[:commit_author_id], author_id] if author_id
- end
-
- unless row.committer_id
- committer_id =
- find_or_create_user(commit, :committer_name, :committer_email)
-
- updates << [arel_table[:committer_id], committer_id] if committer_id
- end
-
- return if updates.empty?
-
- update = Arel::UpdateManager
- .new
- .table(MergeRequestDiffCommit.arel_table)
- .where(matches_row(row))
- .set(updates)
- .to_sql
-
- MergeRequestDiffCommit.connection.execute(update)
- end
- # rubocop: enable Metrics/AbcSize
-
- def schedule_next_job
- job = Database::BackgroundMigrationJob
- .for_migration_class('FixMergeRequestDiffCommitUsers')
- .pending
- .first
-
- return unless job
-
- BackgroundMigrationWorker.perform_in(
- 2.minutes,
- 'FixMergeRequestDiffCommitUsers',
- job.arguments
- )
- end
-
- def find_commit(project, sha)
- @commits[sha] ||= (project.commit(sha)&.to_hash || {})
- end
-
- def find_or_create_user(commit, name_field, email_field)
- name = commit[name_field]
- email = commit[email_field]
-
- return unless name && email
-
- @users[[name, email]] ||=
- MergeRequest::DiffCommitUser.find_or_create(name, email).id
- end
-
- def matches_row(row)
- primary_key = Arel::Nodes::Grouping
- .new([arel_table[:merge_request_diff_id], arel_table[:relative_order]])
-
- primary_val = Arel::Nodes::Grouping
- .new([row.merge_request_diff_id, row.relative_order])
-
- primary_key.eq(primary_val)
- end
-
- def arel_table
- MergeRequestDiffCommit.arel_table
+ # No-op, see https://gitlab.com/gitlab-org/gitlab/-/issues/344540
end
end
- # rubocop: enable Metrics/ClassLength
end
end
diff --git a/lib/gitlab/graphql/authorize/authorize_resource.rb b/lib/gitlab/graphql/authorize/authorize_resource.rb
index dc49c806398..884fc85c4ec 100644
--- a/lib/gitlab/graphql/authorize/authorize_resource.rb
+++ b/lib/gitlab/graphql/authorize/authorize_resource.rb
@@ -15,11 +15,7 @@ module Gitlab
# If the `#authorize` call is used on multiple classes, we add the
# permissions specified on a subclass, to the ones that were specified
# on its superclass.
- @required_permissions ||= if respond_to?(:superclass) && superclass.respond_to?(:required_permissions)
- superclass.required_permissions.dup
- else
- []
- end
+ @required_permissions ||= call_superclass_method(:required_permissions, []).dup
end
def authorize(*permissions)
@@ -27,6 +23,8 @@ module Gitlab
end
def authorizes_object?
+ return true if call_superclass_method(:authorizes_object?, false)
+
defined?(@authorizes_object) ? @authorizes_object : false
end
@@ -37,6 +35,14 @@ module Gitlab
def raise_resource_not_available_error!(msg = RESOURCE_ACCESS_ERROR)
raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, msg
end
+
+ private
+
+ def call_superclass_method(method_name, or_else)
+ return or_else unless respond_to?(:superclass) && superclass.respond_to?(method_name)
+
+ superclass.send(method_name) # rubocop: disable GitlabSecurity/PublicSend
+ end
end
def find_object(*args)
diff --git a/lib/gitlab/graphql/loaders/batch_model_loader.rb b/lib/gitlab/graphql/loaders/batch_model_loader.rb
index 805864cdd4c..41c3af33909 100644
--- a/lib/gitlab/graphql/loaders/batch_model_loader.rb
+++ b/lib/gitlab/graphql/loaders/batch_model_loader.rb
@@ -4,20 +4,27 @@ module Gitlab
module Graphql
module Loaders
class BatchModelLoader
- attr_reader :model_class, :model_id
+ attr_reader :model_class, :model_id, :preloads
- def initialize(model_class, model_id)
+ def initialize(model_class, model_id, preloads = nil)
@model_class = model_class
@model_id = model_id
+ @preloads = preloads || []
end
# rubocop: disable CodeReuse/ActiveRecord
def find
- BatchLoader::GraphQL.for(model_id.to_i).batch(key: model_class) do |ids, loader, args|
+ BatchLoader::GraphQL.for([model_id.to_i, preloads]).batch(key: model_class) do |for_params, loader, args|
model = args[:key]
+ keys_by_id = for_params.group_by(&:first)
+ ids = for_params.map(&:first)
+ preloads = for_params.flat_map(&:second).uniq
results = model.where(id: ids)
+ results = results.preload(*preloads) unless preloads.empty?
- results.each { |record| loader.call(record.id, record) }
+ results.each do |record|
+ keys_by_id.fetch(record.id, []).each { |k| loader.call(k, record) }
+ end
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/lib/gitlab/project_stats_refresh_conflicts_logger.rb b/lib/gitlab/project_stats_refresh_conflicts_logger.rb
index e0e71507577..49f5a544a87 100644
--- a/lib/gitlab/project_stats_refresh_conflicts_logger.rb
+++ b/lib/gitlab/project_stats_refresh_conflicts_logger.rb
@@ -11,5 +11,14 @@ module Gitlab
Gitlab::AppLogger.warn(payload)
end
+
+ def self.warn_request_rejected_during_stats_refresh(project_id)
+ payload = Gitlab::ApplicationContext.current.merge(
+ message: 'Rejected request due to project undergoing stats refresh',
+ project_id: project_id
+ )
+
+ Gitlab::AppLogger.warn(payload)
+ end
end
end
diff --git a/lib/gitlab/usage/metrics/instrumentations/count_imported_projects_total_metric.rb b/lib/gitlab/usage/metrics/instrumentations/count_imported_projects_total_metric.rb
new file mode 100644
index 00000000000..109d2245635
--- /dev/null
+++ b/lib/gitlab/usage/metrics/instrumentations/count_imported_projects_total_metric.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Usage
+ module Metrics
+ module Instrumentations
+ class CountImportedProjectsTotalMetric < DatabaseMetric
+ # Relation and operation are not used, but are included to satisfy expectations
+ # of other metric generation logic.
+ relation { Project }
+ operation :count
+
+ IMPORT_TYPES = %w(gitlab_project gitlab github bitbucket bitbucket_server gitea git manifest
+ gitlab_migration).freeze
+
+ def value
+ count(project_relation) + count(entity_relation)
+ end
+
+ def to_sql
+ project_relation_sql = Gitlab::Usage::Metrics::Query.for(:count, project_relation)
+ entity_relation_sql = Gitlab::Usage::Metrics::Query.for(:count, entity_relation)
+
+ "SELECT (#{project_relation_sql}) + (#{entity_relation_sql})"
+ end
+
+ private
+
+ def project_relation
+ Project.imported_from(IMPORT_TYPES).where(time_constraints)
+ end
+
+ def entity_relation
+ BulkImports::Entity.where(source_type: :project_entity).where(time_constraints)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 208d7c327c3..9d312b3b2fe 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -877,7 +877,7 @@ module Gitlab
gitlab_migration: add_metric('CountBulkImportsEntitiesMetric', time_frame: time_frame, options: { source_type: :project_entity })
}
- counters[:total] = add(*counters.values)
+ counters[:total] = add_metric('CountImportedProjectsTotalMetric', time_frame: time_frame)
counters
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 460e54d17b5..e79d28bc1c2 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -6490,6 +6490,9 @@ msgstr ""
msgid "Branch not loaded - %{branchId}"
msgstr ""
+msgid "Branch rules"
+msgstr ""
+
msgid "Branches"
msgstr ""
@@ -11944,6 +11947,9 @@ msgstr ""
msgid "Define how approval rules are applied to merge requests."
msgstr ""
+msgid "Define rules for who can push, merge, and the required approvals for each branch."
+msgstr ""
+
msgid "Definition"
msgstr ""
@@ -14560,9 +14566,6 @@ msgstr ""
msgid "Epics|Assign Epic"
msgstr ""
-msgid "Epics|Enter a title for your epic"
-msgstr ""
-
msgid "Epics|Leave empty to inherit from milestone dates"
msgstr ""
@@ -39388,6 +39391,9 @@ msgstr ""
msgid "Title"
msgstr ""
+msgid "Title (required)"
+msgstr ""
+
msgid "Title:"
msgstr ""
diff --git a/qa/qa/runtime/namespace.rb b/qa/qa/runtime/namespace.rb
index 6b4cbe6af6e..36726080294 100644
--- a/qa/qa/runtime/namespace.rb
+++ b/qa/qa/runtime/namespace.rb
@@ -25,7 +25,7 @@ module QA
end
def sandbox_name
- Runtime::Env.sandbox_name || 'gitlab-qa-sandbox-group'
+ @sandbox_name ||= Runtime::Env.sandbox_name || "gitlab-qa-sandbox-group-#{Time.now.wday}"
end
end
end
diff --git a/spec/controllers/help_controller_spec.rb b/spec/controllers/help_controller_spec.rb
index 70dc710f604..26e65711e9f 100644
--- a/spec/controllers/help_controller_spec.rb
+++ b/spec/controllers/help_controller_spec.rb
@@ -4,34 +4,35 @@ require 'spec_helper'
RSpec.describe HelpController do
include StubVersion
+ include DocUrlHelper
let(:user) { create(:user) }
shared_examples 'documentation pages local render' do
it 'renders HTML' do
aggregate_failures do
- is_expected.to render_template('show.html.haml')
+ is_expected.to render_template('help/show')
expect(response.media_type).to eq 'text/html'
end
end
end
shared_examples 'documentation pages redirect' do |documentation_base_url|
- let(:gitlab_version) { '13.4.0-ee' }
+ let(:gitlab_version) { version }
before do
stub_version(gitlab_version, 'ignored_revision_value')
end
it 'redirects user to custom documentation url with a specified version' do
- is_expected.to redirect_to("#{documentation_base_url}/13.4/ee/#{path}.html")
+ is_expected.to redirect_to(doc_url(documentation_base_url))
end
context 'when it is a pre-release' do
let(:gitlab_version) { '13.4.0-pre' }
it 'redirects user to custom documentation url without a version' do
- is_expected.to redirect_to("#{documentation_base_url}/ee/#{path}.html")
+ is_expected.to redirect_to(doc_url_without_version(documentation_base_url))
end
end
end
@@ -43,7 +44,7 @@ RSpec.describe HelpController do
describe 'GET #index' do
context 'with absolute url' do
it 'keeps the URL absolute' do
- stub_readme("[API](/api/README.md)")
+ stub_doc_file_read(content: "[API](/api/README.md)")
get :index
@@ -53,7 +54,7 @@ RSpec.describe HelpController do
context 'with relative url' do
it 'prefixes it with /help/' do
- stub_readme("[API](api/README.md)")
+ stub_doc_file_read(content: "[API](api/README.md)")
get :index
@@ -63,7 +64,7 @@ RSpec.describe HelpController do
context 'when url is an external link' do
it 'does not change it' do
- stub_readme("[external](https://some.external.link)")
+ stub_doc_file_read(content: "[external](https://some.external.link)")
get :index
@@ -73,7 +74,7 @@ RSpec.describe HelpController do
context 'when relative url with external on same line' do
it 'prefix it with /help/' do
- stub_readme("[API](api/README.md) [external](https://some.external.link)")
+ stub_doc_file_read(content: "[API](api/README.md) [external](https://some.external.link)")
get :index
@@ -83,7 +84,7 @@ RSpec.describe HelpController do
context 'when relative url with http:// in query' do
it 'prefix it with /help/' do
- stub_readme("[API](api/README.md?go=https://example.com/)")
+ stub_doc_file_read(content: "[API](api/README.md?go=https://example.com/)")
get :index
@@ -93,7 +94,7 @@ RSpec.describe HelpController do
context 'when mailto URL' do
it 'do not change it' do
- stub_readme("[report bug](mailto:bugs@example.com)")
+ stub_doc_file_read(content: "[report bug](mailto:bugs@example.com)")
get :index
@@ -103,7 +104,7 @@ RSpec.describe HelpController do
context 'when protocol-relative link' do
it 'do not change it' do
- stub_readme("[protocol-relative](//example.com)")
+ stub_doc_file_read(content: "[protocol-relative](//example.com)")
get :index
@@ -146,7 +147,7 @@ RSpec.describe HelpController do
context 'when requested file exists' do
before do
- expect_file_read(File.join(Rails.root, 'doc/user/ssh.md'), content: fixture_file('blockquote_fence_after.md'))
+ stub_doc_file_read(file_name: 'user/ssh.md', content: fixture_file('blockquote_fence_after.md'))
subject
end
@@ -265,10 +266,6 @@ RSpec.describe HelpController do
end
end
- def stub_readme(content)
- expect_file_read(Rails.root.join('doc', 'index.md'), content: content)
- end
-
def stub_two_factor_required
allow(controller).to receive(:two_factor_authentication_required?).and_return(true)
allow(controller).to receive(:current_user_requires_two_factor?).and_return(true)
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb
index 162c36f5069..5aafddd94da 100644
--- a/spec/controllers/projects/jobs_controller_spec.rb
+++ b/spec/controllers/projects/jobs_controller_spec.rb
@@ -1075,63 +1075,81 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do
before do
project.add_role(user, role)
sign_in(user)
-
- post_erase
end
- shared_examples_for 'erases' do
- it 'redirects to the erased job page' do
- expect(response).to have_gitlab_http_status(:found)
- expect(response).to redirect_to(namespace_project_job_path(id: job.id))
+ context 'when project is not undergoing stats refresh' do
+ before do
+ post_erase
end
- it 'erases artifacts' do
- expect(job.artifacts_file.present?).to be_falsey
- expect(job.artifacts_metadata.present?).to be_falsey
- end
-
- it 'erases trace' do
- expect(job.trace.exist?).to be_falsey
- end
- end
-
- context 'when job is successful and has artifacts' do
- let(:job) { create(:ci_build, :erasable, :trace_artifact, pipeline: pipeline) }
-
- it_behaves_like 'erases'
- end
-
- context 'when job has live trace and unarchived artifact' do
- let(:job) { create(:ci_build, :success, :trace_live, :unarchived_trace_artifact, pipeline: pipeline) }
-
- it_behaves_like 'erases'
- end
-
- context 'when job is erased' do
- let(:job) { create(:ci_build, :erased, pipeline: pipeline) }
-
- it 'returns unprocessable_entity' do
- expect(response).to have_gitlab_http_status(:unprocessable_entity)
- end
- end
-
- context 'when user is developer' do
- let(:role) { :developer }
- let(:job) { create(:ci_build, :erasable, :trace_artifact, pipeline: pipeline, user: triggered_by) }
-
- context 'when triggered by same user' do
- let(:triggered_by) { user }
-
- it 'has successful status' do
+ shared_examples_for 'erases' do
+ it 'redirects to the erased job page' do
expect(response).to have_gitlab_http_status(:found)
+ expect(response).to redirect_to(namespace_project_job_path(id: job.id))
+ end
+
+ it 'erases artifacts' do
+ expect(job.artifacts_file.present?).to be_falsey
+ expect(job.artifacts_metadata.present?).to be_falsey
+ end
+
+ it 'erases trace' do
+ expect(job.trace.exist?).to be_falsey
end
end
- context 'when triggered by different user' do
- let(:triggered_by) { create(:user) }
+ context 'when job is successful and has artifacts' do
+ let(:job) { create(:ci_build, :erasable, :trace_artifact, pipeline: pipeline) }
- it 'does not have successful status' do
- expect(response).not_to have_gitlab_http_status(:found)
+ it_behaves_like 'erases'
+ end
+
+ context 'when job has live trace and unarchived artifact' do
+ let(:job) { create(:ci_build, :success, :trace_live, :unarchived_trace_artifact, pipeline: pipeline) }
+
+ it_behaves_like 'erases'
+ end
+
+ context 'when job is erased' do
+ let(:job) { create(:ci_build, :erased, pipeline: pipeline) }
+
+ it 'returns unprocessable_entity' do
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ end
+ end
+
+ context 'when user is developer' do
+ let(:role) { :developer }
+ let(:job) { create(:ci_build, :erasable, :trace_artifact, pipeline: pipeline, user: triggered_by) }
+
+ context 'when triggered by same user' do
+ let(:triggered_by) { user }
+
+ it 'has successful status' do
+ expect(response).to have_gitlab_http_status(:found)
+ end
+ end
+
+ context 'when triggered by different user' do
+ let(:triggered_by) { create(:user) }
+
+ it 'does not have successful status' do
+ expect(response).not_to have_gitlab_http_status(:found)
+ end
+ end
+ end
+ end
+
+ context 'when project is undergoing stats refresh' do
+ it_behaves_like 'preventing request because of ongoing project stats refresh' do
+ let(:job) { create(:ci_build, :erasable, :trace_artifact, pipeline: pipeline) }
+ let(:make_request) { post_erase }
+
+ it 'does not erase artifacts' do
+ make_request
+
+ expect(job.artifacts_file).to be_present
+ expect(job.artifacts_metadata).to be_present
end
end
end
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index 1be4177acd1..b3b803649d1 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -1289,6 +1289,18 @@ RSpec.describe Projects::PipelinesController do
expect(response).to have_gitlab_http_status(:not_found)
end
end
+
+ context 'and project is undergoing stats refresh' do
+ it_behaves_like 'preventing request because of ongoing project stats refresh' do
+ let(:make_request) { delete_pipeline }
+
+ it 'does not delete the pipeline' do
+ make_request
+
+ expect(Ci::Pipeline.exists?(pipeline.id)).to be_truthy
+ end
+ end
+ end
end
context 'when user has no privileges' do
diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb
index 20a114bbe8c..9bb34a38005 100644
--- a/spec/controllers/projects/project_members_controller_spec.rb
+++ b/spec/controllers/projects/project_members_controller_spec.rb
@@ -170,6 +170,46 @@ RSpec.describe Projects::ProjectMembersController do
expect(requester.reload.human_access).to eq(label)
end
end
+
+ describe 'managing project direct owners' do
+ context 'when a Maintainer tries to elevate another user to OWNER' do
+ it 'does not allow the operation' do
+ params = {
+ project_member: { access_level: Gitlab::Access::OWNER },
+ namespace_id: project.namespace,
+ project_id: project,
+ id: requester
+ }
+
+ put :update, params: params, xhr: true
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when a user with OWNER access tries to elevate another user to OWNER' do
+ # inherited owner role via personal project association
+ let(:user) { project.first_owner }
+
+ before do
+ sign_in(user)
+ end
+
+ it 'returns success' do
+ params = {
+ project_member: { access_level: Gitlab::Access::OWNER },
+ namespace_id: project.namespace,
+ project_id: project,
+ id: requester
+ }
+
+ put :update, params: params, xhr: true
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(requester.reload.access_level).to eq(Gitlab::Access::OWNER)
+ end
+ end
+ end
end
context 'access expiry date' do
@@ -275,19 +315,40 @@ RSpec.describe Projects::ProjectMembersController do
context 'when member is found' do
context 'when user does not have enough rights' do
- before do
- project.add_developer(user)
+ context 'when user does not have rights to manage other members' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'returns 404', :aggregate_failures do
+ delete :destroy, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: member
+ }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(project.members).to include member
+ end
end
- it 'returns 404', :aggregate_failures do
- delete :destroy, params: {
- namespace_id: project.namespace,
- project_id: project,
- id: member
- }
+ context 'when user does not have rights to manage Owner members' do
+ let_it_be(:member) { create(:project_member, project: project, access_level: Gitlab::Access::OWNER) }
- expect(response).to have_gitlab_http_status(:not_found)
- expect(project.members).to include member
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'returns 403', :aggregate_failures do
+ delete :destroy, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: member
+ }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(project.members).to include member
+ end
end
end
@@ -434,7 +495,7 @@ RSpec.describe Projects::ProjectMembersController do
end
context 'when member is found' do
- context 'when user does not have enough rights' do
+ context 'when user does not have rights to manage other members' do
before do
project.add_developer(user)
end
diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb
index 4e1b55d3d70..cfdd3d9224d 100644
--- a/spec/features/projects/settings/repository_settings_spec.rb
+++ b/spec/features/projects/settings/repository_settings_spec.rb
@@ -39,6 +39,22 @@ RSpec.describe 'Projects > Settings > Repository settings' do
end
end
+ context 'Branch rules', :js do
+ it 'renders branch rules settings' do
+ visit project_settings_repository_path(project)
+ expect(page).to have_content('Branch rules')
+ end
+
+ context 'branch_rules feature flag disabled', :js do
+ it 'does not render branch rules settings' do
+ stub_feature_flags(branch_rules: false)
+ visit project_settings_repository_path(project)
+
+ expect(page).not_to have_content('Branch rules')
+ end
+ end
+ end
+
context 'Deploy Keys', :js do
let_it_be(:private_deploy_key) { create(:deploy_key, title: 'private_deploy_key', public: false) }
let_it_be(:public_deploy_key) { create(:another_deploy_key, title: 'public_deploy_key', public: true) }
diff --git a/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js b/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js
index 703767dab47..f4cbc56be5c 100644
--- a/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js
+++ b/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js
@@ -1,4 +1,4 @@
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlSkeletonLoader } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { shallowMount } from '@vue/test-utils';
import UsageCounts from '~/analytics/usage_trends/components/usage_counts.vue';
@@ -30,7 +30,7 @@ describe('UsageCounts', () => {
wrapper.destroy();
});
- const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoading);
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findAllSingleStats = () => wrapper.findAllComponents(GlSingleStat);
describe('while loading', () => {
diff --git a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
index 1926f3e268e..fe20c23e4d7 100644
--- a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
+++ b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
@@ -1,4 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
+import { Emitter } from 'monaco-editor';
+import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import {
@@ -64,7 +66,6 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
afterEach(() => {
instance.dispose();
- editorEl.remove();
mockAxios.restore();
resetHTMLFixture();
});
@@ -75,11 +76,47 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
actions: expect.any(Object),
shown: false,
modelChangeListener: undefined,
+ layoutChangeListener: {
+ dispose: expect.anything(),
+ },
path: previewMarkdownPath,
actionShowPreviewCondition: expect.any(Object),
});
});
+ describe('onDidLayoutChange', () => {
+ const emitter = new Emitter();
+ let layoutSpy;
+
+ useFakeRequestAnimationFrame();
+
+ beforeEach(() => {
+ instance.unuse(extension);
+ instance.onDidLayoutChange = emitter.event;
+ extension = instance.use({
+ definition: EditorMarkdownPreviewExtension,
+ setupOptions: { previewMarkdownPath },
+ });
+ layoutSpy = jest.spyOn(instance, 'layout');
+ });
+
+ it('does not trigger the layout when the preview is not active [default]', async () => {
+ expect(instance.markdownPreview.shown).toBe(false);
+ expect(layoutSpy).not.toHaveBeenCalled();
+ await emitter.fire();
+ expect(layoutSpy).not.toHaveBeenCalled();
+ });
+
+ it('triggers the layout if the preview panel is opened', async () => {
+ expect(layoutSpy).not.toHaveBeenCalled();
+ instance.togglePreview();
+ layoutSpy.mockReset();
+
+ await emitter.fire();
+ expect(layoutSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
describe('model change listener', () => {
let cleanupSpy;
let actionSpy;
@@ -111,6 +148,9 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
mockAxios.onPost().reply(200, { body: responseData });
await togglePreview();
});
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
it('removes the registered buttons from the toolbar', () => {
expect(instance.toolbar.removeItems).not.toHaveBeenCalled();
@@ -175,6 +215,31 @@ describe('Markdown Live Preview Extension for Source Editor', () => {
instance.unuse(extension);
expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true);
});
+
+ it('disposes the layoutChange listener and does not re-layout on layout changes', () => {
+ expect(instance.markdownPreview.layoutChangeListener).toBeDefined();
+ instance.unuse(extension);
+
+ expect(instance.markdownPreview?.layoutChangeListener).toBeUndefined();
+ });
+
+ it('does not trigger the re-layout after instance is unused', async () => {
+ const emitter = new Emitter();
+
+ instance.unuse(extension);
+ instance.onDidLayoutChange = emitter.event;
+
+ // we have to re-use the extension to pick up the emitter
+ extension = instance.use({
+ definition: EditorMarkdownPreviewExtension,
+ setupOptions: { previewMarkdownPath },
+ });
+ instance.unuse(extension);
+ const layoutSpy = jest.spyOn(instance, 'layout');
+
+ await emitter.fire();
+ expect(layoutSpy).not.toHaveBeenCalled();
+ });
});
describe('fetchPreview', () => {
diff --git a/spec/frontend/editor/source_editor_webide_ext_spec.js b/spec/frontend/editor/source_editor_webide_ext_spec.js
new file mode 100644
index 00000000000..096b6b1646f
--- /dev/null
+++ b/spec/frontend/editor/source_editor_webide_ext_spec.js
@@ -0,0 +1,55 @@
+import { Emitter } from 'monaco-editor';
+import { setHTMLFixture } from 'helpers/fixtures';
+import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext';
+import SourceEditor from '~/editor/source_editor';
+
+describe('Source Editor Web IDE Extension', () => {
+ let editorEl;
+ let editor;
+ let instance;
+
+ beforeEach(() => {
+ setHTMLFixture('');
+ editorEl = document.getElementById('editor');
+ editor = new SourceEditor();
+ });
+ afterEach(() => {});
+
+ describe('onSetup', () => {
+ it.each`
+ width | renderSideBySide
+ ${'0'} | ${false}
+ ${'699px'} | ${false}
+ ${'700px'} | ${true}
+ `(
+ "correctly renders the Diff Editor when the parent element's width is $width",
+ ({ width, renderSideBySide }) => {
+ editorEl.style.width = width;
+ instance = editor.createDiffInstance({ el: editorEl });
+
+ const sideBySideSpy = jest.spyOn(instance, 'updateOptions');
+ instance.use({ definition: EditorWebIdeExtension });
+
+ expect(sideBySideSpy).toBeCalledWith({ renderSideBySide });
+ },
+ );
+
+ it('re-renders the Diff Editor when layout of the modified editor is changed', async () => {
+ const emitter = new Emitter();
+ editorEl.style.width = '700px';
+
+ instance = editor.createDiffInstance({ el: editorEl });
+ instance.getModifiedEditor().onDidLayoutChange = emitter.event;
+ instance.use({ definition: EditorWebIdeExtension });
+
+ const sideBySideSpy = jest.spyOn(instance, 'updateOptions');
+ await emitter.fire();
+
+ expect(sideBySideSpy).toBeCalledWith({ renderSideBySide: true });
+
+ editorEl.style.width = '0px';
+ await emitter.fire();
+ expect(sideBySideSpy).toBeCalledWith({ renderSideBySide: false });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index 9a30fd5f5c3..b48372afdea 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -11,19 +11,13 @@ import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markd
import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext';
import SourceEditor from '~/editor/source_editor';
import RepoEditor from '~/ide/components/repo_editor.vue';
-import {
- leftSidebarViews,
- FILE_VIEW_MODE_EDITOR,
- FILE_VIEW_MODE_PREVIEW,
- viewerTypes,
-} from '~/ide/constants';
+import { leftSidebarViews, FILE_VIEW_MODE_PREVIEW, viewerTypes } from '~/ide/constants';
import ModelManager from '~/ide/lib/common/model_manager';
import service from '~/ide/services';
import { createStoreOptions } from '~/ide/stores';
import axios from '~/lib/utils/axios_utils';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import SourceEditorInstance from '~/editor/source_editor_instance';
-import { spyOnApi } from 'jest/editor/helpers';
import { file } from '../helpers';
const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown';
@@ -196,11 +190,8 @@ describe('RepoEditor', () => {
});
describe('when files is markdown', () => {
- let layoutSpy;
-
beforeEach(async () => {
await createComponent({ activeFile });
- layoutSpy = jest.spyOn(wrapper.vm.editor, 'layout');
});
it('renders an Edit and a Preview Tab', () => {
@@ -217,10 +208,6 @@ describe('RepoEditor', () => {
expect(wrapper.find(ContentViewer).html()).toContain(defaultFileProps.content);
});
- it('should not trigger layout', async () => {
- expect(layoutSpy).not.toHaveBeenCalled();
- });
-
describe('when file changes to non-markdown file', () => {
beforeEach(async () => {
wrapper.setProps({ file: dummyFile.empty });
@@ -229,10 +216,6 @@ describe('RepoEditor', () => {
it('should hide tabs', () => {
expect(findTabs()).toHaveLength(0);
});
-
- it('should trigger refresh dimensions', async () => {
- expect(layoutSpy).toHaveBeenCalledTimes(1);
- });
});
});
@@ -373,53 +356,6 @@ describe('RepoEditor', () => {
});
});
- describe('editor updateDimensions', () => {
- let updateDimensionsSpy;
- beforeEach(async () => {
- await createComponent();
- const ext = extensionsStore.get('EditorWebIde');
- updateDimensionsSpy = jest.fn();
- spyOnApi(ext, {
- updateDimensions: updateDimensionsSpy,
- });
- });
-
- it('calls updateDimensions only when panelResizing is false', async () => {
- expect(updateDimensionsSpy).not.toHaveBeenCalled();
- expect(vm.$store.state.panelResizing).toBe(false); // default value
-
- vm.$store.state.panelResizing = true;
- await nextTick();
-
- expect(updateDimensionsSpy).not.toHaveBeenCalled();
-
- vm.$store.state.panelResizing = false;
- await nextTick();
-
- expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
-
- vm.$store.state.panelResizing = true;
- await nextTick();
-
- expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
- });
-
- it('calls updateDimensions when rightPane is toggled', async () => {
- expect(updateDimensionsSpy).not.toHaveBeenCalled();
- expect(vm.$store.state.rightPane.isOpen).toBe(false); // default value
-
- vm.$store.state.rightPane.isOpen = true;
- await nextTick();
-
- expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
-
- vm.$store.state.rightPane.isOpen = false;
- await nextTick();
-
- expect(updateDimensionsSpy).toHaveBeenCalledTimes(2);
- });
- });
-
describe('editor tabs', () => {
beforeEach(async () => {
await createComponent();
@@ -439,7 +375,6 @@ describe('RepoEditor', () => {
});
describe('files in preview mode', () => {
- let updateDimensionsSpy;
const changeViewMode = (viewMode) =>
vm.$store.dispatch('editor/updateFileEditor', {
path: vm.file.path,
@@ -451,12 +386,6 @@ describe('RepoEditor', () => {
activeFile: dummyFile.markdown,
});
- const ext = extensionsStore.get('EditorWebIde');
- updateDimensionsSpy = jest.fn();
- spyOnApi(ext, {
- updateDimensions: updateDimensionsSpy,
- });
-
changeViewMode(FILE_VIEW_MODE_PREVIEW);
await nextTick();
});
@@ -465,15 +394,6 @@ describe('RepoEditor', () => {
expect(vm.showEditor).toBe(false);
expect(findEditor().isVisible()).toBe(false);
});
-
- it('updates dimensions when switching view back to edit', async () => {
- expect(updateDimensionsSpy).not.toHaveBeenCalled();
-
- changeViewMode(FILE_VIEW_MODE_EDITOR);
- await nextTick();
-
- expect(updateDimensionsSpy).toHaveBeenCalled();
- });
});
describe('initEditor', () => {
diff --git a/spec/frontend/projects/settings/repository/branch_rules/app_spec.js b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js
new file mode 100644
index 00000000000..e12c3aeedd6
--- /dev/null
+++ b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js
@@ -0,0 +1,18 @@
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import BranchRules from '~/projects/settings/repository/branch_rules/app.vue';
+
+describe('Branch rules app', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = mountExtended(BranchRules);
+ };
+
+ const findTitle = () => wrapper.find('strong');
+
+ beforeEach(() => createComponent());
+
+ it('renders a title', () => {
+ expect(findTitle().text()).toBe('Branch');
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js
index 4985417ad99..e7a98d2ebee 100644
--- a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js
+++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js
@@ -279,9 +279,9 @@ describe('MRWidget approvals', () => {
it('revoke action is rendered', () => {
expect(findActionData()).toEqual({
- variant: 'warning',
+ category: 'primary',
+ variant: 'default',
text: 'Revoke approval',
- category: 'secondary',
});
});
diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
index cf2ed3331b7..30e15595193 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
@@ -12,7 +12,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
right="true"
size="medium"
text="Clone"
- variant="info"
+ variant="confirm"
>
an_instance_of(String),
- 'meta.feature_category' => 'test'
+ 'meta.feature_category' => 'test',
+ 'meta.caller_id' => 'caller'
)
)
described_class.warn_artifact_deletion_during_stats_refresh(project_id: project_id, method: method)
end
end
+
+ describe '.warn_request_rejected_during_stats_refresh' do
+ it 'logs a warning about artifacts being deleted while the project is undergoing stats refresh' do
+ project_id = 123
+
+ expect(Gitlab::AppLogger).to receive(:warn).with(
+ hash_including(
+ message: 'Rejected request due to project undergoing stats refresh',
+ project_id: project_id,
+ 'correlation_id' => an_instance_of(String),
+ 'meta.feature_category' => 'test',
+ 'meta.caller_id' => 'caller'
+ )
+ )
+
+ described_class.warn_request_rejected_during_stats_refresh(project_id)
+ end
+ end
end
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/count_imported_projects_total_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/count_imported_projects_total_metric_spec.rb
new file mode 100644
index 00000000000..bfc4240def6
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/count_imported_projects_total_metric_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::CountImportedProjectsTotalMetric do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:gitea_imports) do
+ create_list(:project, 3, import_type: 'gitea', creator_id: user.id, created_at: 3.weeks.ago)
+ end
+
+ let_it_be(:bitbucket_imports) do
+ create_list(:project, 2, import_type: 'bitbucket', creator_id: user.id, created_at: 3.weeks.ago)
+ end
+
+ let_it_be(:old_import) { create(:project, import_type: 'gitea', creator_id: user.id, created_at: 2.months.ago) }
+
+ let_it_be(:bulk_import_projects) do
+ create_list(:bulk_import_entity, 3, source_type: 'project_entity', created_at: 3.weeks.ago)
+ end
+
+ let_it_be(:bulk_import_groups) do
+ create_list(:bulk_import_entity, 3, source_type: 'group_entity', created_at: 3.weeks.ago)
+ end
+
+ let_it_be(:old_bulk_import_project) do
+ create(:bulk_import_entity, source_type: 'project_entity', created_at: 2.months.ago)
+ end
+
+ before do
+ allow(ApplicationRecord.connection).to receive(:transaction_open?).and_return(false)
+ end
+
+ context 'with all time frame' do
+ let(:expected_value) { 10 }
+ let(:expected_query) do
+ "SELECT (SELECT COUNT(\"projects\".\"id\") FROM \"projects\" WHERE \"projects\".\"import_type\""\
+ " IN ('gitlab_project', 'gitlab', 'github', 'bitbucket', 'bitbucket_server', 'gitea', 'git', 'manifest',"\
+ " 'gitlab_migration'))"\
+ " + (SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\""\
+ " WHERE \"bulk_import_entities\".\"source_type\" = 1)"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query', time_frame: 'all'
+ end
+
+ context 'for 28d time frame' do
+ let(:expected_value) { 8 }
+ let(:start) { 30.days.ago.to_s(:db) }
+ let(:finish) { 2.days.ago.to_s(:db) }
+ let(:expected_query) do
+ "SELECT (SELECT COUNT(\"projects\".\"id\") FROM \"projects\" WHERE \"projects\".\"import_type\""\
+ " IN ('gitlab_project', 'gitlab', 'github', 'bitbucket', 'bitbucket_server', 'gitea', 'git', 'manifest',"\
+ " 'gitlab_migration')"\
+ " AND \"projects\".\"created_at\" BETWEEN '#{start}' AND '#{finish}')"\
+ " + (SELECT COUNT(\"bulk_import_entities\".\"id\") FROM \"bulk_import_entities\""\
+ " WHERE \"bulk_import_entities\".\"source_type\" = 1 AND \"bulk_import_entities\".\"created_at\""\
+ " BETWEEN '#{start}' AND '#{finish}')"
+ end
+
+ it_behaves_like 'a correct instrumented metric value and query', time_frame: '28d'
+ end
+end
diff --git a/spec/migrations/20220502015011_clean_up_fix_merge_request_diff_commit_users_spec.rb b/spec/migrations/20220502015011_clean_up_fix_merge_request_diff_commit_users_spec.rb
index 769c0993b67..2bc3e89a748 100644
--- a/spec/migrations/20220502015011_clean_up_fix_merge_request_diff_commit_users_spec.rb
+++ b/spec/migrations/20220502015011_clean_up_fix_merge_request_diff_commit_users_spec.rb
@@ -15,21 +15,5 @@ RSpec.describe CleanUpFixMergeRequestDiffCommitUsers, :migration do
migrate!
end
-
- it 'processes pending background jobs' do
- project = projects.create!(name: 'p1', namespace_id: namespace.id, project_namespace_id: project_namespace.id)
-
- Gitlab::Database::BackgroundMigrationJob.create!(
- class_name: 'FixMergeRequestDiffCommitUsers',
- arguments: [project.id]
- )
-
- migrate!
-
- background_migrations = Gitlab::Database::BackgroundMigrationJob
- .where(class_name: 'FixMergeRequestDiffCommitUsers')
-
- expect(background_migrations.count).to eq(0)
- end
end
end
diff --git a/spec/models/project_group_link_spec.rb b/spec/models/project_group_link_spec.rb
index c925d87170c..8b95b86b14b 100644
--- a/spec/models/project_group_link_spec.rb
+++ b/spec/models/project_group_link_spec.rb
@@ -30,6 +30,12 @@ RSpec.describe ProjectGroupLink do
expect(project_group_link).not_to be_valid
end
+
+ it 'does not allow a project to be shared with `OWNER` access level' do
+ project_group_link.group_access = Gitlab::Access::OWNER
+
+ expect(project_group_link).not_to be_valid
+ end
end
describe 'scopes' do
diff --git a/spec/requests/api/ci/job_artifacts_spec.rb b/spec/requests/api/ci/job_artifacts_spec.rb
index 1dd1ca4e115..2fa1ffb4974 100644
--- a/spec/requests/api/ci/job_artifacts_spec.rb
+++ b/spec/requests/api/ci/job_artifacts_spec.rb
@@ -41,42 +41,58 @@ RSpec.describe API::Ci::JobArtifacts do
describe 'DELETE /projects/:id/jobs/:job_id/artifacts' do
let!(:job) { create(:ci_build, :artifacts, pipeline: pipeline, user: api_user) }
- before do
- delete api("/projects/#{project.id}/jobs/#{job.id}/artifacts", api_user)
- end
-
- context 'when user is anonymous' do
- let(:api_user) { nil }
-
- it 'does not delete artifacts' do
- expect(job.job_artifacts.size).to eq 2
+ context 'when project is not undergoing stats refresh' do
+ before do
+ delete api("/projects/#{project.id}/jobs/#{job.id}/artifacts", api_user)
end
- it 'returns status 401 (unauthorized)' do
- expect(response).to have_gitlab_http_status(:unauthorized)
+ context 'when user is anonymous' do
+ let(:api_user) { nil }
+
+ it 'does not delete artifacts' do
+ expect(job.job_artifacts.size).to eq 2
+ end
+
+ it 'returns status 401 (unauthorized)' do
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'with developer' do
+ it 'does not delete artifacts' do
+ expect(job.job_artifacts.size).to eq 2
+ end
+
+ it 'returns status 403 (forbidden)' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'with authorized user' do
+ let(:maintainer) { create(:project_member, :maintainer, project: project).user }
+ let!(:api_user) { maintainer }
+
+ it 'deletes artifacts' do
+ expect(job.job_artifacts.size).to eq 0
+ end
+
+ it 'returns status 204 (no content)' do
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
end
end
- context 'with developer' do
- it 'does not delete artifacts' do
- expect(job.job_artifacts.size).to eq 2
- end
+ context 'when project is undergoing stats refresh' do
+ it_behaves_like 'preventing request because of ongoing project stats refresh' do
+ let(:maintainer) { create(:project_member, :maintainer, project: project).user }
+ let(:api_user) { maintainer }
+ let(:make_request) { delete api("/projects/#{project.id}/jobs/#{job.id}/artifacts", api_user) }
- it 'returns status 403 (forbidden)' do
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
+ it 'does not delete artifacts' do
+ make_request
- context 'with authorized user' do
- let(:maintainer) { create(:project_member, :maintainer, project: project).user }
- let!(:api_user) { maintainer }
-
- it 'deletes artifacts' do
- expect(job.job_artifacts.size).to eq 0
- end
-
- it 'returns status 204 (no content)' do
- expect(response).to have_gitlab_http_status(:no_content)
+ expect(job.job_artifacts.size).to eq 2
+ end
end
end
end
@@ -131,6 +147,22 @@ RSpec.describe API::Ci::JobArtifacts do
expect(response).to have_gitlab_http_status(:accepted)
end
+
+ context 'when project is undergoing stats refresh' do
+ let!(:job) { create(:ci_build, :artifacts, pipeline: pipeline, user: api_user) }
+
+ it_behaves_like 'preventing request because of ongoing project stats refresh' do
+ let(:maintainer) { create(:project_member, :maintainer, project: project).user }
+ let(:api_user) { maintainer }
+ let(:make_request) { delete api("/projects/#{project.id}/artifacts", api_user) }
+
+ it 'does not delete artifacts' do
+ make_request
+
+ expect(job.job_artifacts.size).to eq 2
+ end
+ end
+ end
end
end
diff --git a/spec/requests/api/ci/jobs_spec.rb b/spec/requests/api/ci/jobs_spec.rb
index 4bd9f81fd1d..a6cf2dc6d9f 100644
--- a/spec/requests/api/ci/jobs_spec.rb
+++ b/spec/requests/api/ci/jobs_spec.rb
@@ -655,62 +655,80 @@ RSpec.describe API::Ci::Jobs do
before do
project.add_role(user, role)
-
- post api("/projects/#{project.id}/jobs/#{job.id}/erase", user)
end
- shared_examples_for 'erases job' do
- it 'erases job content' do
- expect(response).to have_gitlab_http_status(:created)
- expect(job.job_artifacts.count).to eq(0)
- expect(job.trace.exist?).to be_falsy
- expect(job.artifacts_file.present?).to be_falsy
- expect(job.artifacts_metadata.present?).to be_falsy
- expect(job.has_job_artifacts?).to be_falsy
+ context 'when project is not undergoing stats refresh' do
+ before do
+ post api("/projects/#{project.id}/jobs/#{job.id}/erase", user)
+ end
+
+ shared_examples_for 'erases job' do
+ it 'erases job content' do
+ expect(response).to have_gitlab_http_status(:created)
+ expect(job.job_artifacts.count).to eq(0)
+ expect(job.trace.exist?).to be_falsy
+ expect(job.artifacts_file.present?).to be_falsy
+ expect(job.artifacts_metadata.present?).to be_falsy
+ expect(job.has_job_artifacts?).to be_falsy
+ end
+ end
+
+ context 'job is erasable' do
+ let(:job) { create(:ci_build, :trace_artifact, :artifacts, :test_reports, :success, project: project, pipeline: pipeline) }
+
+ it_behaves_like 'erases job'
+
+ it 'updates job' do
+ job.reload
+
+ expect(job.erased_at).to be_truthy
+ expect(job.erased_by).to eq(user)
+ end
+ end
+
+ context 'when job has an unarchived trace artifact' do
+ let(:job) { create(:ci_build, :success, :trace_live, :unarchived_trace_artifact, project: project, pipeline: pipeline) }
+
+ it_behaves_like 'erases job'
+ end
+
+ context 'job is not erasable' do
+ let(:job) { create(:ci_build, :trace_live, project: project, pipeline: pipeline) }
+
+ it 'responds with forbidden' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when a developer erases a build' do
+ let(:role) { :developer }
+ let(:job) { create(:ci_build, :trace_artifact, :artifacts, :success, project: project, pipeline: pipeline, user: owner) }
+
+ context 'when the build was created by the developer' do
+ let(:owner) { user }
+
+ it { expect(response).to have_gitlab_http_status(:created) }
+ end
+
+ context 'when the build was created by another user' do
+ let(:owner) { create(:user) }
+
+ it { expect(response).to have_gitlab_http_status(:forbidden) }
+ end
end
end
- context 'job is erasable' do
+ context 'when project is undergoing stats refresh' do
let(:job) { create(:ci_build, :trace_artifact, :artifacts, :test_reports, :success, project: project, pipeline: pipeline) }
- it_behaves_like 'erases job'
+ it_behaves_like 'preventing request because of ongoing project stats refresh' do
+ let(:make_request) { post api("/projects/#{project.id}/jobs/#{job.id}/erase", user) }
- it 'updates job' do
- job.reload
+ it 'does not delete artifacts' do
+ make_request
- expect(job.erased_at).to be_truthy
- expect(job.erased_by).to eq(user)
- end
- end
-
- context 'when job has an unarchived trace artifact' do
- let(:job) { create(:ci_build, :success, :trace_live, :unarchived_trace_artifact, project: project, pipeline: pipeline) }
-
- it_behaves_like 'erases job'
- end
-
- context 'job is not erasable' do
- let(:job) { create(:ci_build, :trace_live, project: project, pipeline: pipeline) }
-
- it 'responds with forbidden' do
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'when a developer erases a build' do
- let(:role) { :developer }
- let(:job) { create(:ci_build, :trace_artifact, :artifacts, :success, project: project, pipeline: pipeline, user: owner) }
-
- context 'when the build was created by the developer' do
- let(:owner) { user }
-
- it { expect(response).to have_gitlab_http_status(:created) }
- end
-
- context 'when the build was created by the other' do
- let(:owner) { create(:user) }
-
- it { expect(response).to have_gitlab_http_status(:forbidden) }
+ expect(job.reload.job_artifacts).not_to be_empty
+ end
end
end
end
diff --git a/spec/requests/api/ci/pipelines_spec.rb b/spec/requests/api/ci/pipelines_spec.rb
index 12faeec94da..697fe16e222 100644
--- a/spec/requests/api/ci/pipelines_spec.rb
+++ b/spec/requests/api/ci/pipelines_spec.rb
@@ -1018,6 +1018,18 @@ RSpec.describe API::Ci::Pipelines do
expect { build.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
+
+ context 'when project is undergoing stats refresh' do
+ it_behaves_like 'preventing request because of ongoing project stats refresh' do
+ let(:make_request) { delete api("/projects/#{project.id}/pipelines/#{pipeline.id}", owner) }
+
+ it 'does not delete the pipeline' do
+ make_request
+
+ expect(pipeline.reload).to be_persisted
+ end
+ end
+ end
end
context 'unauthorized user' do
diff --git a/spec/requests/api/graphql/milestone_spec.rb b/spec/requests/api/graphql/milestone_spec.rb
index 59de116fa2b..f6835936418 100644
--- a/spec/requests/api/graphql/milestone_spec.rb
+++ b/spec/requests/api/graphql/milestone_spec.rb
@@ -5,43 +5,125 @@ require 'spec_helper'
RSpec.describe 'Querying a Milestone' do
include GraphqlHelpers
- let_it_be(:current_user) { create(:user) }
+ let_it_be(:guest) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:milestone) { create(:milestone, project: project) }
+ let_it_be(:release_a) { create(:release, project: project) }
+ let_it_be(:release_b) { create(:release, project: project) }
- let(:query) do
- graphql_query_for('milestone', { id: milestone.to_global_id.to_s }, 'title')
+ before_all do
+ milestone.releases << [release_a, release_b]
+ project.add_guest(guest)
end
- subject { graphql_data['milestone'] }
-
- before do
- post_graphql(query, current_user: current_user)
+ let(:expected_release_nodes) do
+ contain_exactly(a_graphql_entity_for(release_a), a_graphql_entity_for(release_b))
end
- context 'when the user has access to the milestone' do
- before_all do
- project.add_guest(current_user)
- end
-
- it_behaves_like 'a working graphql query'
-
- it { is_expected.to include('title' => milestone.name) }
- end
-
- context 'when the user does not have access to the milestone' do
- it_behaves_like 'a working graphql query'
-
- it { is_expected.to be_nil }
- end
-
- context 'when ID argument is missing' do
+ context 'when we post the query' do
+ let(:current_user) { nil }
let(:query) do
- graphql_query_for('milestone', {}, 'title')
+ graphql_query_for('milestone', { id: milestone.to_global_id.to_s }, all_graphql_fields_for('Milestone'))
end
- it 'raises an exception' do
- expect(graphql_errors).to include(a_hash_including('message' => "Field 'milestone' is missing required arguments: id"))
+ subject { graphql_data['milestone'] }
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ context 'when the user has access to the milestone' do
+ let(:current_user) { guest }
+
+ it_behaves_like 'a working graphql query'
+
+ it { is_expected.to include('title' => milestone.name) }
+
+ it 'contains release information' do
+ is_expected.to include('releases' => include('nodes' => expected_release_nodes))
+ end
+ end
+
+ context 'when the user does not have access to the milestone' do
+ it_behaves_like 'a working graphql query'
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when ID argument is missing' do
+ let(:query) do
+ graphql_query_for('milestone', {}, 'title')
+ end
+
+ it 'raises an exception' do
+ expect(graphql_errors).to include(a_hash_including('message' => "Field 'milestone' is missing required arguments: id"))
+ end
+ end
+ end
+
+ context 'when there are two milestones' do
+ let_it_be(:milestone_b) { create(:milestone, project: project) }
+
+ let(:current_user) { guest }
+ let(:milestone_fields) do
+ <<~GQL
+ fragment milestoneFields on Milestone {
+ #{all_graphql_fields_for('Milestone', max_depth: 1)}
+ releases { nodes { #{all_graphql_fields_for('Release', max_depth: 1)} } }
+ }
+ GQL
+ end
+
+ let(:single_query) do
+ <<~GQL
+ query ($id_a: MilestoneID!) {
+ a: milestone(id: $id_a) { ...milestoneFields }
+ }
+
+ #{milestone_fields}
+ GQL
+ end
+
+ let(:multi_query) do
+ <<~GQL
+ query ($id_a: MilestoneID!, $id_b: MilestoneID!) {
+ a: milestone(id: $id_a) { ...milestoneFields }
+ b: milestone(id: $id_b) { ...milestoneFields }
+ }
+ #{milestone_fields}
+ GQL
+ end
+
+ it 'produces correct results' do
+ r = run_with_clean_state(multi_query,
+ context: { current_user: current_user },
+ variables: {
+ id_a: global_id_of(milestone).to_s,
+ id_b: milestone_b.to_global_id.to_s
+ })
+
+ expect(r.to_h['errors']).to be_blank
+ expect(graphql_dig_at(r.to_h, :data, :a, :releases, :nodes)).to match expected_release_nodes
+ expect(graphql_dig_at(r.to_h, :data, :b, :releases, :nodes)).to be_empty
+ end
+
+ it 'does not suffer from N+1 performance issues' do
+ baseline = ActiveRecord::QueryRecorder.new do
+ run_with_clean_state(single_query,
+ context: { current_user: current_user },
+ variables: { id_a: milestone.to_global_id.to_s })
+ end
+
+ multi = ActiveRecord::QueryRecorder.new do
+ run_with_clean_state(multi_query,
+ context: { current_user: current_user },
+ variables: {
+ id_a: milestone.to_global_id.to_s,
+ id_b: milestone_b.to_global_id.to_s
+ })
+ end
+
+ expect(multi).not_to exceed_query_limit(baseline)
end
end
end
diff --git a/spec/requests/api/graphql/mutations/ci/pipeline_destroy_spec.rb b/spec/requests/api/graphql/mutations/ci/pipeline_destroy_spec.rb
index 37656ab4eea..7abd5ca8772 100644
--- a/spec/requests/api/graphql/mutations/ci/pipeline_destroy_spec.rb
+++ b/spec/requests/api/graphql/mutations/ci/pipeline_destroy_spec.rb
@@ -28,4 +28,21 @@ RSpec.describe 'PipelineDestroy' do
expect(response).to have_gitlab_http_status(:success)
expect { pipeline.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
+
+ context 'when project is undergoing stats refresh' do
+ before do
+ create(:project_build_artifacts_size_refresh, :pending, project: pipeline.project)
+ end
+
+ it 'returns an error and does not destroy the pipeline' do
+ expect(Gitlab::ProjectStatsRefreshConflictsLogger)
+ .to receive(:warn_request_rejected_during_stats_refresh)
+ .with(pipeline.project.id)
+
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(graphql_mutation_response(:pipeline_destroy)['errors']).not_to be_empty
+ expect(pipeline.reload).to be_persisted
+ end
+ end
end
diff --git a/spec/requests/api/graphql/project/milestones_spec.rb b/spec/requests/api/graphql/project/milestones_spec.rb
index 3e8948d83b1..d1ee157fc74 100644
--- a/spec/requests/api/graphql/project/milestones_spec.rb
+++ b/spec/requests/api/graphql/project/milestones_spec.rb
@@ -59,6 +59,27 @@ RSpec.describe 'getting milestone listings nested in a project' do
end
end
+ context 'the user does not have access' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:milestones) { create_list(:milestone, 2, project: project) }
+
+ it 'is nil' do
+ post_graphql(query, current_user: current_user)
+
+ expect(graphql_data_at(:project)).to be_nil
+ end
+
+ context 'the user has access' do
+ let(:expected) { milestones }
+
+ before do
+ project.add_guest(current_user)
+ end
+
+ it_behaves_like 'searching with parameters'
+ end
+ end
+
context 'there are no search params' do
let(:search_params) { nil }
let(:expected) { all_milestones }
diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb
index 0db42e7439c..94f1bf13830 100644
--- a/spec/requests/api/members_spec.rb
+++ b/spec/requests/api/members_spec.rb
@@ -4,13 +4,14 @@ require 'spec_helper'
RSpec.describe API::Members do
let(:maintainer) { create(:user, username: 'maintainer_user') }
+ let(:maintainer2) { create(:user, username: 'user-with-maintainer-role') }
let(:developer) { create(:user) }
let(:access_requester) { create(:user) }
let(:stranger) { create(:user) }
let(:user_with_minimal_access) { create(:user) }
let(:project) do
- create(:project, :public, creator_id: maintainer.id, namespace: maintainer.namespace) do |project|
+ create(:project, :public, creator_id: maintainer.id, group: create(:group, :public)) do |project|
project.add_maintainer(maintainer)
project.add_developer(developer, current_user: maintainer)
project.request_access(access_requester)
@@ -238,21 +239,48 @@ RSpec.describe API::Members do
expect(response).to have_gitlab_http_status(:forbidden)
end
end
+
+ context 'adding a member of higher access level' do
+ before do
+ # the other 'maintainer' is in fact an owner of the group!
+ source.add_maintainer(maintainer2)
+ end
+
+ context 'when an access requester' do
+ it 'is not successful' do
+ post api("/#{source_type.pluralize}/#{source.id}/members", maintainer2),
+ params: { user_id: access_requester.id, access_level: Member::OWNER }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when a totally new user' do
+ it 'is not successful' do
+ post api("/#{source_type.pluralize}/#{source.id}/members", maintainer2),
+ params: { user_id: stranger.id, access_level: Member::OWNER }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
end
end
- context 'when authenticated as a maintainer/owner' do
+ context 'when authenticated as a member with membership management rights' do
context 'and new member is already a requester' do
- it 'transforms the requester into a proper member' do
- expect do
- post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
- params: { user_id: access_requester.id, access_level: Member::MAINTAINER }
+ context 'when the requester is of equal or lower access level' do
+ it 'transforms the requester into a proper member' do
+ expect do
+ post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
+ params: { user_id: access_requester.id, access_level: Member::MAINTAINER }
- expect(response).to have_gitlab_http_status(:created)
- end.to change { source.members.count }.by(1)
- expect(source.requesters.count).to eq(0)
- expect(json_response['id']).to eq(access_requester.id)
- expect(json_response['access_level']).to eq(Member::MAINTAINER)
+ expect(response).to have_gitlab_http_status(:created)
+ end.to change { source.members.count }.by(1)
+ expect(source.requesters.count).to eq(0)
+ expect(json_response['id']).to eq(access_requester.id)
+ expect(json_response['access_level']).to eq(Member::MAINTAINER)
+ end
end
end
@@ -430,7 +458,7 @@ RSpec.describe API::Members do
it 'returns 404 when the user_id is not valid' do
post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
- params: { user_id: 0, access_level: Member::MAINTAINER }
+ params: { user_id: non_existing_record_id, access_level: Member::MAINTAINER }
expect(response).to have_gitlab_http_status(:not_found)
expect(json_response['message']).to eq('404 User Not Found')
@@ -500,16 +528,49 @@ RSpec.describe API::Members do
end
end
end
+
+ context 'as a maintainer updating a member to one with higher access level than themselves' do
+ before do
+ # the other 'maintainer' is in fact an owner of the group!
+ source.add_maintainer(maintainer2)
+ end
+
+ it 'returns 403' do
+ put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", maintainer2),
+ params: { access_level: Member::OWNER }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
end
context 'when authenticated as a maintainer/owner' do
- it 'updates the member' do
- put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", maintainer),
- params: { access_level: Member::MAINTAINER }
+ context 'when updating a member with the same or lower access level' do
+ it 'updates the member' do
+ put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", maintainer),
+ params: { access_level: Member::MAINTAINER }
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['id']).to eq(developer.id)
- expect(json_response['access_level']).to eq(Member::MAINTAINER)
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['id']).to eq(developer.id)
+ expect(json_response['access_level']).to eq(Member::MAINTAINER)
+ end
+ end
+
+ context 'when updating a member with higher access level' do
+ let(:owner) { create(:user) }
+
+ before do
+ source.add_owner(owner)
+ # the other 'maintainer' is in fact an owner of the group!
+ source.add_maintainer(maintainer2)
+ end
+
+ it 'returns 403' do
+ put api("/#{source_type.pluralize}/#{source.id}/members/#{owner.id}", maintainer2),
+ params: { access_level: Member::DEVELOPER }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
end
end
@@ -604,6 +665,23 @@ RSpec.describe API::Members do
end
end
+ context 'when attempting to delete a member with higher access level' do
+ let(:owner) { create(:user) }
+
+ before do
+ source.add_owner(owner)
+ # the other 'maintainer' is in fact an owner of the group!
+ source.add_maintainer(maintainer2)
+ end
+
+ it 'returns 403' do
+ delete api("/#{source_type.pluralize}/#{source.id}/members/#{owner.id}", maintainer2),
+ params: { access_level: Member::DEVELOPER }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
it 'deletes the member' do
expect do
delete api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", maintainer)
@@ -679,13 +757,11 @@ RSpec.describe API::Members do
end
context 'adding owner to project' do
- it 'returns created status' do
- expect do
- post api("/projects/#{project.id}/members", maintainer),
- params: { user_id: stranger.id, access_level: Member::OWNER }
+ it 'returns 403' do
+ post api("/projects/#{project.id}/members", maintainer),
+ params: { user_id: stranger.id, access_level: Member::OWNER }
- expect(response).to have_gitlab_http_status(:created)
- end.to change { project.members.count }.by(1)
+ expect(response).to have_gitlab_http_status(:forbidden)
end
end
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index d2189ab02ea..431d2e56cb5 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -3106,6 +3106,13 @@ RSpec.describe API::Projects do
expect(json_response['error']).to eq 'group_access does not have a valid value'
end
+ it "returns a 400 error when the project-group share is created with an OWNER access level" do
+ post api("/projects/#{project.id}/share", user), params: { group_id: group.id, group_access: Gitlab::Access::OWNER }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq 'group_access does not have a valid value'
+ end
+
it "returns a 409 error when link is not saved" do
allow(::Projects::GroupLinks::CreateService).to receive_message_chain(:new, :execute)
.and_return({ status: :error, http_status: 409, message: 'error' })
diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb
index 730175af0bb..e79e13af769 100644
--- a/spec/services/members/create_service_spec.rb
+++ b/spec/services/members/create_service_spec.rb
@@ -33,6 +33,18 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
it 'raises a Gitlab::Access::AccessDeniedError' do
expect { execute_service }.to raise_error(Gitlab::Access::AccessDeniedError)
end
+
+ context 'when a project maintainer attempts to add owners' do
+ let(:access_level) { Gitlab::Access::OWNER }
+
+ before do
+ source.add_maintainer(current_user)
+ end
+
+ it 'raises a Gitlab::Access::AccessDeniedError' do
+ expect { execute_service }.to raise_error(Gitlab::Access::AccessDeniedError)
+ end
+ end
end
context 'when passing an invalid source' do
diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb
index 1a1283b1078..9f0daba3327 100644
--- a/spec/services/members/destroy_service_spec.rb
+++ b/spec/services/members/destroy_service_spec.rb
@@ -105,26 +105,46 @@ RSpec.describe Members::DestroyService do
context 'with a project member' do
let(:member) { group_project.members.find_by(user_id: member_user.id) }
- before do
- group_project.add_developer(member_user)
+ context 'when current user does not have any membership management permissions' do
+ before do
+ group_project.add_developer(member_user)
+ end
+
+ it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError'
+
+ context 'when skipping authorisation' do
+ it_behaves_like 'a service destroying a member with access' do
+ let(:opts) { { skip_authorization: true, unassign_issuables: true } }
+ end
+ end
end
- it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError'
+ context 'when a project maintainer tries to destroy a project owner' do
+ before do
+ group_project.add_owner(member_user)
+ end
- it_behaves_like 'a service destroying a member with access' do
- let(:opts) { { skip_authorization: true, unassign_issuables: true } }
+ it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError'
+
+ context 'when skipping authorisation' do
+ it_behaves_like 'a service destroying a member with access' do
+ let(:opts) { { skip_authorization: true, unassign_issuables: true } }
+ end
+ end
end
end
+ end
- context 'with a group member' do
- let(:member) { group.members.find_by(user_id: member_user.id) }
+ context 'with a group member' do
+ let(:member) { group.members.find_by(user_id: member_user.id) }
- before do
- group.add_developer(member_user)
- end
+ before do
+ group.add_developer(member_user)
+ end
- it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError'
+ it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError'
+ context 'when skipping authorisation' do
it_behaves_like 'a service destroying a member with access' do
let(:opts) { { skip_authorization: true, unassign_issuables: true } }
end
diff --git a/spec/services/members/groups/bulk_creator_service_spec.rb b/spec/services/members/groups/bulk_creator_service_spec.rb
index 0623ae00080..3922c37487c 100644
--- a/spec/services/members/groups/bulk_creator_service_spec.rb
+++ b/spec/services/members/groups/bulk_creator_service_spec.rb
@@ -3,8 +3,12 @@
require 'spec_helper'
RSpec.describe Members::Groups::BulkCreatorService do
+ let_it_be(:source, reload: true) { create(:group, :public) }
+ let_it_be(:current_user) { create(:user) }
+
it_behaves_like 'bulk member creation' do
- let_it_be(:source, reload: true) { create(:group, :public) }
let_it_be(:member_type) { GroupMember }
end
+
+ it_behaves_like 'owner management'
end
diff --git a/spec/services/members/projects/bulk_creator_service_spec.rb b/spec/services/members/projects/bulk_creator_service_spec.rb
index 7acb7d79fe7..dd998b47eb3 100644
--- a/spec/services/members/projects/bulk_creator_service_spec.rb
+++ b/spec/services/members/projects/bulk_creator_service_spec.rb
@@ -3,8 +3,12 @@
require 'spec_helper'
RSpec.describe Members::Projects::BulkCreatorService do
+ let_it_be(:source, reload: true) { create(:project, :public) }
+ let_it_be(:current_user) { create(:user) }
+
it_behaves_like 'bulk member creation' do
- let_it_be(:source, reload: true) { create(:project, :public) }
let_it_be(:member_type) { ProjectMember }
end
+
+ it_behaves_like 'owner management'
end
diff --git a/spec/services/members/update_service_spec.rb b/spec/services/members/update_service_spec.rb
index a1b1397d444..f919d6d1516 100644
--- a/spec/services/members/update_service_spec.rb
+++ b/spec/services/members/update_service_spec.rb
@@ -9,8 +9,9 @@ RSpec.describe Members::UpdateService do
let(:member_user) { create(:user) }
let(:permission) { :update }
let(:member) { source.members_and_requesters.find_by!(user_id: member_user.id) }
+ let(:access_level) { Gitlab::Access::MAINTAINER }
let(:params) do
- { access_level: Gitlab::Access::MAINTAINER }
+ { access_level: access_level }
end
subject { described_class.new(current_user, params).execute(member, permission: permission) }
@@ -29,7 +30,7 @@ RSpec.describe Members::UpdateService do
updated_member = subject.fetch(:member)
expect(updated_member).to be_valid
- expect(updated_member.access_level).to eq(Gitlab::Access::MAINTAINER)
+ expect(updated_member.access_level).to eq(access_level)
end
it 'returns success status' do
@@ -111,4 +112,75 @@ RSpec.describe Members::UpdateService do
let(:source) { group }
end
end
+
+ context 'in a project' do
+ let_it_be(:group_project) { create(:project, group: create(:group)) }
+
+ let(:source) { group_project }
+
+ context 'a project maintainer' do
+ before do
+ group_project.add_maintainer(current_user)
+ end
+
+ context 'cannot update a member to OWNER' do
+ before do
+ group_project.add_developer(member_user)
+ end
+
+ it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
+ let(:access_level) { Gitlab::Access::OWNER }
+ end
+ end
+
+ context 'cannot update themselves to OWNER' do
+ let(:member) { source.members_and_requesters.find_by!(user_id: current_user.id) }
+
+ before do
+ group_project.add_developer(member_user)
+ end
+
+ it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
+ let(:access_level) { Gitlab::Access::OWNER }
+ end
+ end
+
+ context 'cannot downgrade a member from OWNER' do
+ before do
+ group_project.add_owner(member_user)
+ end
+
+ it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError' do
+ let(:access_level) { Gitlab::Access::MAINTAINER }
+ end
+ end
+ end
+
+ context 'owners' do
+ before do
+ # so that `current_user` is considered an `OWNER` in the project via inheritance.
+ group_project.group.add_owner(current_user)
+ end
+
+ context 'can update a member to OWNER' do
+ before do
+ group_project.add_developer(member_user)
+ end
+
+ it_behaves_like 'a service updating a member' do
+ let(:access_level) { Gitlab::Access::OWNER }
+ end
+ end
+
+ context 'can downgrade a member from OWNER' do
+ before do
+ group_project.add_owner(member_user)
+ end
+
+ it_behaves_like 'a service updating a member' do
+ let(:access_level) { Gitlab::Access::MAINTAINER }
+ end
+ end
+ end
+ end
end
diff --git a/spec/support/graphql/resolver_factories.rb b/spec/support/graphql/resolver_factories.rb
index 8188f17cc43..3c5aad34e8b 100644
--- a/spec/support/graphql/resolver_factories.rb
+++ b/spec/support/graphql/resolver_factories.rb
@@ -15,8 +15,8 @@ module Graphql
private
- def simple_resolver(resolved_value = 'Resolved value')
- Class.new(Resolvers::BaseResolver) do
+ def simple_resolver(resolved_value = 'Resolved value', base_class: Resolvers::BaseResolver)
+ Class.new(base_class) do
define_method :resolve do |**_args|
resolved_value
end
diff --git a/spec/support/helpers/doc_url_helper.rb b/spec/support/helpers/doc_url_helper.rb
new file mode 100644
index 00000000000..bbff4827c56
--- /dev/null
+++ b/spec/support/helpers/doc_url_helper.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module DocUrlHelper
+ def version
+ "13.4.0-ee"
+ end
+
+ def doc_url(documentation_base_url)
+ "#{documentation_base_url}/13.4/ee/#{path}.html"
+ end
+
+ def doc_url_without_version(documentation_base_url)
+ "#{documentation_base_url}/ee/#{path}.html"
+ end
+
+ def stub_doc_file_read(file_name: 'index.md', content: )
+ expect_file_read(File.join(Rails.root, 'doc', file_name), content: content)
+ end
+end
+
+DocUrlHelper.prepend_mod
diff --git a/spec/support/matchers/exceed_query_limit.rb b/spec/support/matchers/exceed_query_limit.rb
index b48c7f905b2..23f43e05a10 100644
--- a/spec/support/matchers/exceed_query_limit.rb
+++ b/spec/support/matchers/exceed_query_limit.rb
@@ -323,7 +323,12 @@ RSpec::Matchers.define :exceed_query_limit do |expected|
include ExceedQueryLimitHelpers
match do |block|
- verify_count(&block)
+ if block.is_a?(ActiveRecord::QueryRecorder)
+ @recorder = block
+ verify_count
+ else
+ verify_count(&block)
+ end
end
failure_message_when_negated do |actual|
diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb
index e50083a10e7..7396643823c 100644
--- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb
+++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb
@@ -75,7 +75,7 @@ RSpec.shared_context 'ProjectPolicy context' do
let(:base_owner_permissions) do
%i[
archive_project change_namespace change_visibility_level destroy_issue
- destroy_merge_request remove_fork_project remove_project rename_project
+ destroy_merge_request manage_owners remove_fork_project remove_project rename_project
set_issue_created_at set_issue_iid set_issue_updated_at
set_note_created_at
]
diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb
index e293d10964b..0537c3b7327 100644
--- a/spec/support/shared_examples/models/member_shared_examples.rb
+++ b/spec/support/shared_examples/models/member_shared_examples.rb
@@ -401,6 +401,15 @@ RSpec.shared_examples_for "bulk member creation" do
let_it_be(:user1) { create(:user) }
let_it_be(:user2) { create(:user) }
+ context 'when current user does not have permission' do
+ it 'does not succeed' do
+ # maintainers cannot add owners
+ source.add_maintainer(user)
+
+ expect(described_class.add_users(source, [user1, user2], :owner, current_user: user)).to be_empty
+ end
+ end
+
it 'returns a Member objects' do
members = described_class.add_users(source, [user1, user2], :maintainer)
@@ -546,3 +555,29 @@ RSpec.shared_examples_for "bulk member creation" do
end
end
end
+
+RSpec.shared_examples 'owner management' do
+ describe '.cannot_manage_owners?' do
+ subject { described_class.cannot_manage_owners?(source, current_user) }
+
+ context 'when maintainer' do
+ before do
+ source.add_maintainer(current_user)
+ end
+
+ it 'cannot manage owners' do
+ expect(subject).to be_truthy
+ end
+ end
+
+ context 'when owner' do
+ before do
+ source.add_owner(current_user)
+ end
+
+ it 'can manage owners' do
+ expect(subject).to be_falsey
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/members_notifications_shared_example.rb b/spec/support/shared_examples/models/members_notifications_shared_example.rb
index 04af3935d15..75eed0203a7 100644
--- a/spec/support/shared_examples/models/members_notifications_shared_example.rb
+++ b/spec/support/shared_examples/models/members_notifications_shared_example.rb
@@ -33,6 +33,18 @@ RSpec.shared_examples 'members notifications' do |entity_type|
end
end
+ describe '#after_commit' do
+ context 'on creation of a member requesting access' do
+ let(:member) { build(:"#{entity_type}_member", :access_request) }
+
+ it "calls NotificationService.new_access_request" do
+ expect(notification_service).to receive(:new_access_request).with(member)
+
+ member.save!
+ end
+ end
+ end
+
describe '#accept_request' do
let(:member) { create(:"#{entity_type}_member", :access_request) }
diff --git a/spec/support/shared_examples/requests/api/project_statistics_refresh_conflicts_shared_examples.rb b/spec/support/shared_examples/requests/api/project_statistics_refresh_conflicts_shared_examples.rb
new file mode 100644
index 00000000000..7c3f4781472
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/project_statistics_refresh_conflicts_shared_examples.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'preventing request because of ongoing project stats refresh' do |entrypoint|
+ before do
+ create(:project_build_artifacts_size_refresh, :pending, project: project)
+ end
+
+ it 'logs about the rejected request' do
+ expect(Gitlab::ProjectStatsRefreshConflictsLogger)
+ .to receive(:warn_request_rejected_during_stats_refresh)
+ .with(project.id)
+
+ make_request
+ end
+
+ it 'returns 409 error' do
+ make_request
+
+ expect(response).to have_gitlab_http_status(:conflict)
+ end
+end