From ceb0c326ae57bac76fe40ca3471b0ee5d152f58e Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 2 Sep 2021 03:09:04 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .../components/board_card_deprecated.vue | 61 --- .../board_card_layout_deprecated.vue | 91 ---- .../components/board_column_deprecated.vue | 112 ----- .../components/board_list_deprecated.vue | 459 ------------------ .../board_list_header_deprecated.vue | 361 -------------- .../components/board_new_issue_deprecated.vue | 138 ------ .../issue_card_inner_deprecated.vue | 247 ---------- .../issue_time_estimate_deprecated.vue | 48 -- .../components/project_select_deprecated.vue | 146 ------ config/application.rb | 9 + config/database.yml.postgresql | 80 +-- .../development/gitaly_backup.yml | 8 - .../initializers/validate_database_config.rb | 31 ++ ...32600_remove_duplicate_dast_site_tokens.rb | 33 ++ ...ndex_dast_site_token_project_id_and_url.rb | 19 + ...511_prepare_async_indexes_for_ci_builds.rb | 80 +++ db/schema_migrations/20210823132600 | 1 + db/schema_migrations/20210826193907 | 1 + db/schema_migrations/20210901184511 | 1 + db/structure.sql | 6 +- doc/raketasks/backup_restore.md | 27 -- doc/user/group/saml_sso/index.md | 62 +-- doc/user/group/saml_sso/scim_setup.md | 31 +- lib/backup/gitaly_backup.rb | 4 - lib/backup/gitaly_rpc_backup.rb | 132 ----- lib/backup/repositories.rb | 126 +---- lib/gitlab/database.rb | 3 + lib/gitlab/patch/legacy_database_config.rb | 56 +++ lib/tasks/gitlab/backup.rake | 21 +- locale/gitlab.pot | 12 - package.json | 2 +- .../boards/board_list_deprecated_spec.js | 274 ----------- .../boards/board_new_issue_deprecated_spec.js | 211 -------- .../components/board_card_deprecated_spec.js | 181 ------- .../board_card_layout_deprecated_spec.js | 154 ------ .../board_column_deprecated_spec.js | 106 ---- .../board_list_header_deprecated_spec.js | 174 ------- .../boards_selector_deprecated_spec.js | 214 -------- .../issue_time_estimate_deprecated_spec.js | 64 --- .../boards/issue_card_deprecated_spec.js | 332 ------------- .../boards/project_select_deprecated_spec.js | 263 ---------- .../validate_database_config_spec.rb | 166 +++++++ spec/lib/backup/gitaly_rpc_backup_spec.rb | 153 ------ spec/lib/backup/repositories_spec.rb | 137 +----- .../patch/legacy_database_config_spec.rb | 123 +++++ .../remove_duplicate_dast_site_tokens_spec.rb | 53 ++ spec/tasks/gitlab/backup_rake_spec.rb | 20 - yarn.lock | 8 +- 48 files changed, 701 insertions(+), 4340 deletions(-) delete mode 100644 app/assets/javascripts/boards/components/board_card_deprecated.vue delete mode 100644 app/assets/javascripts/boards/components/board_card_layout_deprecated.vue delete mode 100644 app/assets/javascripts/boards/components/board_column_deprecated.vue delete mode 100644 app/assets/javascripts/boards/components/board_list_deprecated.vue delete mode 100644 app/assets/javascripts/boards/components/board_list_header_deprecated.vue delete mode 100644 app/assets/javascripts/boards/components/board_new_issue_deprecated.vue delete mode 100644 app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue delete mode 100644 app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue delete mode 100644 app/assets/javascripts/boards/components/project_select_deprecated.vue delete mode 100644 config/feature_flags/development/gitaly_backup.yml create mode 100644 config/initializers/validate_database_config.rb create mode 100644 db/post_migrate/20210823132600_remove_duplicate_dast_site_tokens.rb create mode 100644 db/post_migrate/20210826193907_add_unique_index_dast_site_token_project_id_and_url.rb create mode 100644 db/post_migrate/20210901184511_prepare_async_indexes_for_ci_builds.rb create mode 100644 db/schema_migrations/20210823132600 create mode 100644 db/schema_migrations/20210826193907 create mode 100644 db/schema_migrations/20210901184511 delete mode 100644 lib/backup/gitaly_rpc_backup.rb create mode 100644 lib/gitlab/patch/legacy_database_config.rb delete mode 100644 spec/frontend/boards/board_list_deprecated_spec.js delete mode 100644 spec/frontend/boards/board_new_issue_deprecated_spec.js delete mode 100644 spec/frontend/boards/components/board_card_deprecated_spec.js delete mode 100644 spec/frontend/boards/components/board_card_layout_deprecated_spec.js delete mode 100644 spec/frontend/boards/components/board_column_deprecated_spec.js delete mode 100644 spec/frontend/boards/components/board_list_header_deprecated_spec.js delete mode 100644 spec/frontend/boards/components/boards_selector_deprecated_spec.js delete mode 100644 spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js delete mode 100644 spec/frontend/boards/issue_card_deprecated_spec.js delete mode 100644 spec/frontend/boards/project_select_deprecated_spec.js create mode 100644 spec/initializers/validate_database_config_spec.rb delete mode 100644 spec/lib/backup/gitaly_rpc_backup_spec.rb create mode 100644 spec/lib/gitlab/patch/legacy_database_config_spec.rb create mode 100644 spec/migrations/remove_duplicate_dast_site_tokens_spec.rb diff --git a/app/assets/javascripts/boards/components/board_card_deprecated.vue b/app/assets/javascripts/boards/components/board_card_deprecated.vue deleted file mode 100644 index e12a2836f67..00000000000 --- a/app/assets/javascripts/boards/components/board_card_deprecated.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue b/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue deleted file mode 100644 index 030bc828406..00000000000 --- a/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue +++ /dev/null @@ -1,91 +0,0 @@ - - - diff --git a/app/assets/javascripts/boards/components/board_column_deprecated.vue b/app/assets/javascripts/boards/components/board_column_deprecated.vue deleted file mode 100644 index 7c090dfaa53..00000000000 --- a/app/assets/javascripts/boards/components/board_column_deprecated.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue deleted file mode 100644 index fabaf7a85f5..00000000000 --- a/app/assets/javascripts/boards/components/board_list_deprecated.vue +++ /dev/null @@ -1,459 +0,0 @@ - - - diff --git a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue deleted file mode 100644 index bc29728fc55..00000000000 --- a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue +++ /dev/null @@ -1,361 +0,0 @@ - - - diff --git a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue deleted file mode 100644 index a25b436b8de..00000000000 --- a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue +++ /dev/null @@ -1,138 +0,0 @@ - - - diff --git a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue deleted file mode 100644 index 6e90731cc2f..00000000000 --- a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue +++ /dev/null @@ -1,247 +0,0 @@ - - diff --git a/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue b/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue deleted file mode 100644 index 8ddf50cb357..00000000000 --- a/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - diff --git a/app/assets/javascripts/boards/components/project_select_deprecated.vue b/app/assets/javascripts/boards/components/project_select_deprecated.vue deleted file mode 100644 index fc95ba0461d..00000000000 --- a/app/assets/javascripts/boards/components/project_select_deprecated.vue +++ /dev/null @@ -1,146 +0,0 @@ - - - diff --git a/config/application.rb b/config/application.rb index 06a5a726d92..2349de4892f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -31,9 +31,18 @@ module Gitlab require_dependency Rails.root.join('lib/gitlab/middleware/handle_malformed_strings') require_dependency Rails.root.join('lib/gitlab/middleware/rack_multipart_tempfile_factory') require_dependency Rails.root.join('lib/gitlab/runtime') + require_dependency Rails.root.join('lib/gitlab/patch/legacy_database_config') config.autoloader = :zeitwerk + # To be removed in 15.0 + # This preload is needed to convert legacy `database.yml` + # from `production: adapter: postgresql` + # into a `production: main: adapter: postgresql` + unless Gitlab::Utils.to_boolean(ENV['SKIP_DATABASE_CONFIG_VALIDATION'], default: false) + config.class.prepend(::Gitlab::Patch::LegacyDatabaseConfig) + end + # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. diff --git a/config/database.yml.postgresql b/config/database.yml.postgresql index ca1ff4db1b4..a4daab1fd0c 100644 --- a/config/database.yml.postgresql +++ b/config/database.yml.postgresql @@ -2,56 +2,60 @@ # PRODUCTION # production: - adapter: postgresql - encoding: unicode - database: gitlabhq_production - username: git - password: "secure password" - host: localhost - # load_balancing: - # hosts: - # - host1.example.com - # - host2.example.com - # discover: - # nameserver: 1.2.3.4 - # port: 8600 - # record: secondary.postgresql.service.consul - # interval: 300 + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + # load_balancing: + # hosts: + # - host1.example.com + # - host2.example.com + # discover: + # nameserver: 1.2.3.4 + # port: 8600 + # record: secondary.postgresql.service.consul + # interval: 300 # # Development specific # development: - adapter: postgresql - encoding: unicode - database: gitlabhq_development - username: postgres - password: "secure password" - host: localhost - variables: - statement_timeout: 15s + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_development + username: postgres + password: "secure password" + host: localhost + variables: + statement_timeout: 15s # # Staging specific # staging: - adapter: postgresql - encoding: unicode - database: gitlabhq_staging - username: git - password: "secure password" - host: localhost + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_staging + username: git + password: "secure password" + host: localhost # Warning: The database defined as "test" will be erased and # re-generated from your development database when you run "rake". # Do not set this db to the same as development or production. test: &test - adapter: postgresql - encoding: unicode - database: gitlabhq_test - username: postgres - password: - host: localhost - prepared_statements: false - variables: - statement_timeout: 15s + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_test + username: postgres + password: + host: localhost + prepared_statements: false + variables: + statement_timeout: 15s diff --git a/config/feature_flags/development/gitaly_backup.yml b/config/feature_flags/development/gitaly_backup.yml deleted file mode 100644 index 67552d39d92..00000000000 --- a/config/feature_flags/development/gitaly_backup.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: gitaly_backup -introduced_by_url: https://gitlab.com/gitlab-org/gitaly/-/merge_requests/3554 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/333034 -milestone: '14.0' -type: development -group: group::gitaly -default_enabled: true diff --git a/config/initializers/validate_database_config.rb b/config/initializers/validate_database_config.rb new file mode 100644 index 00000000000..a651db8b783 --- /dev/null +++ b/config/initializers/validate_database_config.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +if Gitlab::Utils.to_boolean(ENV['SKIP_DATABASE_CONFIG_VALIDATION'], default: false) + return +end + +if Rails.application.config.uses_legacy_database_config + warn "WARNING: This installation of GitLab uses a deprecated syntax for 'config/database.yml'. " \ + "The support for this syntax will be removed in 15.0. " \ + "More information can be found here: https://gitlab.com/gitlab-org/gitlab/-/issues/338182" +end + +if configurations = ActiveRecord::Base.configurations.configurations + if configurations.first.name != Gitlab::Database::MAIN_DATABASE_NAME + raise "ERROR: This installation of GitLab uses unsupported 'config/database.yml'. " \ + "The `main:` database needs to be defined as a first configuration item instead of `#{configurations.first.name}`." + end + + rejected_config_names = configurations.map(&:name).to_set - Gitlab::Database::DATABASE_NAMES + if rejected_config_names.any? + raise "ERROR: This installation of GitLab uses unsupported database names " \ + "in 'config/database.yml': #{rejected_config_names.to_a.join(", ")}. The only supported ones are " \ + "#{Gitlab::Database::DATABASE_NAMES.join(", ")}." + end + + replicas_config_names = configurations.select(&:replica?).map(&:name) + if replicas_config_names.any? + raise "ERROR: This installation of GitLab uses unsupported database configuration " \ + "with 'replica: true' parameter in 'config/database.yml' for: #{replicas_config_names.join(", ")}" + end +end diff --git a/db/post_migrate/20210823132600_remove_duplicate_dast_site_tokens.rb b/db/post_migrate/20210823132600_remove_duplicate_dast_site_tokens.rb new file mode 100644 index 00000000000..35cf3b55200 --- /dev/null +++ b/db/post_migrate/20210823132600_remove_duplicate_dast_site_tokens.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class RemoveDuplicateDastSiteTokens < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + class DastSiteToken < ApplicationRecord + self.table_name = 'dast_site_tokens' + self.inheritance_column = :_type_disabled + + scope :duplicates, -> do + all_duplicates = select(:project_id, :url) + .distinct + .group(:project_id, :url) + .having('count(*) > 1') + .pluck('array_agg(id) as ids') + + duplicate_ids = extract_duplicate_ids(all_duplicates) + + where(id: duplicate_ids) + end + + def self.extract_duplicate_ids(duplicates) + duplicates.flat_map { |ids| ids.first(ids.size - 1) } + end + end + + def up + DastSiteToken.duplicates.delete_all + end + + def down + end +end diff --git a/db/post_migrate/20210826193907_add_unique_index_dast_site_token_project_id_and_url.rb b/db/post_migrate/20210826193907_add_unique_index_dast_site_token_project_id_and_url.rb new file mode 100644 index 00000000000..1e65d5647e4 --- /dev/null +++ b/db/post_migrate/20210826193907_add_unique_index_dast_site_token_project_id_and_url.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddUniqueIndexDastSiteTokenProjectIdAndUrl < ActiveRecord::Migration[6.1] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + INDEX_NAME = 'index_dast_site_token_on_project_id_and_url' + + def up + add_concurrent_index :dast_site_tokens, [:project_id, :url], name: INDEX_NAME, unique: true + end + + def down + remove_concurrent_index_by_name :dast_site_tokens, name: INDEX_NAME + end +end diff --git a/db/post_migrate/20210901184511_prepare_async_indexes_for_ci_builds.rb b/db/post_migrate/20210901184511_prepare_async_indexes_for_ci_builds.rb new file mode 100644 index 00000000000..47795c5d646 --- /dev/null +++ b/db/post_migrate/20210901184511_prepare_async_indexes_for_ci_builds.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +class PrepareAsyncIndexesForCiBuilds < Gitlab::Database::Migration[1.0] + def up + prepare_async_index :ci_builds, :stage_id_convert_to_bigint, name: :index_ci_builds_on_converted_stage_id + + prepare_async_index :ci_builds, [:commit_id, :artifacts_expire_at, :id_convert_to_bigint], + where: "type::text = 'Ci::Build'::text + AND (retried = false OR retried IS NULL) + AND (name::text = ANY (ARRAY['sast'::character varying::text, + 'secret_detection'::character varying::text, + 'dependency_scanning'::character varying::text, + 'container_scanning'::character varying::text, + 'dast'::character varying::text]))", + name: :index_ci_builds_on_commit_id_expire_at_and_converted_id + + prepare_async_index :ci_builds, [:project_id, :id_convert_to_bigint], + name: :index_ci_builds_on_project_and_converted_id + + prepare_async_index :ci_builds, [:runner_id, :id_convert_to_bigint], + order: { id_convert_to_bigint: :desc }, + name: :index_ci_builds_on_runner_id_and_converted_id_desc + + prepare_async_index :ci_builds, [:resource_group_id, :id_convert_to_bigint], + where: 'resource_group_id IS NOT NULL', + name: :index_ci_builds_on_resource_group_and_converted_id + + prepare_async_index :ci_builds, [:name, :id_convert_to_bigint], + where: "(name::text = ANY (ARRAY['container_scanning'::character varying::text, + 'dast'::character varying::text, + 'dependency_scanning'::character varying::text, + 'license_management'::character varying::text, + 'sast'::character varying::text, + 'secret_detection'::character varying::text, + 'coverage_fuzzing'::character varying::text, + 'license_scanning'::character varying::text]) + ) AND type::text = 'Ci::Build'::text", + name: :index_security_ci_builds_on_name_and_converted_id_parser + + prepare_async_index_from_sql(:ci_builds, :index_ci_builds_runner_id_and_converted_id_pending_covering, <<~SQL.squish) + CREATE INDEX CONCURRENTLY index_ci_builds_runner_id_and_converted_id_pending_covering + ON ci_builds (runner_id, id_convert_to_bigint) INCLUDE (project_id) + WHERE status::text = 'pending'::text AND type::text = 'Ci::Build'::text + SQL + end + + def down + unprepare_async_index_by_name :ci_builds, :index_ci_builds_runner_id_and_converted_id_pending_covering + + unprepare_async_index_by_name :ci_builds, :index_security_ci_builds_on_name_and_converted_id_parser + + unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_resource_group_and_converted_id + + unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_runner_id_and_converted_id_desc + + unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_project_and_converted_id + + unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_commit_id_expire_at_and_converted_id + + unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_converted_stage_id + end + + private + + def prepare_async_index_from_sql(table_name, index_name, definition) + return unless async_index_creation_available? + + return if index_name_exists?(table_name, index_name) + + async_index = Gitlab::Database::AsyncIndexes::PostgresAsyncIndex.find_or_create_by!(name: index_name) do |rec| + rec.table_name = table_name + rec.definition = definition + end + + Gitlab::AppLogger.info( + message: 'Prepared index for async creation', + table_name: async_index.table_name, + index_name: async_index.name) + end +end diff --git a/db/schema_migrations/20210823132600 b/db/schema_migrations/20210823132600 new file mode 100644 index 00000000000..85ab3b55ee4 --- /dev/null +++ b/db/schema_migrations/20210823132600 @@ -0,0 +1 @@ +7324c3803c910338261556c65cae5d0827e78b77890386e402e056d480c3486b \ No newline at end of file diff --git a/db/schema_migrations/20210826193907 b/db/schema_migrations/20210826193907 new file mode 100644 index 00000000000..417333d7212 --- /dev/null +++ b/db/schema_migrations/20210826193907 @@ -0,0 +1 @@ +b7916e025131f11da97ab89a01b32d1dbacf94bb96dc84877ba18404c8b0b2ba \ No newline at end of file diff --git a/db/schema_migrations/20210901184511 b/db/schema_migrations/20210901184511 new file mode 100644 index 00000000000..45a3b3b8c18 --- /dev/null +++ b/db/schema_migrations/20210901184511 @@ -0,0 +1 @@ +40780a28f881d4e80bdb6b023f22804c4da735223323c8cf03cfcdcaf5337fe6 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index dbc7af116af..184234fbb4f 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9606,14 +9606,14 @@ CREATE TABLE application_settings ( encrypted_customers_dot_jwt_signing_key bytea, encrypted_customers_dot_jwt_signing_key_iv bytea, pypi_package_requests_forwarding boolean DEFAULT true NOT NULL, - max_yaml_size_bytes bigint DEFAULT 1048576 NOT NULL, - max_yaml_depth integer DEFAULT 100 NOT NULL, throttle_unauthenticated_files_api_requests_per_period integer DEFAULT 125 NOT NULL, throttle_unauthenticated_files_api_period_in_seconds integer DEFAULT 15 NOT NULL, throttle_authenticated_files_api_requests_per_period integer DEFAULT 500 NOT NULL, throttle_authenticated_files_api_period_in_seconds integer DEFAULT 15 NOT NULL, throttle_unauthenticated_files_api_enabled boolean DEFAULT false NOT NULL, throttle_authenticated_files_api_enabled boolean DEFAULT false NOT NULL, + max_yaml_size_bytes bigint DEFAULT 1048576 NOT NULL, + max_yaml_depth integer DEFAULT 100 NOT NULL, throttle_authenticated_git_lfs_requests_per_period integer DEFAULT 1000 NOT NULL, throttle_authenticated_git_lfs_period_in_seconds integer DEFAULT 60 NOT NULL, throttle_authenticated_git_lfs_enabled boolean DEFAULT false NOT NULL, @@ -23846,6 +23846,8 @@ CREATE UNIQUE INDEX index_dast_site_profiles_on_project_id_and_name ON dast_site CREATE UNIQUE INDEX index_dast_site_profiles_pipelines_on_ci_pipeline_id ON dast_site_profiles_pipelines USING btree (ci_pipeline_id); +CREATE UNIQUE INDEX index_dast_site_token_on_project_id_and_url ON dast_site_tokens USING btree (project_id, url); + CREATE INDEX index_dast_site_tokens_on_project_id ON dast_site_tokens USING btree (project_id); CREATE INDEX index_dast_site_validations_on_dast_site_token_id ON dast_site_validations USING btree (dast_site_token_id); diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index 8f755a64b69..a2f8cbc7b02 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -1477,16 +1477,8 @@ If this happens, examine the following: ### `gitaly-backup` for repository backup and restore **(FREE SELF)** > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/333034) in GitLab 14.2. -> - [Deployed behind a feature flag](../user/feature_flags.md), enabled by default. -> - Recommended for production use. -> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#disable-or-enable-gitaly-backup). - -There can be -[risks when disabling released features](../administration/feature_flags.md#risks-when-disabling-released-features). -Refer to this feature's version history for more details. `gitaly-backup` is used by the backup Rake task to create and restore repository backups from Gitaly. -`gitaly-backup` replaces the previous backup method that directly calls RPCs on Gitaly from GitLab. The backup Rake task must be able to find this executable. It can be configured in Omnibus GitLab packages: @@ -1498,22 +1490,3 @@ The backup Rake task must be able to find this executable. It can be configured 1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect - -#### Disable or enable `gitaly-backup` - -`gitaly-backup` is under development but ready for production use. -It is deployed behind a feature flag that is **enabled by default**. -[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md) -can opt to disable it. - -To disable it: - -```ruby -Feature.disable(:gitaly_backup) -``` - -To enable it: - -```ruby -Feature.enable(:gitaly_backup) -``` diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md index a627f04fa46..0c4519c8d36 100644 --- a/doc/user/group/saml_sso/index.md +++ b/doc/user/group/saml_sso/index.md @@ -120,6 +120,13 @@ SSO has the following effects when enabled: - Users must be signed-in through SSO before they can pull images using the [Dependency Proxy](../../packages/dependency_proxy/index.md). +Notes: + +- When SSO is enforced users are not immediately revoked +- If they are signed out then they cannot access the group after being removed from the identity provider +- However, if the user has an active session they can continue accessing the group for up to 24 hours, until the identity provider session times out +- Upon SCIM update, the user's access would be immediately revoked + ## Providers The SAML standard means that a wide range of identity providers will work with GitLab. Your identity provider may have relevant documentation. It may be generic SAML documentation, or specifically targeted for GitLab. @@ -140,13 +147,13 @@ Follow the Azure documentation on [configuring single sign-on to applications](h For a demo of the Azure SAML setup including SCIM, see [SCIM Provisioning on Azure Using SAML SSO for Groups Demo](https://youtu.be/24-ZxmTeEBU). The video is outdated in regard to objectID mapping and the [SCIM documentation should be followed](scim_setup.md#azure-configuration-steps). -| GitLab Setting | Azure Field | -|--------------|----------------| -| Identifier | Identifier (Entity ID) | -| Assertion consumer service URL | Reply URL (Assertion Consumer Service URL) | -| GitLab single sign-on URL | Sign on URL | -| Identity provider single sign-on URL | Login URL | -| Certificate fingerprint | Thumbprint | +| GitLab Setting | Azure Field | +| ------------------------------------ | ------------------------------------------ | +| Identifier | Identifier (Entity ID) | +| Assertion consumer service URL | Reply URL (Assertion Consumer Service URL) | +| GitLab single sign-on URL | Sign on URL | +| Identity provider single sign-on URL | Login URL | +| Certificate fingerprint | Thumbprint | We recommend: @@ -164,12 +171,12 @@ Please follow the Okta documentation on [setting up a SAML application in Okta]( For a demo of the Okta SAML setup including SCIM, see [Demo: Okta Group SAML & SCIM setup](https://youtu.be/0ES9HsZq0AQ). -| GitLab Setting | Okta Field | -|--------------|----------------| -| Identifier | Audience URI | -| Assertion consumer service URL | Single sign-on URL | -| GitLab single sign-on URL | Login page URL (under **Application Login Page** settings) | -| Identity provider single sign-on URL | Identity Provider Single Sign-On URL | +| GitLab Setting | Okta Field | +| ------------------------------------ | ---------------------------------------------------------- | +| Identifier | Audience URI | +| Assertion consumer service URL | Single sign-on URL | +| GitLab single sign-on URL | Login page URL (under **Application Login Page** settings) | +| Identity provider single sign-on URL | Identity Provider Single Sign-On URL | Under Okta's **Single sign-on URL** field, check the option **Use this for Recipient URL and Destination URL**. @@ -186,14 +193,14 @@ application. If you decide to use the OneLogin generic [SAML Test Connector (Advanced)](https://onelogin.service-now.com/support?id=kb_article&sys_id=b2c19353dbde7b8024c780c74b9619fb&kb_category=93e869b0db185340d5505eea4b961934), we recommend the ["Use the OneLogin SAML Test Connector" documentation](https://onelogin.service-now.com/support?id=kb_article&sys_id=93f95543db109700d5505eea4b96198f) with the following settings: -| GitLab Setting | OneLogin Field | -|--------------|----------------| -| Identifier | Audience | -| Assertion consumer service URL | Recipient | -| Assertion consumer service URL | ACS (Consumer) URL | +| GitLab Setting | OneLogin Field | +| ------------------------------------------------ | ---------------------------- | +| Identifier | Audience | +| Assertion consumer service URL | Recipient | +| Assertion consumer service URL | ACS (Consumer) URL | | Assertion consumer service URL (escaped version) | ACS (Consumer) URL Validator | -| GitLab single sign-on URL | Login URL | -| Identity provider single sign-on URL | SAML 2.0 Endpoint | +| GitLab single sign-on URL | Login URL | +| Identity provider single sign-on URL | SAML 2.0 Endpoint | Recommended `NameID` value: `OneLogin ID`. @@ -281,10 +288,7 @@ If a user is already a member of the group, linking the SAML identity does not c ### Blocking access -To rescind access to the group, perform the following steps, in order: - -1. Remove the user from the user data store on the identity provider or the list of users on the specific app. -1. Remove the user from the GitLab.com group. +Please refer to [Blocking access via SCiM](scim_setup.md#blocking-access). ### Unlinking accounts @@ -406,14 +410,14 @@ If you do not wish to use that GitLab user with the SAML login, you can [unlink The user that you're signed in with already has SAML linked to a different identity. Here are possible causes and solutions: -| Cause | Solution | -|------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Cause | Solution | +| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | You've tried to link multiple SAML identities to the same user, for a given identity provider. | Change the identity that you sign in with. To do so, [unlink the previous SAML identity](#unlinking-accounts) from this GitLab account before attempting to sign in again. | ### Message: "SAML authentication failed: Email has already been taken" | Cause | Solution | -|------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------| +| ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | When a user account with the email address already exists in GitLab, but the user does not have the SAML identity tied to their account. | The user will need to [link their account](#user-access-and-management). | ### Message: "SAML authentication failed: Extern UID has already been taken, User has already been taken" @@ -439,8 +443,8 @@ Alternatively, when users need to [link SAML to their existing GitLab.com accoun ### The NameID has changed -| Cause | Solution | -|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Cause | Solution | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | As mentioned in the [NameID](#nameid) section, if the NameID changes for any user, the user can be locked out. This is a common problem when an email address is used as the identifier. | Follow the steps outlined in the ["SAML authentication failed: User has already been taken"](#message-saml-authentication-failed-user-has-already-been-taken) section. | ### I need to change my SAML app diff --git a/doc/user/group/saml_sso/scim_setup.md b/doc/user/group/saml_sso/scim_setup.md index a0c281971fc..a5bf82e01b1 100644 --- a/doc/user/group/saml_sso/scim_setup.md +++ b/doc/user/group/saml_sso/scim_setup.md @@ -59,6 +59,7 @@ During this configuration, note the following: [previous step](#gitlab-configuration). - It is recommended to set a notification email and check the **Send an email notification when a failure occurs** checkbox. - For mappings, we will only leave `Synchronize Azure Active Directory Users to AppName` enabled. + - `Synchronize Azure Active Directory Groups to AppName` should be disabled. However, this does not mean Azure AD users cannot be provisioned in groups. Leaving it enabled does not break the SCIM user provisioning, but causes errors in Azure AD that may be confusing and misleading. You can then test the connection by clicking on **Test Connection**. If the connection is successful, be sure to save your configuration before moving on. See below for [troubleshooting](#troubleshooting). @@ -71,11 +72,11 @@ your SAML configuration differs from [the recommended SAML settings](index.md#az modify the corresponding `customappsso` settings accordingly. If a mapping is not listed in the table, use the Azure defaults. -| Azure Active Directory Attribute | `customappsso` Attribute | Matching precedence | -| -------------------------------- | ---------------------- | -------------------- | -| `objectId` | `externalId` | 1 | -| `userPrincipalName` | `emails[type eq "work"].value` | | -| `mailNickname` | `userName` | | +| Azure Active Directory Attribute | `customappsso` Attribute | Matching precedence | +| -------------------------------- | ------------------------------ | ------------------- | +| `objectId` | `externalId` | 1 | +| `userPrincipalName` | `emails[type eq "work"].value` | | +| `mailNickname` | `userName` | | For guidance, you can view [an example configuration in the troubleshooting reference](../../../administration/troubleshooting/group_saml_scim.md#azure-active-directory). @@ -162,6 +163,11 @@ graph TD B -->|Yes| D[GitLab sends message back 'Email exists'] ``` +During provisioning, note the following: + +- Both primary and secondary emails are considered when checking whether a GitLab user account exists. +- Duplicate usernames are also handled, by adding suffix `1` upon user creation. E.g. due to already existing `test_user` username, `test_user1` is used). + As long as [Group SAML](index.md) has been configured, existing GitLab.com users can link to their accounts in one of the following ways: - By updating their *primary* email address in their GitLab.com user account to match their identity provider's user profile email address. @@ -183,13 +189,12 @@ For role information, please see the [Group SAML page](index.md#user-access-and- ### Blocking access -To rescind access to the group, remove the user from the identity -provider or users list for the specific app. - -Upon the next sync, the user is deprovisioned, which means that the user is removed from the group. +To rescind access to the top-level group and all sub-groups and projects remove or deactivate the user on the identity provider. +SCIM providers will generally update GitLab with the changes on-demand, which is minutes at most. +The user's membership is revoked and they immediately lose access. NOTE: -Deprovisioning does not delete the user account. +Deprovisioning does not delete the GitLab user account. ```mermaid graph TD @@ -260,9 +265,9 @@ Alternatively, users can be removed from the SCIM app which de-links all removed Changing the SAML or SCIM configuration or provider can cause the following problems: -| Problem | Solution | -|------------------------------------------------------------------------------|--------------------| -| SAML and SCIM identity mismatch. | First [verify that the user's SAML NameId matches the SCIM externalId](#how-do-i-verify-users-saml-nameid-matches-the-scim-externalid) and then [update or fix the mismatched SCIM externalId and SAML NameId](#update-or-fix-mismatched-scim-externalid-and-saml-nameid). | +| Problem | Solution | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SAML and SCIM identity mismatch. | First [verify that the user's SAML NameId matches the SCIM externalId](#how-do-i-verify-users-saml-nameid-matches-the-scim-externalid) and then [update or fix the mismatched SCIM externalId and SAML NameId](#update-or-fix-mismatched-scim-externalid-and-saml-nameid). | | SCIM identity mismatch between GitLab and the Identify Provider SCIM app. | You can confirm whether you're hitting the error because of your SCIM identity mismatch between your SCIM app and GitLab.com by using [SCIM API](../../../api/scim.md#update-a-single-scim-provisioned-user) which shows up in the `id` key and compares it with the user `externalId` in the SCIM app. You can use the same [SCIM API](../../../api/scim.md#update-a-single-scim-provisioned-user) to update the SCIM `id` for the user on GitLab.com. | ### Azure diff --git a/lib/backup/gitaly_backup.rb b/lib/backup/gitaly_backup.rb index 55fd68fd6e8..7c7c07394d1 100644 --- a/lib/backup/gitaly_backup.rb +++ b/lib/backup/gitaly_backup.rb @@ -57,10 +57,6 @@ module Backup }.merge(Gitlab::GitalyClient.connection_data(repository.storage)).to_json) end - def parallel_enqueue? - false - end - private def started? diff --git a/lib/backup/gitaly_rpc_backup.rb b/lib/backup/gitaly_rpc_backup.rb deleted file mode 100644 index baac4eb26ca..00000000000 --- a/lib/backup/gitaly_rpc_backup.rb +++ /dev/null @@ -1,132 +0,0 @@ -# frozen_string_literal: true - -module Backup - # Backup and restores repositories using the gitaly RPC - class GitalyRpcBackup - def initialize(progress) - @progress = progress - end - - def start(type) - raise Error, 'already started' if @type - - @type = type - case type - when :create - FileUtils.rm_rf(backup_repos_path) - FileUtils.mkdir_p(Gitlab.config.backup.path) - FileUtils.mkdir(backup_repos_path, mode: 0700) - when :restore - # no op - else - raise Error, "unknown backup type: #{type}" - end - end - - def wait - @type = nil - end - - def enqueue(container, repository_type) - backup_restore = BackupRestore.new( - progress, - repository_type.repository_for(container), - backup_repos_path - ) - - case @type - when :create - backup_restore.backup - when :restore - backup_restore.restore(always_create: repository_type.project?) - else - raise Error, 'not started' - end - end - - def parallel_enqueue? - true - end - - private - - attr_reader :progress - - def backup_repos_path - @backup_repos_path ||= File.join(Gitlab.config.backup.path, 'repositories') - end - - class BackupRestore - attr_accessor :progress, :repository, :backup_repos_path - - def initialize(progress, repository, backup_repos_path) - @progress = progress - @repository = repository - @backup_repos_path = backup_repos_path - end - - def backup - progress.puts " * #{display_repo_path} ... " - - if repository.empty? - progress.puts " * #{display_repo_path} ... " + "[EMPTY] [SKIPPED]".color(:cyan) - return - end - - FileUtils.mkdir_p(repository_backup_path) - - repository.bundle_to_disk(path_to_bundle) - repository.gitaly_repository_client.backup_custom_hooks(custom_hooks_tar) - - progress.puts " * #{display_repo_path} ... " + "[DONE]".color(:green) - - rescue StandardError => e - progress.puts "[Failed] backing up #{display_repo_path}".color(:red) - progress.puts "Error #{e}".color(:red) - end - - def restore(always_create: false) - progress.puts " * #{display_repo_path} ... " - - repository.remove rescue nil - - if File.exist?(path_to_bundle) - repository.create_from_bundle(path_to_bundle) - restore_custom_hooks - elsif always_create - repository.create_repository - end - - progress.puts " * #{display_repo_path} ... " + "[DONE]".color(:green) - - rescue StandardError => e - progress.puts "[Failed] restoring #{display_repo_path}".color(:red) - progress.puts "Error #{e}".color(:red) - end - - private - - def display_repo_path - "#{repository.full_path} (#{repository.disk_path})" - end - - def repository_backup_path - @repository_backup_path ||= File.join(backup_repos_path, repository.disk_path) - end - - def path_to_bundle - @path_to_bundle ||= File.join(backup_repos_path, repository.disk_path + '.bundle') - end - - def restore_custom_hooks - return unless File.exist?(custom_hooks_tar) - - repository.gitaly_repository_client.restore_custom_hooks(custom_hooks_tar) - end - - def custom_hooks_tar - File.join(repository_backup_path, "custom_hooks.tar") - end - end - end -end diff --git a/lib/backup/repositories.rb b/lib/backup/repositories.rb index 0b5a62529b4..795d5eebaea 100644 --- a/lib/backup/repositories.rb +++ b/lib/backup/repositories.rb @@ -9,36 +9,10 @@ module Backup @strategy = strategy end - def dump(max_concurrency:, max_storage_concurrency:) + def dump strategy.start(:create) + enqueue_consecutive - # gitaly-backup is designed to handle concurrency on its own. So we want - # to avoid entering the buggy concurrency code here when gitaly-backup - # is enabled. - if (max_concurrency <= 1 && max_storage_concurrency <= 1) || !strategy.parallel_enqueue? - return enqueue_consecutive - end - - check_valid_storages! - - semaphore = Concurrent::Semaphore.new(max_concurrency) - errors = Queue.new - - threads = Gitlab.config.repositories.storages.keys.map do |storage| - Thread.new do - Rails.application.executor.wrap do - enqueue_storage(storage, semaphore, max_storage_concurrency: max_storage_concurrency) - rescue StandardError => e - errors << e - end - end - end - - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - threads.each(&:join) - end - - raise errors.pop unless errors.empty? ensure strategy.wait end @@ -58,18 +32,6 @@ module Backup attr_reader :progress, :strategy - def check_valid_storages! - repository_storage_klasses.each do |klass| - if klass.excluding_repository_storage(Gitlab.config.repositories.storages.keys).exists? - raise Error, "repositories.storages in gitlab.yml does not include all storages used by #{klass}" - end - end - end - - def repository_storage_klasses - [ProjectRepository, SnippetRepository] - end - def enqueue_consecutive enqueue_consecutive_projects enqueue_consecutive_snippets @@ -85,50 +47,6 @@ module Backup Snippet.find_each(batch_size: 1000) { |snippet| enqueue_snippet(snippet) } end - def enqueue_storage(storage, semaphore, max_storage_concurrency:) - errors = Queue.new - queue = InterlockSizedQueue.new(1) - - threads = Array.new(max_storage_concurrency) do - Thread.new do - Rails.application.executor.wrap do - while container = queue.pop - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - semaphore.acquire - end - - begin - enqueue_container(container) - rescue StandardError => e - errors << e - break - ensure - semaphore.release - end - end - end - end - end - - enqueue_records_for_storage(storage, queue, errors) - - raise errors.pop unless errors.empty? - ensure - queue.close - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - threads.each(&:join) - end - end - - def enqueue_container(container) - case container - when Project - enqueue_project(container) - when Snippet - enqueue_snippet(container) - end - end - def enqueue_project(project) strategy.enqueue(project, Gitlab::GlRepository::PROJECT) strategy.enqueue(project, Gitlab::GlRepository::WIKI) @@ -139,32 +57,10 @@ module Backup strategy.enqueue(snippet, Gitlab::GlRepository::SNIPPET) end - def enqueue_records_for_storage(storage, queue, errors) - records_to_enqueue(storage).each do |relation| - relation.find_each(batch_size: 100) do |project| - break unless errors.empty? - - queue.push(project) - end - end - end - - def records_to_enqueue(storage) - [projects_in_storage(storage), snippets_in_storage(storage)] - end - - def projects_in_storage(storage) - project_relation.id_in(ProjectRepository.for_repository_storage(storage).select(:project_id)) - end - def project_relation Project.includes(:route, :group, namespace: :owner) end - def snippets_in_storage(storage) - Snippet.id_in(SnippetRepository.for_repository_storage(storage).select(:snippet_id)) - end - def restore_object_pools PoolRepository.includes(:source_project).find_each do |pool| progress.puts " - Object pool #{pool.disk_path}..." @@ -199,24 +95,6 @@ module Backup Snippet.id_in(invalid_snippets).delete_all end - - class InterlockSizedQueue < SizedQueue - extend ::Gitlab::Utils::Override - - override :pop - def pop(*) - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - super - end - end - - override :push - def push(*) - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - super - end - end - end end end diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb index 3a1178793f3..ce198061a49 100644 --- a/lib/gitlab/database.rb +++ b/lib/gitlab/database.rb @@ -2,6 +2,9 @@ module Gitlab module Database + DATABASE_NAMES = %w[main ci].freeze + + MAIN_DATABASE_NAME = 'main' CI_DATABASE_NAME = 'ci' # This constant is used when renaming tables concurrently. diff --git a/lib/gitlab/patch/legacy_database_config.rb b/lib/gitlab/patch/legacy_database_config.rb new file mode 100644 index 00000000000..a7d4fdf7490 --- /dev/null +++ b/lib/gitlab/patch/legacy_database_config.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# The purpose of this code is to transform legacy `database.yml` +# into a `database.yml` containing `main:` as a name of a first database +# +# This should be removed once all places using legacy `database.yml` +# are fixed. The likely moment to remove this check is the %14.0. +# +# This converts the following syntax: +# +# production: +# adapter: postgresql +# database: gitlabhq_production +# username: git +# password: "secure password" +# host: localhost +# +# Into: +# +# production: +# main: +# adapter: postgresql +# database: gitlabhq_production +# username: git +# password: "secure password" +# host: localhost +# + +module Gitlab + module Patch + module LegacyDatabaseConfig + extend ActiveSupport::Concern + + prepended do + attr_reader :uses_legacy_database_config + end + + def database_configuration + @uses_legacy_database_config = false # rubocop:disable Gitlab/ModuleWithInstanceVariables + + super.to_h do |env, configs| + # This check is taken from Rails where the transformation + # of a flat database.yml is done into `primary:` + # https://github.com/rails/rails/blob/v6.1.4/activerecord/lib/active_record/database_configurations.rb#L169 + if configs.is_a?(Hash) && !configs.all? { |_, v| v.is_a?(Hash) } + configs = { "main" => configs } + + @uses_legacy_database_config = true # rubocop:disable Gitlab/ModuleWithInstanceVariables + end + + [env, configs] + end + end + end + end +end diff --git a/lib/tasks/gitlab/backup.rake b/lib/tasks/gitlab/backup.rake index cc10d73f76a..1182cec5d6b 100644 --- a/lib/tasks/gitlab/backup.rake +++ b/lib/tasks/gitlab/backup.rake @@ -102,19 +102,10 @@ namespace :gitlab do task create: :gitlab_environment do puts_time "Dumping repositories ...".color(:blue) - max_concurrency = ENV.fetch('GITLAB_BACKUP_MAX_CONCURRENCY', 1).to_i - max_storage_concurrency = ENV.fetch('GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY', 1).to_i - if ENV["SKIP"] && ENV["SKIP"].include?("repositories") puts_time "[SKIPPED]".color(:cyan) - elsif max_concurrency < 1 || max_storage_concurrency < 1 - puts "GITLAB_BACKUP_MAX_CONCURRENCY and GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY must have a value of at least 1".color(:red) - exit 1 else - Backup::Repositories.new(progress, strategy: repository_backup_strategy).dump( - max_concurrency: max_concurrency, - max_storage_concurrency: max_storage_concurrency - ) + Backup::Repositories.new(progress, strategy: repository_backup_strategy).dump puts_time "done".color(:green) end end @@ -299,13 +290,9 @@ namespace :gitlab do end def repository_backup_strategy - if Feature.enabled?(:gitaly_backup, default_enabled: :yaml) - max_concurrency = ENV['GITLAB_BACKUP_MAX_CONCURRENCY'].presence - max_storage_concurrency = ENV['GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY'].presence - Backup::GitalyBackup.new(progress, parallel: max_concurrency, parallel_storage: max_storage_concurrency) - else - Backup::GitalyRpcBackup.new(progress) - end + max_concurrency = ENV['GITLAB_BACKUP_MAX_CONCURRENCY'].presence + max_storage_concurrency = ENV['GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY'].presence + Backup::GitalyBackup.new(progress, parallel: max_concurrency, parallel_storage: max_storage_concurrency) end end # namespace end: backup diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6fec63667af..53fd25e5673 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -621,9 +621,6 @@ msgstr "" msgid "%{issueType} actions" msgstr "" -msgid "%{issuesCount} issues with a limit of %{maxIssueCount}" -msgstr "" - msgid "%{issuesSize} with a limit of %{maxIssueCount}" msgstr "" @@ -20232,9 +20229,6 @@ msgstr "" msgid "Loading functions timed out. Please reload the page to try again." msgstr "" -msgid "Loading issues" -msgstr "" - msgid "Loading more" msgstr "" @@ -30904,9 +30898,6 @@ msgstr "" msgid "Showing %{pageSize} of %{total} %{issuableType}" msgstr "" -msgid "Showing %{pageSize} of %{total} issues" -msgstr "" - msgid "Showing all epics" msgstr "" @@ -31327,9 +31318,6 @@ msgstr "" msgid "Something went wrong while obtaining the Let's Encrypt certificate." msgstr "" -msgid "Something went wrong while performing the action." -msgstr "" - msgid "Something went wrong while promoting the issue to an epic. Please try again." msgstr "" diff --git a/package.json b/package.json index 4563900cb4e..ee9aa0c0d95 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "codesandbox-api": "0.0.23", "compression-webpack-plugin": "^5.0.2", "copy-webpack-plugin": "^6.4.1", - "core-js": "^3.16.4", + "core-js": "^3.17.1", "cron-validator": "^1.1.1", "cropper": "^2.3.0", "css-loader": "^2.1.1", diff --git a/spec/frontend/boards/board_list_deprecated_spec.js b/spec/frontend/boards/board_list_deprecated_spec.js deleted file mode 100644 index b71564f7858..00000000000 --- a/spec/frontend/boards/board_list_deprecated_spec.js +++ /dev/null @@ -1,274 +0,0 @@ -/* global List */ -/* global ListIssue */ -import MockAdapter from 'axios-mock-adapter'; -import Vue from 'vue'; -import waitForPromises from 'helpers/wait_for_promises'; -import BoardList from '~/boards/components/board_list_deprecated.vue'; -import eventHub from '~/boards/eventhub'; -import store from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import { listObj, boardsMockInterceptor } from './mock_data'; - -const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => { - const el = document.createElement('div'); - - document.body.appendChild(el); - const mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - boardsStore.create(); - - const BoardListComp = Vue.extend(BoardList); - const list = new List({ ...listObj, ...listProps }); - const issue = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [], - assignees: [], - ...listIssueProps, - }); - if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { - list.issuesSize = 1; - } - list.issues.push(issue); - - const component = new BoardListComp({ - el, - store, - propsData: { - disabled: false, - list, - issues: list.issues, - ...componentProps, - }, - provide: { - groupId: null, - rootPath: '/', - }, - }).$mount(); - - Vue.nextTick(() => { - done(); - }); - - return { component, mock }; -}; - -describe('Board list component', () => { - let mock; - let component; - let getIssues; - function generateIssues(compWrapper) { - for (let i = 1; i < 20; i += 1) { - const issue = { ...compWrapper.list.issues[0] }; - issue.id += i; - compWrapper.list.issues.push(issue); - } - } - - describe('When Expanded', () => { - beforeEach((done) => { - getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); - ({ mock, component } = createComponent({ done })); - }); - - afterEach(() => { - mock.restore(); - component.$destroy(); - }); - - it('loads first page of issues', () => { - return waitForPromises().then(() => { - expect(getIssues).toHaveBeenCalled(); - }); - }); - - it('renders component', () => { - expect(component.$el.classList.contains('board-list-component')).toBe(true); - }); - - it('renders loading icon', () => { - component.list.loading = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-loading')).not.toBeNull(); - }); - }); - - it('renders issues', () => { - expect(component.$el.querySelectorAll('.board-card').length).toBe(1); - }); - - it('sets data attribute with issue id', () => { - expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1'); - }); - - it('shows new issue form', () => { - component.toggleForm(); - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); - - expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - }); - }); - - it('shows new issue form after eventhub event', () => { - eventHub.$emit(`toggle-issue-form-${component.list.id}`); - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); - - expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); - }); - }); - - it('does not show new issue form for closed list', () => { - component.list.type = 'closed'; - component.toggleForm(); - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).toBeNull(); - }); - }); - - it('shows count list item', () => { - component.showCount = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count')).not.toBeNull(); - - expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( - 'Showing all issues', - ); - }); - }); - - it('sets data attribute with invalid id', () => { - component.showCount = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe( - '-1', - ); - }); - }); - - it('shows how many more issues to load', () => { - component.showCount = true; - component.list.issuesSize = 20; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( - 'Showing 1 of 20 issues', - ); - }); - }); - - it('loads more issues after scrolling', () => { - jest.spyOn(component.list, 'nextPage').mockImplementation(() => {}); - generateIssues(component); - component.$refs.list.dispatchEvent(new Event('scroll')); - - return waitForPromises().then(() => { - expect(component.list.nextPage).toHaveBeenCalled(); - }); - }); - - it('does not load issues if already loading', () => { - component.list.nextPage = jest - .spyOn(component.list, 'nextPage') - .mockReturnValue(new Promise(() => {})); - - component.onScroll(); - component.onScroll(); - - return waitForPromises().then(() => { - expect(component.list.nextPage).toHaveBeenCalledTimes(1); - }); - }); - - it('shows loading more spinner', () => { - component.showCount = true; - component.list.loadingMore = true; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull(); - }); - }); - }); - - describe('When Collapsed', () => { - beforeEach((done) => { - getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); - ({ mock, component } = createComponent({ - done, - listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, - })); - generateIssues(component); - component.scrollHeight = jest.spyOn(component, 'scrollHeight').mockReturnValue(0); - }); - - afterEach(() => { - mock.restore(); - component.$destroy(); - }); - - it('does not load all issues', () => { - return waitForPromises().then(() => { - // Initial getIssues from list constructor - expect(getIssues).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('max issue count warning', () => { - beforeEach((done) => { - ({ mock, component } = createComponent({ - done, - listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, - })); - }); - - afterEach(() => { - mock.restore(); - component.$destroy(); - }); - - describe('when issue count exceeds max issue count', () => { - it('sets background to bg-danger-100', () => { - component.list.issuesSize = 4; - component.list.maxIssueCount = 3; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull(); - }); - }); - }); - - describe('when list issue count does NOT exceed list max issue count', () => { - it('does not sets background to bg-danger-100', () => { - component.list.issuesSize = 2; - component.list.maxIssueCount = 3; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); - }); - }); - }); - - describe('when list max issue count is 0', () => { - it('does not sets background to bg-danger-100', () => { - component.list.maxIssueCount = 0; - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); - }); - }); - }); - }); -}); diff --git a/spec/frontend/boards/board_new_issue_deprecated_spec.js b/spec/frontend/boards/board_new_issue_deprecated_spec.js deleted file mode 100644 index 3beaf870bf5..00000000000 --- a/spec/frontend/boards/board_new_issue_deprecated_spec.js +++ /dev/null @@ -1,211 +0,0 @@ -/* global List */ - -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import boardNewIssue from '~/boards/components/board_new_issue_deprecated.vue'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; - -import '~/boards/models/list'; -import { listObj, boardsMockInterceptor } from './mock_data'; - -Vue.use(Vuex); - -describe('Issue boards new issue form', () => { - let wrapper; - let vm; - let list; - let mock; - let newIssueMock; - const promiseReturn = { - data: { - iid: 100, - }, - }; - - const submitIssue = () => { - const dummySubmitEvent = { - preventDefault() {}, - }; - wrapper.vm.$refs.submitButton = wrapper.find({ ref: 'submitButton' }); - return wrapper.vm.submit(dummySubmitEvent); - }; - - beforeEach(() => { - const BoardNewIssueComp = Vue.extend(boardNewIssue); - - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - - boardsStore.create(); - - list = new List(listObj); - - newIssueMock = Promise.resolve(promiseReturn); - jest.spyOn(list, 'newIssue').mockImplementation(() => newIssueMock); - - const store = new Vuex.Store({ - getters: { isGroupBoard: () => false }, - }); - - wrapper = mount(BoardNewIssueComp, { - propsData: { - disabled: false, - list, - }, - store, - provide: { - groupId: null, - }, - }); - - vm = wrapper.vm; - - return Vue.nextTick(); - }); - - afterEach(() => { - wrapper.destroy(); - mock.restore(); - }); - - it('calls submit if submit button is clicked', () => { - jest.spyOn(wrapper.vm, 'submit').mockImplementation(); - vm.title = 'Testing Title'; - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(wrapper.vm.submit).toHaveBeenCalled(); - }); - }); - - it('disables submit button if title is empty', () => { - expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(true); - }); - - it('enables submit button if title is not empty', () => { - wrapper.setData({ title: 'Testing Title' }); - - return Vue.nextTick().then(() => { - expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title'); - expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(false); - }); - }); - - it('clears title after clicking cancel', () => { - wrapper.find({ ref: 'cancelButton' }).trigger('click'); - - return Vue.nextTick().then(() => { - expect(vm.title).toBe(''); - }); - }); - - it('does not create new issue if title is empty', () => { - return submitIssue().then(() => { - expect(list.newIssue).not.toHaveBeenCalled(); - }); - }); - - describe('submit success', () => { - it('creates new issue', () => { - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(list.newIssue).toHaveBeenCalled(); - }); - }); - - it('enables button after submit', () => { - jest.spyOn(wrapper.vm, 'submit').mockImplementation(); - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(wrapper.vm.$refs.submitButton.props().disabled).toBe(false); - }); - }); - - it('clears title after submit', () => { - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(vm.title).toBe(''); - }); - }); - - it('sets detail issue after submit', () => { - expect(boardsStore.detail.issue.title).toBe(undefined); - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(boardsStore.detail.issue.title).toBe('create issue'); - }); - }); - - it('sets detail list after submit', () => { - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(boardsStore.detail.list.id).toBe(list.id); - }); - }); - - it('sets detail weight after submit', () => { - boardsStore.weightFeatureAvailable = true; - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(boardsStore.detail.list.weight).toBe(list.weight); - }); - }); - - it('does not set detail weight after submit', () => { - boardsStore.weightFeatureAvailable = false; - wrapper.setData({ title: 'create issue' }); - - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(boardsStore.detail.list.weight).toBe(list.weight); - }); - }); - }); - - describe('submit error', () => { - beforeEach(() => { - newIssueMock = Promise.reject(new Error('My hovercraft is full of eels!')); - vm.title = 'error'; - }); - - it('removes issue', () => { - const lengthBefore = list.issues.length; - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(list.issues.length).toBe(lengthBefore); - }); - }); - - it('shows error', () => { - return Vue.nextTick() - .then(submitIssue) - .then(() => { - expect(vm.error).toBe(true); - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_card_deprecated_spec.js b/spec/frontend/boards/components/board_card_deprecated_spec.js deleted file mode 100644 index e21a8941bdc..00000000000 --- a/spec/frontend/boards/components/board_card_deprecated_spec.js +++ /dev/null @@ -1,181 +0,0 @@ -/* global List */ -/* global ListAssignee */ -/* global ListLabel */ - -import { mount } from '@vue/test-utils'; - -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; -import BoardCardDeprecated from '~/boards/components/board_card_deprecated.vue'; -import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; -import store from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; - -import sidebarEventHub from '~/sidebar/event_hub'; -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/list'; -import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; - -describe('BoardCard', () => { - let wrapper; - let mock; - let list; - - const findIssueCardInner = () => wrapper.find(issueCardInner); - const findUserAvatarLink = () => wrapper.find(userAvatarLink); - - // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized - const mountComponent = (propsData) => { - wrapper = mount(BoardCardDeprecated, { - stubs: { - issueCardInner, - }, - store, - propsData: { - list, - issue: list.issues[0], - disabled: false, - index: 0, - ...propsData, - }, - provide: { - groupId: null, - rootPath: '/', - scopedLabelsAvailable: false, - }, - }); - }; - - const setupData = async () => { - list = new List(listObj); - boardsStore.create(); - boardsStore.detail.issue = {}; - const label1 = new ListLabel({ - id: 3, - title: 'testing 123', - color: '#000cff', - text_color: 'white', - description: 'test', - }); - await waitForPromises(); - - list.issues[0].labels.push(label1); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - setMockEndpoints(); - return setupData(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - list = null; - mock.restore(); - }); - - it('when details issue is empty does not show the element', () => { - mountComponent(); - expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active'); - }); - - it('when detailIssue is equal to card issue shows the element', () => { - [boardsStore.detail.issue] = list.issues; - mountComponent(); - - expect(wrapper.classes()).toContain('is-active'); - }); - - it('when multiSelect does not contain issue removes multi select class', () => { - mountComponent(); - expect(wrapper.classes()).not.toContain('multi-select'); - }); - - it('when multiSelect contain issue add multi select class', () => { - boardsStore.multiSelect.list = [list.issues[0]]; - mountComponent(); - - expect(wrapper.classes()).toContain('multi-select'); - }); - - it('adds user-can-drag class if not disabled', () => { - mountComponent(); - expect(wrapper.classes()).toContain('user-can-drag'); - }); - - it('does not add user-can-drag class disabled', () => { - mountComponent({ disabled: true }); - - expect(wrapper.classes()).not.toContain('user-can-drag'); - }); - - it('does not add disabled class', () => { - mountComponent(); - expect(wrapper.classes()).not.toContain('is-disabled'); - }); - - it('adds disabled class is disabled is true', () => { - mountComponent({ disabled: true }); - - expect(wrapper.classes()).toContain('is-disabled'); - }); - - describe('mouse events', () => { - it('does not set detail issue if showDetail is false', () => { - mountComponent(); - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('does not set detail issue if link is clicked', () => { - mountComponent(); - findIssueCardInner().find('a').trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('does not set detail issue if img is clicked', () => { - mountComponent({ - issue: { - ...list.issues[0], - assignees: [ - new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - avatar: 'test_image', - }), - ], - }, - }); - - findUserAvatarLink().trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - - it('does not set detail issue if showDetail is false after mouseup', () => { - mountComponent(); - wrapper.trigger('mouseup'); - - expect(boardsStore.detail.issue).toEqual({}); - }); - }); - - describe('sidebarHub events', () => { - it('it does not closes all sidebars before showing an issue if an issue is opened', () => { - jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {}); - const [issue] = list.issues; - boardsStore.detail.issue = issue; - mountComponent(); - - wrapper.trigger('mousedown'); - - expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll'); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_card_layout_deprecated_spec.js b/spec/frontend/boards/components/board_card_layout_deprecated_spec.js deleted file mode 100644 index 5a81f3ecc26..00000000000 --- a/spec/frontend/boards/components/board_card_layout_deprecated_spec.js +++ /dev/null @@ -1,154 +0,0 @@ -/* global List */ -/* global ListLabel */ - -import { createLocalVue, shallowMount } from '@vue/test-utils'; - -import MockAdapter from 'axios-mock-adapter'; -import Vuex from 'vuex'; -import waitForPromises from 'helpers/wait_for_promises'; - -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/list'; -import BoardCardLayout from '~/boards/components/board_card_layout_deprecated.vue'; -import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; -import { ISSUABLE } from '~/boards/constants'; -import boardsVuexStore from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; -import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; - -describe('Board card layout', () => { - let wrapper; - let mock; - let list; - let store; - - const localVue = createLocalVue(); - localVue.use(Vuex); - - const createStore = ({ getters = {}, actions = {} } = {}) => { - store = new Vuex.Store({ - ...boardsVuexStore, - actions, - getters, - }); - }; - - // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized - const mountComponent = ({ propsData = {}, provide = {} } = {}) => { - wrapper = shallowMount(BoardCardLayout, { - localVue, - stubs: { - issueCardInner, - }, - store, - propsData: { - list, - issue: list.issues[0], - disabled: false, - index: 0, - ...propsData, - }, - provide: { - groupId: null, - rootPath: '/', - scopedLabelsAvailable: false, - ...provide, - }, - }); - }; - - const setupData = () => { - list = new List(listObj); - boardsStore.create(); - boardsStore.detail.issue = {}; - const label1 = new ListLabel({ - id: 3, - title: 'testing 123', - color: '#000cff', - text_color: 'white', - description: 'test', - }); - return waitForPromises().then(() => { - list.issues[0].labels.push(label1); - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - setMockEndpoints(); - return setupData(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - list = null; - mock.restore(); - }); - - describe('mouse events', () => { - it('sets showDetail to true on mousedown', async () => { - createStore(); - mountComponent(); - - wrapper.trigger('mousedown'); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.showDetail).toBe(true); - }); - - it('sets showDetail to false on mousemove', async () => { - createStore(); - mountComponent(); - wrapper.trigger('mousedown'); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.showDetail).toBe(true); - wrapper.trigger('mousemove'); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.showDetail).toBe(false); - }); - - it("calls 'setActiveId'", async () => { - const setActiveId = jest.fn(); - createStore({ - actions: { - setActiveId, - }, - }); - mountComponent(); - - wrapper.trigger('mouseup'); - await wrapper.vm.$nextTick(); - - expect(setActiveId).toHaveBeenCalledTimes(1); - expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { - id: list.issues[0].id, - sidebarType: ISSUABLE, - }); - }); - - it("calls 'setActiveId' when epic swimlanes is active", async () => { - const setActiveId = jest.fn(); - const isSwimlanesOn = () => true; - createStore({ - getters: { isSwimlanesOn }, - actions: { - setActiveId, - }, - }); - mountComponent(); - - wrapper.trigger('mouseup'); - await wrapper.vm.$nextTick(); - - expect(setActiveId).toHaveBeenCalledTimes(1); - expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), { - id: list.issues[0].id, - sidebarType: ISSUABLE, - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_column_deprecated_spec.js b/spec/frontend/boards/components/board_column_deprecated_spec.js deleted file mode 100644 index e6d65e48c3f..00000000000 --- a/spec/frontend/boards/components/board_column_deprecated_spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import Vue, { nextTick } from 'vue'; - -import { TEST_HOST } from 'helpers/test_constants'; -import { listObj } from 'jest/boards/mock_data'; -import Board from '~/boards/components/board_column_deprecated.vue'; -import { ListType } from '~/boards/constants'; -import List from '~/boards/models/list'; -import axios from '~/lib/utils/axios_utils'; - -describe('Board Column Component', () => { - let wrapper; - let axiosMock; - - beforeEach(() => { - window.gon = {}; - axiosMock = new AxiosMockAdapter(axios); - axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); - }); - - afterEach(() => { - axiosMock.restore(); - - wrapper.destroy(); - - localStorage.clear(); - }); - - const createComponent = ({ - listType = ListType.backlog, - collapsed = false, - highlighted = false, - withLocalStorage = true, - } = {}) => { - const boardId = '1'; - - const listMock = { - ...listObj, - list_type: listType, - highlighted, - collapsed, - }; - - if (listType === ListType.assignee) { - delete listMock.label; - listMock.user = {}; - } - - // Making List reactive - const list = Vue.observable(new List(listMock)); - - if (withLocalStorage) { - localStorage.setItem( - `boards.${boardId}.${list.type}.${list.id}.expanded`, - (!collapsed).toString(), - ); - } - - wrapper = shallowMount(Board, { - propsData: { - boardId, - disabled: false, - list, - }, - provide: { - boardId, - }, - }); - }; - - const isExpandable = () => wrapper.classes('is-expandable'); - const isCollapsed = () => wrapper.classes('is-collapsed'); - - describe('Given different list types', () => { - it('is expandable when List Type is `backlog`', () => { - createComponent({ listType: ListType.backlog }); - - expect(isExpandable()).toBe(true); - }); - }); - - describe('expanded / collapsed column', () => { - it('has class is-collapsed when list is collapsed', () => { - createComponent({ collapsed: false }); - - expect(wrapper.vm.list.isExpanded).toBe(true); - }); - - it('does not have class is-collapsed when list is expanded', () => { - createComponent({ collapsed: true }); - - expect(isCollapsed()).toBe(true); - }); - }); - - describe('highlighting', () => { - it('scrolls to column when highlighted', async () => { - createComponent({ highlighted: true }); - - await nextTick(); - - expect(wrapper.element.scrollIntoView).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/boards/components/board_list_header_deprecated_spec.js b/spec/frontend/boards/components/board_list_header_deprecated_spec.js deleted file mode 100644 index db79e67fe78..00000000000 --- a/spec/frontend/boards/components/board_list_header_deprecated_spec.js +++ /dev/null @@ -1,174 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import Vue from 'vue'; - -import { TEST_HOST } from 'helpers/test_constants'; -import { listObj } from 'jest/boards/mock_data'; -import BoardListHeader from '~/boards/components/board_list_header_deprecated.vue'; -import { ListType } from '~/boards/constants'; -import List from '~/boards/models/list'; -import axios from '~/lib/utils/axios_utils'; - -describe('Board List Header Component', () => { - let wrapper; - let axiosMock; - - beforeEach(() => { - window.gon = {}; - axiosMock = new AxiosMockAdapter(axios); - axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] }); - }); - - afterEach(() => { - axiosMock.restore(); - - wrapper.destroy(); - - localStorage.clear(); - }); - - const createComponent = ({ - listType = ListType.backlog, - collapsed = false, - withLocalStorage = true, - currentUserId = 1, - } = {}) => { - const boardId = '1'; - - const listMock = { - ...listObj, - list_type: listType, - collapsed, - }; - - if (listType === ListType.assignee) { - delete listMock.label; - listMock.user = {}; - } - - // Making List reactive - const list = Vue.observable(new List(listMock)); - - if (withLocalStorage) { - localStorage.setItem( - `boards.${boardId}.${list.type}.${list.id}.expanded`, - (!collapsed).toString(), - ); - } - - wrapper = shallowMount(BoardListHeader, { - propsData: { - disabled: false, - list, - }, - provide: { - boardId, - currentUserId, - }, - }); - }; - - const isCollapsed = () => !wrapper.props().list.isExpanded; - const isExpanded = () => wrapper.vm.list.isExpanded; - - const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); - const findCaret = () => wrapper.find('.board-title-caret'); - - describe('Add issue button', () => { - const hasNoAddButton = [ListType.closed]; - const hasAddButton = [ - ListType.backlog, - ListType.label, - ListType.milestone, - ListType.iteration, - ListType.assignee, - ]; - - it.each(hasNoAddButton)('does not render when List Type is `%s`', (listType) => { - createComponent({ listType }); - - expect(findAddIssueButton().exists()).toBe(false); - }); - - it.each(hasAddButton)('does render when List Type is `%s`', (listType) => { - createComponent({ listType }); - - expect(findAddIssueButton().exists()).toBe(true); - }); - - it('has a test for each list type', () => { - Object.values(ListType).forEach((value) => { - expect([...hasAddButton, ...hasNoAddButton]).toContain(value); - }); - }); - - it('does not render when logged out', () => { - createComponent({ - currentUserId: null, - }); - - expect(findAddIssueButton().exists()).toBe(false); - }); - }); - - describe('expanding / collapsing the column', () => { - it('does not collapse when clicking the header', () => { - createComponent(); - - expect(isCollapsed()).toBe(false); - wrapper.find('[data-testid="board-list-header"]').trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(false); - }); - }); - - it('collapses expanded Column when clicking the collapse icon', () => { - createComponent(); - - expect(isExpanded()).toBe(true); - findCaret().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(true); - }); - }); - - it('expands collapsed Column when clicking the expand icon', () => { - createComponent({ collapsed: true }); - - expect(isCollapsed()).toBe(true); - findCaret().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(isCollapsed()).toBe(false); - }); - }); - - it("when logged in it calls list update and doesn't set localStorage", () => { - jest.spyOn(List.prototype, 'update'); - - createComponent({ withLocalStorage: false }); - - findCaret().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); - }); - }); - - it("when logged out it doesn't call list update and sets localStorage", () => { - jest.spyOn(List.prototype, 'update'); - - createComponent({ currentUserId: null }); - - findCaret().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.list.update).not.toHaveBeenCalled(); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/boards_selector_deprecated_spec.js b/spec/frontend/boards/components/boards_selector_deprecated_spec.js deleted file mode 100644 index cc078861d75..00000000000 --- a/spec/frontend/boards/components/boards_selector_deprecated_spec.js +++ /dev/null @@ -1,214 +0,0 @@ -import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { TEST_HOST } from 'spec/test_constants'; -import BoardsSelector from '~/boards/components/boards_selector_deprecated.vue'; -import boardsStore from '~/boards/stores/boards_store'; - -const throttleDuration = 1; - -function boardGenerator(n) { - return new Array(n).fill().map((board, index) => { - const id = `${index}`; - const name = `board${id}`; - - return { - id, - name, - }; - }); -} - -describe('BoardsSelector', () => { - let wrapper; - let allBoardsResponse; - let recentBoardsResponse; - const boards = boardGenerator(20); - const recentBoards = boardGenerator(5); - - const fillSearchBox = (filterTerm) => { - const searchBox = wrapper.find({ ref: 'searchBox' }); - const searchBoxInput = searchBox.find('input'); - searchBoxInput.setValue(filterTerm); - searchBoxInput.trigger('input'); - }; - - const getDropdownItems = () => wrapper.findAll('.js-dropdown-item'); - const getDropdownHeaders = () => wrapper.findAll(GlDropdownSectionHeader); - const getLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findDropdown = () => wrapper.find(GlDropdown); - - beforeEach(() => { - const $apollo = { - queries: { - boards: { - loading: false, - }, - }, - }; - - boardsStore.setEndpoints({ - boardsEndpoint: '', - recentBoardsEndpoint: '', - listsEndpoint: '', - bulkUpdatePath: '', - boardId: '', - }); - - allBoardsResponse = Promise.resolve({ - data: { - group: { - boards: { - edges: boards.map((board) => ({ node: board })), - }, - }, - }, - }); - recentBoardsResponse = Promise.resolve({ - data: recentBoards, - }); - - boardsStore.allBoards = jest.fn(() => allBoardsResponse); - boardsStore.recentBoards = jest.fn(() => recentBoardsResponse); - - wrapper = mount(BoardsSelector, { - propsData: { - throttleDuration, - currentBoard: { - id: 1, - name: 'Development', - milestone_id: null, - weight: null, - assignee_id: null, - labels: [], - }, - boardBaseUrl: `${TEST_HOST}/board/base/url`, - hasMissingBoards: false, - canAdminBoard: true, - multipleIssueBoardsAvailable: true, - labelsPath: `${TEST_HOST}/labels/path`, - labelsWebUrl: `${TEST_HOST}/labels`, - projectId: 42, - groupId: 19, - scopedIssueBoardFeatureEnabled: true, - weights: [], - }, - mocks: { $apollo }, - attachTo: document.body, - }); - - wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => { - wrapper.setData({ - [options.loadingKey]: true, - }); - }); - - // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time - findDropdown().vm.$emit('show'); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('loading', () => { - // we are testing loading state, so don't resolve responses until after the tests - afterEach(() => { - return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick()); - }); - - it('shows loading spinner', () => { - expect(getDropdownHeaders()).toHaveLength(0); - expect(getDropdownItems()).toHaveLength(0); - expect(getLoadingIcon().exists()).toBe(true); - }); - }); - - describe('loaded', () => { - beforeEach(async () => { - await wrapper.setData({ - loadingBoards: false, - }); - return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick()); - }); - - it('hides loading spinner', () => { - expect(getLoadingIcon().exists()).toBe(false); - }); - - describe('filtering', () => { - beforeEach(() => { - wrapper.setData({ - boards, - }); - - return nextTick(); - }); - - it('shows all boards without filtering', () => { - expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length); - }); - - it('shows only matching boards when filtering', () => { - const filterTerm = 'board1'; - const expectedCount = boards.filter((board) => board.name.includes(filterTerm)).length; - - fillSearchBox(filterTerm); - - return nextTick().then(() => { - expect(getDropdownItems()).toHaveLength(expectedCount); - }); - }); - - it('shows message if there are no matching boards', () => { - fillSearchBox('does not exist'); - - return nextTick().then(() => { - expect(getDropdownItems()).toHaveLength(0); - expect(wrapper.text().includes('No matching boards found')).toBe(true); - }); - }); - }); - - describe('recent boards section', () => { - it('shows only when boards are greater than 10', () => { - wrapper.setData({ - boards, - }); - - return nextTick().then(() => { - expect(getDropdownHeaders()).toHaveLength(2); - }); - }); - - it('does not show when boards are less than 10', () => { - wrapper.setData({ - boards: boards.slice(0, 5), - }); - - return nextTick().then(() => { - expect(getDropdownHeaders()).toHaveLength(0); - }); - }); - - it('does not show when recentBoards api returns empty array', () => { - wrapper.setData({ - recentBoards: [], - }); - - return nextTick().then(() => { - expect(getDropdownHeaders()).toHaveLength(0); - }); - }); - - it('does not show when search is active', () => { - fillSearchBox('Random string'); - - return nextTick().then(() => { - expect(getDropdownHeaders()).toHaveLength(0); - }); - }); - }); - }); -}); diff --git a/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js b/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js deleted file mode 100644 index fafebaf3a4e..00000000000 --- a/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js +++ /dev/null @@ -1,64 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import IssueTimeEstimate from '~/boards/components/issue_time_estimate_deprecated.vue'; -import boardsStore from '~/boards/stores/boards_store'; - -describe('Issue Time Estimate component', () => { - let wrapper; - - beforeEach(() => { - boardsStore.create(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when limitToHours is false', () => { - beforeEach(() => { - boardsStore.timeTracking.limitToHours = false; - wrapper = shallowMount(IssueTimeEstimate, { - propsData: { - estimate: 374460, - }, - }); - }); - - it('renders the correct time estimate', () => { - expect(wrapper.find('time').text().trim()).toEqual('2w 3d 1m'); - }); - - it('renders expanded time estimate in tooltip', () => { - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute'); - }); - - it('prevents tooltip xss', (done) => { - const alertSpy = jest.spyOn(window, 'alert'); - wrapper.setProps({ estimate: 'Foo ' }); - wrapper.vm.$nextTick(() => { - expect(alertSpy).not.toHaveBeenCalled(); - expect(wrapper.find('time').text().trim()).toEqual('0m'); - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m'); - done(); - }); - }); - }); - - describe('when limitToHours is true', () => { - beforeEach(() => { - boardsStore.timeTracking.limitToHours = true; - wrapper = shallowMount(IssueTimeEstimate, { - propsData: { - estimate: 374460, - }, - }); - }); - - it('renders the correct time estimate', () => { - expect(wrapper.find('time').text().trim()).toEqual('104h 1m'); - }); - - it('renders expanded time estimate in tooltip', () => { - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('104 hours 1 minute'); - }); - }); -}); diff --git a/spec/frontend/boards/issue_card_deprecated_spec.js b/spec/frontend/boards/issue_card_deprecated_spec.js deleted file mode 100644 index 909be275030..00000000000 --- a/spec/frontend/boards/issue_card_deprecated_spec.js +++ /dev/null @@ -1,332 +0,0 @@ -/* global ListAssignee, ListLabel, ListIssue */ -import { GlLabel } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { range } from 'lodash'; -import '~/boards/models/label'; -import '~/boards/models/assignee'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import IssueCardInner from '~/boards/components/issue_card_inner_deprecated.vue'; -import store from '~/boards/stores'; -import { listObj } from './mock_data'; - -describe('Issue card component', () => { - const user = new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - avatar: 'test_image', - }); - - const label1 = new ListLabel({ - id: 3, - title: 'testing 123', - color: '#000CFF', - text_color: 'white', - description: 'test', - }); - - let wrapper; - let issue; - let list; - - beforeEach(() => { - list = { ...listObj, type: 'label' }; - issue = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [list.label], - assignees: [], - reference_path: '#1', - real_path: '/test/1', - weight: 1, - }); - wrapper = mount(IssueCardInner, { - propsData: { - list, - issue, - }, - store, - stubs: { - GlLabel: true, - }, - provide: { - groupId: null, - rootPath: '/', - }, - }); - }); - - it('renders issue title', () => { - expect(wrapper.find('.board-card-title').text()).toContain(issue.title); - }); - - it('includes issue base in link', () => { - expect(wrapper.find('.board-card-title a').attributes('href')).toContain('/test'); - }); - - it('includes issue title on link', () => { - expect(wrapper.find('.board-card-title a').attributes('title')).toBe(issue.title); - }); - - it('does not render confidential icon', () => { - expect(wrapper.find('.confidential-icon').exists()).toBe(false); - }); - - it('does not render blocked icon', () => { - expect(wrapper.find('.issue-blocked-icon').exists()).toBe(false); - }); - - it('renders confidential icon', (done) => { - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - confidential: true, - }, - }); - wrapper.vm.$nextTick(() => { - expect(wrapper.find('.confidential-icon').exists()).toBe(true); - done(); - }); - }); - - it('renders issue ID with #', () => { - expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.id}`); - }); - - describe('assignee', () => { - it('does not render assignee', () => { - expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false); - }); - - describe('exists', () => { - beforeEach((done) => { - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - assignees: [user], - updateData(newData) { - Object.assign(this, newData); - }, - }, - }); - - wrapper.vm.$nextTick(done); - }); - - it('renders assignee', () => { - expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(true); - }); - - it('sets title', () => { - expect(wrapper.find('.js-assignee-tooltip').text()).toContain(`${user.name}`); - }); - - it('sets users path', () => { - expect(wrapper.find('.board-card-assignee a').attributes('href')).toBe('/test'); - }); - - it('renders avatar', () => { - expect(wrapper.find('.board-card-assignee img').exists()).toBe(true); - }); - - it('renders the avatar using avatar_url property', (done) => { - wrapper.props('issue').updateData({ - ...wrapper.props('issue'), - assignees: [ - { - id: '1', - name: 'test', - state: 'active', - username: 'test_name', - avatar_url: 'test_image_from_avatar_url', - }, - ], - }); - - wrapper.vm.$nextTick(() => { - expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe( - 'test_image_from_avatar_url?width=24', - ); - done(); - }); - }); - }); - - describe('assignee default avatar', () => { - beforeEach((done) => { - global.gon.default_avatar_url = 'default_avatar'; - - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - assignees: [ - new ListAssignee({ - id: 1, - name: 'testing 123', - username: 'test', - }), - ], - }, - }); - - wrapper.vm.$nextTick(done); - }); - - afterEach(() => { - global.gon.default_avatar_url = null; - }); - - it('displays defaults avatar if users avatar is null', () => { - expect(wrapper.find('.board-card-assignee img').exists()).toBe(true); - expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe( - 'default_avatar?width=24', - ); - }); - }); - }); - - describe('multiple assignees', () => { - beforeEach((done) => { - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - assignees: [ - new ListAssignee({ - id: 2, - name: 'user2', - username: 'user2', - avatar: 'test_image', - }), - new ListAssignee({ - id: 3, - name: 'user3', - username: 'user3', - avatar: 'test_image', - }), - new ListAssignee({ - id: 4, - name: 'user4', - username: 'user4', - avatar: 'test_image', - }), - ], - }, - }); - - wrapper.vm.$nextTick(done); - }); - - it('renders all three assignees', () => { - expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(3); - }); - - describe('more than three assignees', () => { - beforeEach((done) => { - const { assignees } = wrapper.props('issue'); - assignees.push( - new ListAssignee({ - id: 5, - name: 'user5', - username: 'user5', - avatar: 'test_image', - }), - ); - - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - assignees, - }, - }); - wrapper.vm.$nextTick(done); - }); - - it('renders more avatar counter', () => { - expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('+2'); - }); - - it('renders two assignees', () => { - expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(2); - }); - - it('renders 99+ avatar counter', (done) => { - const assignees = [ - ...wrapper.props('issue').assignees, - ...range(5, 103).map( - (i) => - new ListAssignee({ - id: i, - name: 'name', - username: 'username', - avatar: 'test_image', - }), - ), - ]; - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - assignees, - }, - }); - - wrapper.vm.$nextTick(() => { - expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('99+'); - done(); - }); - }); - }); - }); - - describe('labels', () => { - beforeEach((done) => { - issue.addLabel(label1); - wrapper.setProps({ issue: { ...issue } }); - - wrapper.vm.$nextTick(done); - }); - - it('does not render list label but renders all other labels', () => { - expect(wrapper.findAll(GlLabel).length).toBe(1); - const label = wrapper.find(GlLabel); - expect(label.props('title')).toEqual(label1.title); - expect(label.props('description')).toEqual(label1.description); - expect(label.props('backgroundColor')).toEqual(label1.color); - }); - - it('does not render label if label does not have an ID', (done) => { - issue.addLabel( - new ListLabel({ - title: 'closed', - }), - ); - wrapper.setProps({ issue: { ...issue } }); - wrapper.vm - .$nextTick() - .then(() => { - expect(wrapper.findAll(GlLabel).length).toBe(1); - expect(wrapper.text()).not.toContain('closed'); - done(); - }) - .catch(done.fail); - }); - }); - - describe('blocked', () => { - beforeEach((done) => { - wrapper.setProps({ - issue: { - ...wrapper.props('issue'), - blocked: true, - }, - }); - wrapper.vm.$nextTick(done); - }); - - it('renders blocked icon if issue is blocked', () => { - expect(wrapper.find('.issue-blocked-icon').exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/boards/project_select_deprecated_spec.js b/spec/frontend/boards/project_select_deprecated_spec.js deleted file mode 100644 index 4494de43083..00000000000 --- a/spec/frontend/boards/project_select_deprecated_spec.js +++ /dev/null @@ -1,263 +0,0 @@ -import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import axios from 'axios'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import ProjectSelect from '~/boards/components/project_select_deprecated.vue'; -import { ListType } from '~/boards/constants'; -import eventHub from '~/boards/eventhub'; -import createFlash from '~/flash'; -import httpStatus from '~/lib/utils/http_status'; -import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; - -import { listObj, mockRawGroupProjects } from './mock_data'; - -jest.mock('~/boards/eventhub'); -jest.mock('~/flash'); - -const dummyGon = { - api_version: 'v4', - relative_url_root: '/gitlab', -}; - -const mockGroupId = 1; -const mockProjectsList1 = mockRawGroupProjects.slice(0, 1); -const mockProjectsList2 = mockRawGroupProjects.slice(1); -const mockDefaultFetchOptions = { - with_issues_enabled: true, - with_shared: false, - include_subgroups: true, - order_by: 'similarity', - archived: false, -}; - -const itemsPerPage = 20; - -describe('ProjectSelect component', () => { - let wrapper; - let axiosMock; - - const findLabel = () => wrapper.find("[data-testid='header-label']"); - const findGlDropdown = () => wrapper.find(GlDropdown); - const findGlDropdownLoadingIcon = () => - findGlDropdown().find('button:first-child').find(GlLoadingIcon); - const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType); - const findGlDropdownItems = () => wrapper.findAll(GlDropdownItem); - const findFirstGlDropdownItem = () => findGlDropdownItems().at(0); - const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']"); - const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']"); - - const mockGetRequest = (data = [], statusCode = httpStatus.OK) => { - axiosMock - .onGet(`/gitlab/api/v4/groups/${mockGroupId}/projects.json`) - .replyOnce(statusCode, data); - }; - - const searchForProject = async (keyword, waitForAll = true) => { - findGlSearchBoxByType().vm.$emit('input', keyword); - - if (waitForAll) { - await axios.waitForAll(); - } - }; - - const createWrapper = async ({ list = listObj } = {}, waitForAll = true) => { - wrapper = mount(ProjectSelect, { - propsData: { - list, - }, - provide: { - groupId: 1, - }, - }); - - if (waitForAll) { - await axios.waitForAll(); - } - }; - - beforeEach(() => { - axiosMock = new AxiosMockAdapter(axios); - window.gon = dummyGon; - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - axiosMock.restore(); - jest.clearAllMocks(); - }); - - it('displays a header title', async () => { - createWrapper({}); - - expect(findLabel().text()).toBe('Projects'); - }); - - it('renders a default dropdown text', async () => { - createWrapper({}); - - expect(findGlDropdown().exists()).toBe(true); - expect(findGlDropdown().text()).toContain('Select a project'); - }); - - describe('when mounted', () => { - it('displays a loading icon while projects are being fetched', async () => { - mockGetRequest([]); - - createWrapper({}, false); - - expect(findGlDropdownLoadingIcon().exists()).toBe(true); - - await axios.waitForAll(); - - expect(axiosMock.history.get[0].params).toMatchObject({ search: '' }); - expect(axiosMock.history.get[0].url).toBe( - `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, - ); - - expect(findGlDropdownLoadingIcon().exists()).toBe(false); - }); - }); - - describe('when dropdown menu is open', () => { - describe('by default', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList1); - - await createWrapper(); - }); - - it('shows GlSearchBoxByType with default attributes', () => { - expect(findGlSearchBoxByType().exists()).toBe(true); - expect(findGlSearchBoxByType().vm.$attrs).toMatchObject({ - placeholder: 'Search projects', - debounce: '250', - }); - }); - - it("displays the fetched project's name", () => { - expect(findFirstGlDropdownItem().exists()).toBe(true); - expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList1[0].name); - }); - - it("doesn't render loading icon in the menu", () => { - expect(findInMenuLoadingIcon().isVisible()).toBe(false); - }); - - it('renders empty search result message', async () => { - await createWrapper(); - - expect(findEmptySearchMessage().exists()).toBe(true); - }); - }); - - describe('when a project is selected', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList1); - - await createWrapper(); - - await findFirstGlDropdownItem().find('button').trigger('click'); - }); - - it('emits setSelectedProject with correct project metadata', () => { - expect(eventHub.$emit).toHaveBeenCalledWith('setSelectedProject', { - id: mockProjectsList1[0].id, - path: mockProjectsList1[0].path_with_namespace, - name: mockProjectsList1[0].name, - namespacedName: mockProjectsList1[0].name_with_namespace, - }); - }); - - it('renders the name of the selected project', () => { - expect(findGlDropdown().find('.gl-new-dropdown-button-text').text()).toBe( - mockProjectsList1[0].name, - ); - }); - }); - - describe('when user searches for a project', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList1); - - await createWrapper(); - }); - - it('calls API with correct parameters with default fetch options', async () => { - await searchForProject('foobar'); - - const expectedApiParams = { - search: 'foobar', - per_page: itemsPerPage, - ...mockDefaultFetchOptions, - }; - - expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams); - expect(axiosMock.history.get[1].url).toBe( - `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, - ); - }); - - describe("when list type is defined and isn't backlog", () => { - it('calls API with an additional fetch option (min_access_level)', async () => { - axiosMock.reset(); - - await createWrapper({ list: { ...listObj, type: ListType.label } }); - - await searchForProject('foobar'); - - const expectedApiParams = { - search: 'foobar', - per_page: itemsPerPage, - ...mockDefaultFetchOptions, - min_access_level: featureAccessLevel.EVERYONE, - }; - - expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams); - expect(axiosMock.history.get[1].url).toBe( - `/gitlab/api/v4/groups/${mockGroupId}/projects.json`, - ); - }); - }); - - it('displays and hides gl-loading-icon while and after fetching data', async () => { - await searchForProject('some keyword', false); - - await wrapper.vm.$nextTick(); - - expect(findInMenuLoadingIcon().isVisible()).toBe(true); - - await axios.waitForAll(); - - expect(findInMenuLoadingIcon().isVisible()).toBe(false); - }); - - it('flashes an error message when fetching fails', async () => { - mockGetRequest([], httpStatus.INTERNAL_SERVER_ERROR); - - await searchForProject('foobar'); - - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ - message: 'Something went wrong while fetching projects', - }); - }); - - describe('with non-empty search result', () => { - beforeEach(async () => { - mockGetRequest(mockProjectsList2); - - await searchForProject('foobar'); - }); - - it('displays the retrieved list of projects', async () => { - expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList2[0].name); - }); - - it('does not render empty search result message', async () => { - expect(findEmptySearchMessage().exists()).toBe(false); - }); - }); - }); - }); -}); diff --git a/spec/initializers/validate_database_config_spec.rb b/spec/initializers/validate_database_config_spec.rb new file mode 100644 index 00000000000..99e4a4b36ee --- /dev/null +++ b/spec/initializers/validate_database_config_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'validate database config' do + include RakeHelpers + include StubENV + + let(:rails_configuration) { Rails::Application::Configuration.new(Rails.root) } + let(:ar_configurations) { ActiveRecord::DatabaseConfigurations.new(rails_configuration.database_configuration) } + + subject do + load Rails.root.join('config/initializers/validate_database_config.rb') + end + + before do + # The `AS::ConfigurationFile` calls `read` in `def initialize` + # thus we cannot use `expect_next_instance_of` + # rubocop:disable RSpec/AnyInstanceOf + expect_any_instance_of(ActiveSupport::ConfigurationFile) + .to receive(:read).with(Rails.root.join('config/database.yml')).and_return(database_yml) + # rubocop:enable RSpec/AnyInstanceOf + + allow(Rails.application).to receive(:config).and_return(rails_configuration) + allow(ActiveRecord::Base).to receive(:configurations).and_return(ar_configurations) + end + + shared_examples 'with SKIP_DATABASE_CONFIG_VALIDATION=true' do + before do + stub_env('SKIP_DATABASE_CONFIG_VALIDATION', 'true') + end + + it 'does not raise exception' do + expect { subject }.not_to raise_error + end + end + + context 'when config/database.yml is valid' do + context 'uses legacy syntax' do + let(:database_yml) do + <<-EOS + production: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + EOS + end + + it 'validates configuration with a warning' do + expect(main_object).to receive(:warn).with /uses a deprecated syntax for/ + + expect { subject }.not_to raise_error + end + + it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true' + end + + context 'uses new syntax' do + let(:database_yml) do + <<-EOS + production: + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + EOS + end + + it 'validates configuration without errors and warnings' do + expect(main_object).not_to receive(:warn) + + expect { subject }.not_to raise_error + end + end + end + + context 'when config/database.yml is invalid' do + context 'uses unknown connection name' do + let(:database_yml) do + <<-EOS + production: + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + + another: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + EOS + end + + it 'raises exception' do + expect { subject }.to raise_error /This installation of GitLab uses unsupported database names/ + end + + it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true' + end + + context 'uses replica configuration' do + let(:database_yml) do + <<-EOS + production: + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + replica: true + EOS + end + + it 'raises exception' do + expect { subject }.to raise_error /with 'replica: true' parameter in/ + end + + it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true' + end + + context 'main is not a first entry' do + let(:database_yml) do + <<-EOS + production: + ci: + adapter: postgresql + encoding: unicode + database: gitlabhq_production_ci + username: git + password: "secure password" + host: localhost + replica: true + + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + replica: true + EOS + end + + it 'raises exception' do + expect { subject }.to raise_error /The `main:` database needs to be defined as a first configuration item/ + end + + it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true' + end + end +end diff --git a/spec/lib/backup/gitaly_rpc_backup_spec.rb b/spec/lib/backup/gitaly_rpc_backup_spec.rb deleted file mode 100644 index fb442f4a86f..00000000000 --- a/spec/lib/backup/gitaly_rpc_backup_spec.rb +++ /dev/null @@ -1,153 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Backup::GitalyRpcBackup do - let(:progress) { spy(:stdout) } - - subject { described_class.new(progress) } - - after do - # make sure we do not leave behind any backup files - FileUtils.rm_rf(File.join(Gitlab.config.backup.path, 'repositories')) - end - - context 'unknown' do - it 'fails to start unknown' do - expect { subject.start(:unknown) }.to raise_error(::Backup::Error, 'unknown backup type: unknown') - end - end - - context 'create' do - RSpec.shared_examples 'creates a repository backup' do - it 'creates repository bundles', :aggregate_failures do - # Add data to the wiki, design repositories, and snippets, so they will be included in the dump. - create(:wiki_page, container: project) - create(:design, :with_file, issue: create(:issue, project: project)) - project_snippet = create(:project_snippet, :repository, project: project) - personal_snippet = create(:personal_snippet, :repository, author: project.owner) - - subject.start(:create) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.enqueue(project, Gitlab::GlRepository::WIKI) - subject.enqueue(project, Gitlab::GlRepository::DESIGN) - subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) - subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.wait - - expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.bundle')) - expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.wiki.bundle')) - expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.design.bundle')) - expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', personal_snippet.disk_path + '.bundle')) - expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project_snippet.disk_path + '.bundle')) - end - - context 'failure' do - before do - allow_next_instance_of(Repository) do |repository| - allow(repository).to receive(:bundle_to_disk) { raise 'Fail in tests' } - end - end - - it 'logs an appropriate message', :aggregate_failures do - subject.start(:create) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.wait - - expect(progress).to have_received(:puts).with("[Failed] backing up #{project.full_path} (#{project.disk_path})") - expect(progress).to have_received(:puts).with("Error Fail in tests") - end - end - end - - context 'hashed storage' do - let_it_be(:project) { create(:project, :repository) } - - it_behaves_like 'creates a repository backup' - end - - context 'legacy storage' do - let_it_be(:project) { create(:project, :repository, :legacy_storage) } - - it_behaves_like 'creates a repository backup' - end - end - - context 'restore' do - let_it_be(:project) { create(:project, :repository) } - let_it_be(:personal_snippet) { create(:personal_snippet, author: project.owner) } - let_it_be(:project_snippet) { create(:project_snippet, project: project, author: project.owner) } - - def copy_bundle_to_backup_path(bundle_name, destination) - FileUtils.mkdir_p(File.join(Gitlab.config.backup.path, 'repositories', File.dirname(destination))) - FileUtils.cp(Rails.root.join('spec/fixtures/lib/backup', bundle_name), File.join(Gitlab.config.backup.path, 'repositories', destination)) - end - - it 'restores from repository bundles', :aggregate_failures do - copy_bundle_to_backup_path('project_repo.bundle', project.disk_path + '.bundle') - copy_bundle_to_backup_path('wiki_repo.bundle', project.disk_path + '.wiki.bundle') - copy_bundle_to_backup_path('design_repo.bundle', project.disk_path + '.design.bundle') - copy_bundle_to_backup_path('personal_snippet_repo.bundle', personal_snippet.disk_path + '.bundle') - copy_bundle_to_backup_path('project_snippet_repo.bundle', project_snippet.disk_path + '.bundle') - - subject.start(:restore) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.enqueue(project, Gitlab::GlRepository::WIKI) - subject.enqueue(project, Gitlab::GlRepository::DESIGN) - subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) - subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.wait - - collect_commit_shas = -> (repo) { repo.commits('master', limit: 10).map(&:sha) } - - expect(collect_commit_shas.call(project.repository)).to eq(['393a7d860a5a4c3cc736d7eb00604e3472bb95ec']) - expect(collect_commit_shas.call(project.wiki.repository)).to eq(['c74b9948d0088d703ee1fafeddd9ed9add2901ea']) - expect(collect_commit_shas.call(project.design_repository)).to eq(['c3cd4d7bd73a51a0f22045c3a4c871c435dc959d']) - expect(collect_commit_shas.call(personal_snippet.repository)).to eq(['3b3c067a3bc1d1b695b51e2be30c0f8cf698a06e']) - expect(collect_commit_shas.call(project_snippet.repository)).to eq(['6e44ba56a4748be361a841e759c20e421a1651a1']) - end - - it 'cleans existing repositories', :aggregate_failures do - expect_next_instance_of(DesignManagement::Repository) do |repository| - expect(repository).to receive(:remove) - end - - # 4 times = project repo + wiki repo + project_snippet repo + personal_snippet repo - expect(Repository).to receive(:new).exactly(4).times.and_wrap_original do |method, *original_args| - full_path, container, kwargs = original_args - - repository = method.call(full_path, container, **kwargs) - - expect(repository).to receive(:remove) - - repository - end - - subject.start(:restore) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.enqueue(project, Gitlab::GlRepository::WIKI) - subject.enqueue(project, Gitlab::GlRepository::DESIGN) - subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) - subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.wait - end - - context 'failure' do - before do - allow_next_instance_of(Repository) do |repository| - allow(repository).to receive(:create_repository) { raise 'Fail in tests' } - allow(repository).to receive(:create_from_bundle) { raise 'Fail in tests' } - end - end - - it 'logs an appropriate message', :aggregate_failures do - subject.start(:restore) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.wait - - expect(progress).to have_received(:puts).with("[Failed] restoring #{project.full_path} (#{project.disk_path})") - expect(progress).to have_received(:puts).with("Error Fail in tests") - end - end - end -end diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb index 85818038c9d..a991ddc62db 100644 --- a/spec/lib/backup/repositories_spec.rb +++ b/spec/lib/backup/repositories_spec.rb @@ -4,8 +4,7 @@ require 'spec_helper' RSpec.describe Backup::Repositories do let(:progress) { spy(:stdout) } - let(:parallel_enqueue) { true } - let(:strategy) { spy(:strategy, parallel_enqueue?: parallel_enqueue) } + let(:strategy) { spy(:strategy) } subject { described_class.new(progress, strategy: strategy) } @@ -17,7 +16,7 @@ RSpec.describe Backup::Repositories do project_snippet = create(:project_snippet, :repository, project: project) personal_snippet = create(:personal_snippet, :repository, author: project.owner) - subject.dump(max_concurrency: 1, max_storage_concurrency: 1) + subject.dump expect(strategy).to have_received(:start).with(:create) expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::PROJECT) @@ -41,132 +40,30 @@ RSpec.describe Backup::Repositories do it_behaves_like 'creates repository bundles' end - context 'no concurrency' do - it 'creates the expected number of threads' do - expect(Thread).not_to receive(:new) + context 'command failure' do + it 'enqueue_project raises an error' do + allow(strategy).to receive(:enqueue).with(anything, Gitlab::GlRepository::PROJECT).and_raise(IOError) - expect(strategy).to receive(:start).with(:create) - projects.each do |project| - expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) - end - expect(strategy).to receive(:wait) - - subject.dump(max_concurrency: 1, max_storage_concurrency: 1) + expect { subject.dump }.to raise_error(IOError) end - describe 'command failure' do - it 'enqueue_project raises an error' do - allow(strategy).to receive(:enqueue).with(anything, Gitlab::GlRepository::PROJECT).and_raise(IOError) + it 'project query raises an error' do + allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout) - expect { subject.dump(max_concurrency: 1, max_storage_concurrency: 1) }.to raise_error(IOError) - end - - it 'project query raises an error' do - allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout) - - expect { subject.dump(max_concurrency: 1, max_storage_concurrency: 1) }.to raise_error(ActiveRecord::StatementTimeout) - end - end - - it 'avoids N+1 database queries' do - control_count = ActiveRecord::QueryRecorder.new do - subject.dump(max_concurrency: 1, max_storage_concurrency: 1) - end.count - - create_list(:project, 2, :repository) - - expect do - subject.dump(max_concurrency: 1, max_storage_concurrency: 1) - end.not_to exceed_query_limit(control_count) + expect { subject.dump }.to raise_error(ActiveRecord::StatementTimeout) end end - context 'concurrency with a strategy without parallel enqueueing support' do - let(:parallel_enqueue) { false } + it 'avoids N+1 database queries' do + control_count = ActiveRecord::QueryRecorder.new do + subject.dump + end.count - it 'enqueues all projects sequentially' do - expect(Thread).not_to receive(:new) + create_list(:project, 2, :repository) - expect(strategy).to receive(:start).with(:create) - projects.each do |project| - expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) - end - expect(strategy).to receive(:wait) - - subject.dump(max_concurrency: 2, max_storage_concurrency: 2) - end - end - - [4, 10].each do |max_storage_concurrency| - context "max_storage_concurrency #{max_storage_concurrency}", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/241701' do - let(:storage_keys) { %w[default test_second_storage] } - - before do - allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(storage_keys) - end - - it 'creates the expected number of threads' do - expect(Thread).to receive(:new) - .exactly(storage_keys.length * (max_storage_concurrency + 1)).times - .and_call_original - - expect(strategy).to receive(:start).with(:create) - projects.each do |project| - expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) - end - expect(strategy).to receive(:wait) - - subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) - end - - it 'creates the expected number of threads with extra max concurrency' do - expect(Thread).to receive(:new) - .exactly(storage_keys.length * (max_storage_concurrency + 1)).times - .and_call_original - - expect(strategy).to receive(:start).with(:create) - projects.each do |project| - expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) - end - expect(strategy).to receive(:wait) - - subject.dump(max_concurrency: 3, max_storage_concurrency: max_storage_concurrency) - end - - describe 'command failure' do - it 'enqueue_project raises an error' do - allow(strategy).to receive(:enqueue).and_raise(IOError) - - expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(IOError) - end - - it 'project query raises an error' do - allow(Project).to receive_message_chain(:for_repository_storage, :includes, :find_each).and_raise(ActiveRecord::StatementTimeout) - - expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(ActiveRecord::StatementTimeout) - end - - context 'misconfigured storages' do - let(:storage_keys) { %w[test_second_storage] } - - it 'raises an error' do - expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(Backup::Error, 'repositories.storages in gitlab.yml is misconfigured') - end - end - end - - it 'avoids N+1 database queries' do - control_count = ActiveRecord::QueryRecorder.new do - subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) - end.count - - create_list(:project, 2, :repository) - - expect do - subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) - end.not_to exceed_query_limit(control_count) - end - end + expect do + subject.dump + end.not_to exceed_query_limit(control_count) end end diff --git a/spec/lib/gitlab/patch/legacy_database_config_spec.rb b/spec/lib/gitlab/patch/legacy_database_config_spec.rb new file mode 100644 index 00000000000..e6c0bdbf360 --- /dev/null +++ b/spec/lib/gitlab/patch/legacy_database_config_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Patch::LegacyDatabaseConfig do + it 'module is included' do + expect(Rails::Application::Configuration).to include(described_class) + end + + describe 'config/database.yml' do + let(:configuration) { Rails::Application::Configuration.new(Rails.root) } + + before do + # The `AS::ConfigurationFile` calls `read` in `def initialize` + # thus we cannot use `expect_next_instance_of` + # rubocop:disable RSpec/AnyInstanceOf + expect_any_instance_of(ActiveSupport::ConfigurationFile) + .to receive(:read).with(Rails.root.join('config/database.yml')).and_return(database_yml) + # rubocop:enable RSpec/AnyInstanceOf + end + + shared_examples 'hash containing main: connection name' do + it 'returns a hash containing only main:' do + database_configuration = configuration.database_configuration + + expect(database_configuration).to match( + "production" => { "main" => a_hash_including("adapter") }, + "development" => { "main" => a_hash_including("adapter" => "postgresql") }, + "test" => { "main" => a_hash_including("adapter" => "postgresql") } + ) + end + end + + context 'when a new syntax is used' do + let(:database_yml) do + <<-EOS + production: + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + + development: + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_development + username: postgres + password: "secure password" + host: localhost + variables: + statement_timeout: 15s + + test: &test + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_test + username: postgres + password: + host: localhost + prepared_statements: false + variables: + statement_timeout: 15s + EOS + end + + include_examples 'hash containing main: connection name' + + it 'configuration is not legacy one' do + configuration.database_configuration + + expect(configuration.uses_legacy_database_config).to eq(false) + end + end + + context 'when a legacy syntax is used' do + let(:database_yml) do + <<-EOS + production: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + + development: + adapter: postgresql + encoding: unicode + database: gitlabhq_development + username: postgres + password: "secure password" + host: localhost + variables: + statement_timeout: 15s + + test: &test + adapter: postgresql + encoding: unicode + database: gitlabhq_test + username: postgres + password: + host: localhost + prepared_statements: false + variables: + statement_timeout: 15s + EOS + end + + include_examples 'hash containing main: connection name' + + it 'configuration is legacy' do + configuration.database_configuration + + expect(configuration.uses_legacy_database_config).to eq(true) + end + end + end +end diff --git a/spec/migrations/remove_duplicate_dast_site_tokens_spec.rb b/spec/migrations/remove_duplicate_dast_site_tokens_spec.rb new file mode 100644 index 00000000000..fed9941b2a4 --- /dev/null +++ b/spec/migrations/remove_duplicate_dast_site_tokens_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe RemoveDuplicateDastSiteTokens do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:dast_site_tokens) { table(:dast_site_tokens) } + let!(:namespace) { namespaces.create!(id: 1, name: 'group', path: 'group') } + let!(:project1) { projects.create!(id: 1, namespace_id: namespace.id, path: 'project1') } + # create non duplicate dast site token + let!(:dast_site_token1) { dast_site_tokens.create!(project_id: project1.id, url: 'https://gitlab.com', token: SecureRandom.uuid) } + + context 'when duplicate dast site tokens exists' do + # create duplicate dast site token + let_it_be(:duplicate_url) { 'https://about.gitlab.com' } + + let!(:project2) { projects.create!(id: 2, namespace_id: namespace.id, path: 'project2') } + let!(:dast_site_token2) { dast_site_tokens.create!(project_id: project2.id, url: duplicate_url, token: SecureRandom.uuid) } + let!(:dast_site_token3) { dast_site_tokens.create!(project_id: project2.id, url: 'https://temp_url.com', token: SecureRandom.uuid) } + let!(:dast_site_token4) { dast_site_tokens.create!(project_id: project2.id, url: 'https://other_temp_url.com', token: SecureRandom.uuid) } + + before 'update URL to bypass uniqueness validation' do + dast_site_tokens.where(project_id: 2).update_all(url: duplicate_url) + end + + describe 'migration up' do + it 'does remove duplicated dast site tokens' do + expect(dast_site_tokens.count).to eq(4) + expect(dast_site_tokens.where(project_id: 2, url: duplicate_url).size).to eq(3) + + migrate! + + expect(dast_site_tokens.count).to eq(2) + expect(dast_site_tokens.where(project_id: 2, url: duplicate_url).size).to eq(1) + end + end + end + + context 'when duplicate dast site tokens does not exists' do + before do + dast_site_tokens.create!(project_id: 1, url: 'https://about.gitlab.com/handbook', token: SecureRandom.uuid) + end + + describe 'migration up' do + it 'does remove duplicated dast site tokens' do + expect { migrate! }.not_to change(dast_site_tokens, :count) + end + end + end +end diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index 99deaa8d154..6397760f055 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -383,30 +383,10 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do create(:project, :repository) end - it 'has defaults' do - expect_next_instance_of(::Backup::Repositories) do |instance| - expect(instance).to receive(:dump) - .with(max_concurrency: 1, max_storage_concurrency: 1) - .and_call_original - end - - expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process - end - it 'passes through concurrency environment variables' do - # The way concurrency is handled will change with the `gitaly_backup` - # feature flag. For now we need to check that both ways continue to - # work. This will be cleaned up in the rollout issue. - # See https://gitlab.com/gitlab-org/gitlab/-/issues/333034 - stub_env('GITLAB_BACKUP_MAX_CONCURRENCY', 5) stub_env('GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY', 2) - expect_next_instance_of(::Backup::Repositories) do |instance| - expect(instance).to receive(:dump) - .with(max_concurrency: 5, max_storage_concurrency: 2) - .and_call_original - end expect(::Backup::GitalyBackup).to receive(:new).with(anything, parallel: 5, parallel_storage: 2).and_call_original expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process diff --git a/yarn.lock b/yarn.lock index 8dc9f756b22..08b09b24819 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3898,10 +3898,10 @@ core-js-pure@^3.0.0: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813" integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA== -core-js@^3.1.3, core-js@^3.16.4: - version "3.16.4" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.16.4.tgz#0fb1029a554fc2688c0963d7c900e188188a78e0" - integrity sha512-Tq4GVE6XCjE+hcyW6hPy0ofN3hwtLudz5ZRdrlCnsnD/xkm/PWQRudzYHiKgZKUcefV6Q57fhDHjZHJP5dpfSg== +core-js@^3.1.3, core-js@^3.17.1: + version "3.17.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.17.1.tgz#b39e086f413789cf2ca4680c4ecd1b36a50ba277" + integrity sha512-C8i/FNpVN2Ti89QIJcFn9ZQmnM+HaAQr2OpE+ja3TRM9Q34FigsGlAVuwPGkIgydSVClo/1l1D1grP8LVt9IYA== core-js@~2.3.0: version "2.3.0"