From 8cdf31a1f97786973eb60564ef667e8416d1b1c8 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 12 Aug 2021 21:10:33 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .../components/content_editor.vue | 107 ++++++++--- .../components/content_editor_provider.vue | 24 +++ .../content_editor/services/content_editor.js | 4 + .../content_editor/services/upload_helpers.js | 8 +- .../shared/wikis/components/wiki_form.vue | 73 ++++---- .../stylesheets/framework/markdown_area.scss | 5 +- .../secondary_navigation_elements.scss | 1 - app/assets/stylesheets/pages/note_form.scss | 2 +- app/finders/packages/pypi/packages_finder.rb | 7 +- ...ests_forwarding_to_application_settings.rb | 17 ++ ...lize_ci_job_artifacts_bigint_conversion.rb | 84 +++++++++ db/schema_migrations/20210706212710 | 1 + db/schema_migrations/20210806152104 | 1 + db/structure.sql | 5 +- doc/api/settings.md | 3 + .../settings/continuous_integration.md | 16 +- ..._registry_npm_package_requests_forward.png | Bin 28630 -> 0 bytes doc/user/packages/pypi_repository/index.md | 5 + .../packages/dependency_proxy_helpers.rb | 23 ++- lib/api/pypi_packages.rb | 39 ++-- locale/gitlab.pot | 32 +++- .../packages/pypi/packages_finder_spec.rb | 10 +- .../components/content_editor_spec.js | 170 +++++++++++++----- .../extensions/attachment_spec.js | 10 +- .../services/content_editor_spec.js | 12 ++ .../shared/wikis/components/wiki_form_spec.js | 5 - .../packages/dependency_proxy_helpers_spec.rb | 62 +++++-- spec/requests/api/pypi_packages_spec.rb | 8 +- ...dependency_proxy_helpers_shared_context.rb | 9 + .../api/npm_packages_shared_examples.rb | 4 +- .../api/npm_packages_tags_shared_examples.rb | 3 +- .../api/pypi_packages_shared_examples.rb | 29 +++ 32 files changed, 592 insertions(+), 187 deletions(-) create mode 100644 app/assets/javascripts/content_editor/components/content_editor_provider.vue create mode 100644 db/migrate/20210806152104_add_pypi_package_requests_forwarding_to_application_settings.rb create mode 100644 db/post_migrate/20210706212710_finalize_ci_job_artifacts_bigint_conversion.rb create mode 100644 db/schema_migrations/20210706212710 create mode 100644 db/schema_migrations/20210806152104 delete mode 100644 doc/user/admin_area/settings/img/admin_package_registry_npm_package_requests_forward.png create mode 100644 spec/support/shared_contexts/lib/api/helpers/packages/dependency_proxy_helpers_shared_context.rb diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 70e6cbe9aca..965fcc6ce79 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -1,53 +1,106 @@ diff --git a/app/assets/javascripts/content_editor/components/content_editor_provider.vue b/app/assets/javascripts/content_editor/components/content_editor_provider.vue new file mode 100644 index 00000000000..630aff9858f --- /dev/null +++ b/app/assets/javascripts/content_editor/components/content_editor_provider.vue @@ -0,0 +1,24 @@ + diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index 209160ad80c..a387322bff7 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -19,6 +19,10 @@ export class ContentEditor { return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0); } + dispose() { + this.tiptapEditor.destroy(); + } + once(type, handler) { this._eventHub.$once(type, handler); } diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js index 7abd3211a72..8ac3f719309 100644 --- a/app/assets/javascripts/content_editor/services/upload_helpers.js +++ b/app/assets/javascripts/content_editor/services/upload_helpers.js @@ -72,7 +72,9 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => { ); } catch (e) { editor.commands.deleteRange({ from: position, to: position + 1 }); - editor.emit('error', __('An error occurred while uploading the image. Please try again.')); + editor.emit('error', { + error: __('An error occurred while uploading the image. Please try again.'), + }); } }; @@ -100,7 +102,9 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) = ); } catch (e) { editor.commands.deleteRange({ from, to: from + 1 }); - editor.emit('error', __('An error occurred while uploading the file. Please try again.')); + editor.emit('error', { + error: __('An error occurred while uploading the file. Please try again.'), + }); } }; diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 1f211b18af1..68865281b27 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -6,7 +6,6 @@ import { GlButton, GlSprintf, GlAlert, - GlLoadingIcon, GlModal, GlModalDirective, } from '@gitlab/ui'; @@ -114,7 +113,6 @@ export default { GlButton, GlModal, MarkdownField, - GlLoadingIcon, ContentEditor: () => import( /* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue' @@ -136,11 +134,12 @@ export default { commitMessage: '', isDirty: false, contentEditorRenderFailed: false, + contentEditorEmpty: false, }; }, computed: { noContent() { - if (this.isContentEditorActive) return this.contentEditor?.empty; + if (this.isContentEditorActive) return this.contentEditorEmpty; return !this.content.trim(); }, csrfToken() { @@ -205,7 +204,7 @@ export default { window.removeEventListener('beforeunload', this.onPageUnload); }, methods: { - getContentHTML(content) { + renderMarkdown(content) { return axios .post(this.pageInfo.markdownPreviewPath, { text: content }) .then(({ data }) => data.body); @@ -232,6 +231,32 @@ export default { this.isDirty = true; }, + async loadInitialContent(contentEditor) { + this.contentEditor = contentEditor; + + try { + await this.contentEditor.setSerializedContent(this.content); + this.trackContentEditorLoaded(); + } catch (e) { + this.contentEditorRenderFailed = true; + } + }, + + async retryInitContentEditor() { + try { + this.contentEditorRenderFailed = false; + await this.contentEditor.setSerializedContent(this.content); + } catch (e) { + this.contentEditorRenderFailed = true; + } + }, + + handleContentEditorChange({ empty }) { + this.contentEditorEmpty = empty; + // TODO: Implement a precise mechanism to detect changes in the Content + this.isDirty = true; + }, + onPageUnload(event) { if (!this.isDirty) return undefined; @@ -252,36 +277,8 @@ export default { this.commitMessage = newCommitMessage; }, - async initContentEditor() { - this.isContentEditorLoading = true; + initContentEditor() { this.useContentEditor = true; - - const { createContentEditor } = await import( - /* webpackChunkName: 'content_editor' */ '~/content_editor/services/create_content_editor' - ); - this.contentEditor = - this.contentEditor || - createContentEditor({ - renderMarkdown: (markdown) => this.getContentHTML(markdown), - uploadsPath: this.pageInfo.uploadsPath, - tiptapOptions: { - onUpdate: () => this.handleContentChange(), - }, - }); - - try { - await this.contentEditor.setSerializedContent(this.content); - this.isContentEditorLoading = false; - - this.trackContentEditorLoaded(); - } catch (e) { - this.contentEditorRenderFailed = true; - } - }, - - retryInitContentEditor() { - this.contentEditorRenderFailed = false; - this.initContentEditor(); }, switchToOldEditor() { @@ -475,12 +472,12 @@ export default { > - - diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 99b1e44f23b..7315bce1ed9 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -83,6 +83,7 @@ li.md-header-toolbar { margin-left: auto; display: none; + padding-bottom: $gl-padding-8; &.active { display: block; @@ -92,8 +93,8 @@ display: flex; justify-content: center; width: 100%; - padding-top: $gl-padding-top; - padding-bottom: $gl-padding-top; + flex-wrap: wrap; + margin-top: $gl-padding-8; } } } diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index f904ef11f5b..06eebb95438 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -14,7 +14,6 @@ a, button { padding: $gl-padding-8; - padding-bottom: $gl-padding-8 + 1; font-size: 14px; line-height: 28px; color: $gl-text-color-secondary; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index c025d8569a7..e2b5af73715 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -54,7 +54,7 @@ .common-note-form { .md-area { - padding: $gl-padding-top $gl-padding; + padding: $gl-padding-8 $gl-padding; border: 1px solid $border-color; border-radius: $border-radius-base; transition: border-color ease-in-out 0.15s, diff --git a/app/finders/packages/pypi/packages_finder.rb b/app/finders/packages/pypi/packages_finder.rb index 642ca2cf2e6..47cfb59944b 100644 --- a/app/finders/packages/pypi/packages_finder.rb +++ b/app/finders/packages/pypi/packages_finder.rb @@ -3,11 +3,8 @@ module Packages module Pypi class PackagesFinder < ::Packages::GroupOrProjectPackageFinder - def execute! - results = packages.with_normalized_pypi_name(@params[:package_name]) - raise ActiveRecord::RecordNotFound if results.empty? - - results + def execute + packages.with_normalized_pypi_name(@params[:package_name]) end private diff --git a/db/migrate/20210806152104_add_pypi_package_requests_forwarding_to_application_settings.rb b/db/migrate/20210806152104_add_pypi_package_requests_forwarding_to_application_settings.rb new file mode 100644 index 00000000000..34f8ec43a8f --- /dev/null +++ b/db/migrate/20210806152104_add_pypi_package_requests_forwarding_to_application_settings.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddPypiPackageRequestsForwardingToApplicationSettings < ActiveRecord::Migration[6.1] + include Gitlab::Database::MigrationHelpers + + def up + with_lock_retries do + add_column(:application_settings, :pypi_package_requests_forwarding, :boolean, default: true, null: false) + end + end + + def down + with_lock_retries do + remove_column(:application_settings, :pypi_package_requests_forwarding) + end + end +end diff --git a/db/post_migrate/20210706212710_finalize_ci_job_artifacts_bigint_conversion.rb b/db/post_migrate/20210706212710_finalize_ci_job_artifacts_bigint_conversion.rb new file mode 100644 index 00000000000..40977277bd1 --- /dev/null +++ b/db/post_migrate/20210706212710_finalize_ci_job_artifacts_bigint_conversion.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# See https://docs.gitlab.com/ee/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class FinalizeCiJobArtifactsBigintConversion < ActiveRecord::Migration[6.1] + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + TABLE_NAME = 'ci_job_artifacts' + + def up + ensure_batched_background_migration_is_finished( + job_class_name: 'CopyColumnUsingBackgroundMigrationJob', + table_name: TABLE_NAME, + column_name: 'id', + job_arguments: [%w[id job_id], %w[id_convert_to_bigint job_id_convert_to_bigint]] + ) + + swap + end + + def down + swap + end + + private + + def swap + add_concurrent_index TABLE_NAME, :id_convert_to_bigint, unique: true, name: 'index_ci_job_artifact_on_id_convert_to_bigint' + # This is to replace the existing "index_ci_job_artifacts_for_terraform_reports" btree (project_id, id) where (file_type = 18) + add_concurrent_index TABLE_NAME, [:project_id, :id_convert_to_bigint], name: 'index_ci_job_artifacts_for_terraform_reports_bigint', where: "file_type = 18" + # This is to replace the existing "index_ci_job_artifacts_id_for_terraform_reports" btree (id) where (file_type = 18) + add_concurrent_index TABLE_NAME, [:id_convert_to_bigint], name: 'index_ci_job_artifacts_id_for_terraform_reports_bigint', where: "file_type = 18" + + # Add a FK on `project_pages_metadata(artifacts_archive_id)` to `id_convert_to_bigint`, the old FK (fk_69366a119e) + # will be removed when ci_job_artifacts_pkey constraint is droppped. + fk_artifacts_archive_id = concurrent_foreign_key_name(:project_pages_metadata, :artifacts_archive_id) + fk_artifacts_archive_id_tmp = "#{fk_artifacts_archive_id}_tmp" + add_concurrent_foreign_key :project_pages_metadata, TABLE_NAME, + column: :artifacts_archive_id, target_column: :id_convert_to_bigint, + name: fk_artifacts_archive_id_tmp, + on_delete: :nullify, + reverse_lock_order: true + + with_lock_retries(raise_on_exhaustion: true) do + # We'll need ACCESS EXCLUSIVE lock on the related tables, + # lets make sure it can be acquired from the start + execute "LOCK TABLE #{TABLE_NAME}, project_pages_metadata IN ACCESS EXCLUSIVE MODE" + + # Swap column names + temp_name = 'id_tmp' + execute "ALTER TABLE #{quote_table_name(TABLE_NAME)} RENAME COLUMN #{quote_column_name(:id)} TO #{quote_column_name(temp_name)}" + execute "ALTER TABLE #{quote_table_name(TABLE_NAME)} RENAME COLUMN #{quote_column_name(:id_convert_to_bigint)} TO #{quote_column_name(:id)}" + execute "ALTER TABLE #{quote_table_name(TABLE_NAME)} RENAME COLUMN #{quote_column_name(temp_name)} TO #{quote_column_name(:id_convert_to_bigint)}" + + # We need to update the trigger function in order to make PostgreSQL to + # regenerate the execution plan for it. This is to avoid type mismatch errors like + # "type of parameter 15 (bigint) does not match that when preparing the plan (integer)" + function_name = Gitlab::Database::UnidirectionalCopyTrigger.on_table(TABLE_NAME).name([:id, :job_id], [:id_convert_to_bigint, :job_id_convert_to_bigint]) + execute "ALTER FUNCTION #{quote_table_name(function_name)} RESET ALL" + + # Swap defaults + execute "ALTER SEQUENCE ci_job_artifacts_id_seq OWNED BY #{TABLE_NAME}.id" + change_column_default TABLE_NAME, :id, -> { "nextval('ci_job_artifacts_id_seq'::regclass)" } + change_column_default TABLE_NAME, :id_convert_to_bigint, 0 + + # Swap PK constraint + execute "ALTER TABLE #{TABLE_NAME} DROP CONSTRAINT ci_job_artifacts_pkey CASCADE" # this will drop ci_job_artifacts_pkey primary key + rename_index TABLE_NAME, 'index_ci_job_artifact_on_id_convert_to_bigint', 'ci_job_artifacts_pkey' + execute "ALTER TABLE #{TABLE_NAME} ADD CONSTRAINT ci_job_artifacts_pkey PRIMARY KEY USING INDEX ci_job_artifacts_pkey" + + # Rename the rest of the indexes (we already hold an exclusive lock, so no need to use DROP INDEX CONCURRENTLY here + execute 'DROP INDEX index_ci_job_artifacts_for_terraform_reports' + rename_index TABLE_NAME, 'index_ci_job_artifacts_for_terraform_reports_bigint', 'index_ci_job_artifacts_for_terraform_reports' + execute 'DROP INDEX index_ci_job_artifacts_id_for_terraform_reports' + rename_index TABLE_NAME, 'index_ci_job_artifacts_id_for_terraform_reports_bigint', 'index_ci_job_artifacts_id_for_terraform_reports' + + # Change the name of the temporary FK for project_pages_metadata(artifacts_archive_id) -> id + rename_constraint(:project_pages_metadata, fk_artifacts_archive_id_tmp, fk_artifacts_archive_id) + end + end +end diff --git a/db/schema_migrations/20210706212710 b/db/schema_migrations/20210706212710 new file mode 100644 index 00000000000..7a4e6df37a4 --- /dev/null +++ b/db/schema_migrations/20210706212710 @@ -0,0 +1 @@ +33162af4ef99c32d3c5b38479e407d4911a8d3dce53407dbee6e5745c8e945ae \ No newline at end of file diff --git a/db/schema_migrations/20210806152104 b/db/schema_migrations/20210806152104 new file mode 100644 index 00000000000..a8bdc0615d5 --- /dev/null +++ b/db/schema_migrations/20210806152104 @@ -0,0 +1 @@ +1bdbcc6ef5ccf7a2bfb1f9571885e218e230a81b632a2d993302bd87432963f3 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 8955a08a6ba..f107a569313 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9624,6 +9624,7 @@ CREATE TABLE application_settings ( usage_ping_features_enabled boolean DEFAULT false NOT NULL, encrypted_customers_dot_jwt_signing_key bytea, encrypted_customers_dot_jwt_signing_key_iv bytea, + pypi_package_requests_forwarding boolean DEFAULT true NOT NULL, CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)), CONSTRAINT app_settings_ext_pipeline_validation_service_url_text_limit CHECK ((char_length(external_pipeline_validation_service_url) <= 255)), CONSTRAINT app_settings_registry_exp_policies_worker_capacity_positive CHECK ((container_registry_expiration_policies_worker_capacity >= 0)), @@ -10785,7 +10786,7 @@ CREATE SEQUENCE ci_instance_variables_id_seq ALTER SEQUENCE ci_instance_variables_id_seq OWNED BY ci_instance_variables.id; CREATE TABLE ci_job_artifacts ( - id integer NOT NULL, + id_convert_to_bigint integer DEFAULT 0 NOT NULL, project_id integer NOT NULL, job_id_convert_to_bigint integer DEFAULT 0 NOT NULL, file_type integer NOT NULL, @@ -10798,7 +10799,7 @@ CREATE TABLE ci_job_artifacts ( file_sha256 bytea, file_format smallint, file_location smallint, - id_convert_to_bigint bigint DEFAULT 0 NOT NULL, + id bigint NOT NULL, job_id bigint NOT NULL, CONSTRAINT check_27f0f6dbab CHECK ((file_store IS NOT NULL)) ); diff --git a/doc/api/settings.md b/doc/api/settings.md index 325dce45047..664b4d7ca1f 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -79,6 +79,7 @@ Example response: "asset_proxy_whitelist": ["example.com", "*.example.com", "your-instance.com"], "asset_proxy_allowlist": ["example.com", "*.example.com", "your-instance.com"], "npm_package_requests_forwarding": true, + "pypi_package_requests_forwarding": true, "snippet_size_limit": 52428800, "issues_create_limit": 300, "raw_blob_request_limit": 300, @@ -180,6 +181,7 @@ Example response: "allow_local_requests_from_web_hooks_and_services": true, "allow_local_requests_from_system_hooks": false, "npm_package_requests_forwarding": true, + "pypi_package_requests_forwarding": true, "snippet_size_limit": 52428800, "issues_create_limit": 300, "raw_blob_request_limit": 300, @@ -347,6 +349,7 @@ listed in the descriptions of the relevant settings. | `mirror_max_capacity` | integer | no | **(PREMIUM)** Maximum number of mirrors that can be synchronizing at the same time. | | `mirror_max_delay` | integer | no | **(PREMIUM)** Maximum time (in minutes) between updates that a mirror can have when scheduled to synchronize. | | `npm_package_requests_forwarding` | boolean | no | **(PREMIUM)** Use npmjs.org as a default remote repository when the package is not found in the GitLab Package Registry for npm. | +| `pypi_package_requests_forwarding` | boolean | no | **(PREMIUM)** Use pypi.org as a default remote repository when the package is not found in the GitLab Package Registry for PyPI. | | `outbound_local_requests_whitelist` | array of strings | no | Define a list of trusted domains or IP addresses to which local requests are allowed when local requests for hooks and services are disabled. | `pages_domain_verification_enabled` | boolean | no | Require users to prove ownership of custom domains. Domain verification is an essential security measure for public GitLab sites. Users are required to demonstrate they control a domain before it is enabled. | | `password_authentication_enabled_for_git` | boolean | no | Enable authentication for Git over HTTP(S) via a GitLab account password. Default is `true`. | diff --git a/doc/user/admin_area/settings/continuous_integration.md b/doc/user/admin_area/settings/continuous_integration.md index 99e003957aa..3b56318e711 100644 --- a/doc/user/admin_area/settings/continuous_integration.md +++ b/doc/user/admin_area/settings/continuous_integration.md @@ -262,10 +262,20 @@ To disable it: 1. On the top bar, select **Menu >** **{admin}** **Admin**. 1. On the left sidebar, select **Settings > CI/CD**. 1. Expand the **Package Registry** section. -1. Uncheck **Enable forwarding of npm package requests to npmjs.org**. -1. Click **Save changes**. +1. Clear the checkbox **Forward npm package requests to the npm Registry if the packages are not found in the GitLab Package Registry**. +1. Select **Save changes**. -![npm package requests forwarding](img/admin_package_registry_npm_package_requests_forward.png) +### PyPI Forwarding **(PREMIUM SELF)** + +GitLab administrators can disable the forwarding of PyPI requests to [pypi.org](https://pypi.org/). + +To disable it: + +1. On the top bar, select **Menu >** **{admin}** **Admin**. +1. On the left sidebar, select **Settings > CI/CD**. +1. Expand the **Package Registry** section. +1. Clear the checkbox **Forward PyPI package requests to the PyPI Registry if the packages are not found in the GitLab Package Registry**. +1. Select **Save changes**. ### Package file size limits diff --git a/doc/user/admin_area/settings/img/admin_package_registry_npm_package_requests_forward.png b/doc/user/admin_area/settings/img/admin_package_registry_npm_package_requests_forward.png deleted file mode 100644 index b6068f5d19bb1d415cad49a064bb61c7f9a1a784..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28630 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 M4^WY=mNX0g9|1B9ivR!s diff --git a/doc/user/packages/pypi_repository/index.md b/doc/user/packages/pypi_repository/index.md index 61993d37bac..2d54cfc5f7d 100644 --- a/doc/user/packages/pypi_repository/index.md +++ b/doc/user/packages/pypi_repository/index.md @@ -328,6 +328,11 @@ more than once, a `400 Bad Request` error occurs. ## Install a PyPI package +In [GitLab 14.2 and later](https://gitlab.com/gitlab-org/gitlab/-/issues/233413), +when a PyPI package is not found in the Package Registry, the request is forwarded to [pypi.org](https://pypi.org/). + +Administrators can disable this behavior in the [Continuous Integration settings](../../admin_area/settings/continuous_integration.md). + ### Install from the project level To install the latest version of a package, use the following command: diff --git a/lib/api/helpers/packages/dependency_proxy_helpers.rb b/lib/api/helpers/packages/dependency_proxy_helpers.rb index 989c4e1761b..b8ae1dddd7e 100644 --- a/lib/api/helpers/packages/dependency_proxy_helpers.rb +++ b/lib/api/helpers/packages/dependency_proxy_helpers.rb @@ -5,11 +5,17 @@ module API module Packages module DependencyProxyHelpers REGISTRY_BASE_URLS = { - npm: 'https://registry.npmjs.org/' + npm: 'https://registry.npmjs.org/', + pypi: 'https://pypi.org/simple/' + }.freeze + + APPLICATION_SETTING_NAMES = { + npm: 'npm_package_requests_forwarding', + pypi: 'pypi_package_requests_forwarding' }.freeze def redirect_registry_request(forward_to_registry, package_type, options) - if forward_to_registry && redirect_registry_request_available? + if forward_to_registry && redirect_registry_request_available?(package_type) ::Gitlab::Tracking.event(self.options[:for].name, "#{package_type}_request_forward") redirect(registry_url(package_type, options)) else @@ -25,11 +31,20 @@ module API case package_type when :npm "#{base_url}#{options[:package_name]}" + when :pypi + "#{base_url}#{options[:package_name]}/" end end - def redirect_registry_request_available? - ::Gitlab::CurrentSettings.current_application_settings.npm_package_requests_forwarding + def redirect_registry_request_available?(package_type) + application_setting_name = APPLICATION_SETTING_NAMES[package_type] + + raise ArgumentError, "Can't find application setting for package_type #{package_type}" unless application_setting_name + + ::Gitlab::CurrentSettings + .current_application_settings + .attributes + .fetch(application_setting_name, false) end end end diff --git a/lib/api/pypi_packages.rb b/lib/api/pypi_packages.rb index 8dd1631ebf8..706c0702fce 100644 --- a/lib/api/pypi_packages.rb +++ b/lib/api/pypi_packages.rb @@ -10,6 +10,7 @@ module API helpers ::API::Helpers::PackagesManagerClientsHelpers helpers ::API::Helpers::RelatedResourcesHelpers helpers ::API::Helpers::Packages::BasicAuthHelpers + helpers ::API::Helpers::Packages::DependencyProxyHelpers include ::API::Helpers::Packages::BasicAuthHelpers::Constants feature_category :package_registry @@ -82,15 +83,20 @@ module API track_package_event('list_package', :pypi) - packages = Packages::Pypi::PackagesFinder.new(current_user, group, { package_name: params[:package_name] }).execute! - presenter = ::Packages::Pypi::PackagePresenter.new(packages, group) + packages = Packages::Pypi::PackagesFinder.new(current_user, group, { package_name: params[:package_name] }).execute + empty_packages = packages.empty? - # Adjusts grape output format - # to be HTML - content_type "text/html; charset=utf-8" - env['api.format'] = :binary + redirect_registry_request(empty_packages, :pypi, package_name: params[:package_name]) do + not_found!('Package') if empty_packages + presenter = ::Packages::Pypi::PackagePresenter.new(packages, group) - body presenter.body + # Adjusts grape output format + # to be HTML + content_type "text/html; charset=utf-8" + env['api.format'] = :binary + + body presenter.body + end end end end @@ -142,15 +148,20 @@ module API track_package_event('list_package', :pypi, project: authorized_user_project, namespace: authorized_user_project.namespace) - packages = Packages::Pypi::PackagesFinder.new(current_user, authorized_user_project, { package_name: params[:package_name] }).execute! - presenter = ::Packages::Pypi::PackagePresenter.new(packages, authorized_user_project) + packages = Packages::Pypi::PackagesFinder.new(current_user, authorized_user_project, { package_name: params[:package_name] }).execute + empty_packages = packages.empty? - # Adjusts grape output format - # to be HTML - content_type "text/html; charset=utf-8" - env['api.format'] = :binary + redirect_registry_request(empty_packages, :pypi, package_name: params[:package_name]) do + not_found!('Package') if empty_packages + presenter = ::Packages::Pypi::PackagePresenter.new(packages, authorized_user_project) - body presenter.body + # Adjusts grape output format + # to be HTML + content_type "text/html; charset=utf-8" + env['api.format'] = :binary + + body presenter.body + end end desc 'The PyPi Package upload endpoint' do diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 59a55462340..cea207d4b8c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4166,7 +4166,7 @@ msgid_plural "ApprovalRuleSummary|%{count} approvals required from %{membersCoun msgstr[0] "" msgstr[1] "" -msgid "ApprovalRule|%{scanner} +%{additionalScanners} more" +msgid "ApprovalRule|%{firstLabel} +%{numberOfAdditionalLabels} more" msgstr "" msgid "ApprovalRule|Add approvers" @@ -4175,9 +4175,15 @@ msgstr "" msgid "ApprovalRule|All scanners" msgstr "" +msgid "ApprovalRule|All severity levels" +msgstr "" + msgid "ApprovalRule|Apply this approval rule to consider only the selected security scanners." msgstr "" +msgid "ApprovalRule|Apply this approval rule to consider only the selected severity levels." +msgstr "" + msgid "ApprovalRule|Approval rules" msgstr "" @@ -4205,6 +4211,9 @@ msgstr "" msgid "ApprovalRule|Please select at least one security scanner" msgstr "" +msgid "ApprovalRule|Please select at least one severity level" +msgstr "" + msgid "ApprovalRule|Rule name" msgstr "" @@ -4217,6 +4226,12 @@ msgstr "" msgid "ApprovalRule|Select scanners" msgstr "" +msgid "ApprovalRule|Select severity levels" +msgstr "" + +msgid "ApprovalRule|Severity levels" +msgstr "" + msgid "ApprovalRule|Target branch" msgstr "" @@ -4922,6 +4937,9 @@ msgstr "" msgid "Automatic deployment rollbacks" msgstr "" +msgid "Automatic event tracking provides a traceable history for audits." +msgstr "" + msgid "Automatically close associated incident when a recovery alert notification resolves an alert" msgstr "" @@ -14578,6 +14596,9 @@ msgstr "" msgid "Format: %{dateFormat}" msgstr "" +msgid "Forward %{package_type} package requests to the %{registry_type} Registry if the packages are not found in the GitLab Package Registry" +msgstr "" + msgid "Found errors in your %{gitlab_ci_yml}:" msgstr "" @@ -26774,9 +26795,6 @@ msgstr "" msgid "Promotions|Add Group Webhooks and GitLab Enterprise Edition." msgstr "" -msgid "Promotions|Audit Events is a way to keep track of important events that happened in GitLab." -msgstr "" - msgid "Promotions|Better Protected Branches" msgstr "" @@ -26828,6 +26846,9 @@ msgstr "" msgid "Promotions|Improve search with Advanced Search and GitLab Enterprise Edition." msgstr "" +msgid "Promotions|Keep track of events in your project" +msgstr "" + msgid "Promotions|Learn more" msgstr "" @@ -26867,9 +26888,6 @@ msgstr "" msgid "Promotions|Track activity with Contribution Analytics." msgstr "" -msgid "Promotions|Track your project with Audit Events." -msgstr "" - msgid "Promotions|Try it for free" msgstr "" diff --git a/spec/finders/packages/pypi/packages_finder_spec.rb b/spec/finders/packages/pypi/packages_finder_spec.rb index a69c2317261..1a44fb99009 100644 --- a/spec/finders/packages/pypi/packages_finder_spec.rb +++ b/spec/finders/packages/pypi/packages_finder_spec.rb @@ -14,14 +14,14 @@ RSpec.describe Packages::Pypi::PackagesFinder do let(:package_name) { package2.name } - describe 'execute!' do - subject { described_class.new(user, scope, package_name: package_name).execute! } + describe 'execute' do + subject { described_class.new(user, scope, package_name: package_name).execute } shared_examples 'when no package is found' do context 'non-existing package' do let(:package_name) { 'none' } - it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) } + it { expect(subject).to be_empty } end end @@ -29,7 +29,7 @@ RSpec.describe Packages::Pypi::PackagesFinder do context 'non-existing package' do let(:package_name) { package2.name.upcase.tr('-', '.') } - it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) } + it { expect(subject).to be_empty } end end @@ -45,7 +45,7 @@ RSpec.describe Packages::Pypi::PackagesFinder do context 'within a group' do let(:scope) { group } - it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) } + it { expect(subject).to be_empty } context 'user with access to only one project' do before do diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 5b6794cbc66..d516baf6f0f 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -1,93 +1,175 @@ -import { GlAlert } from '@gitlab/ui'; +import { GlLoadingIcon } from '@gitlab/ui'; import { EditorContent } from '@tiptap/vue-2'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContentEditor from '~/content_editor/components/content_editor.vue'; +import ContentEditorError from '~/content_editor/components/content_editor_error.vue'; +import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue'; +import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue'; -import { createContentEditor } from '~/content_editor/services/create_content_editor'; +import { + LOADING_CONTENT_EVENT, + LOADING_SUCCESS_EVENT, + LOADING_ERROR_EVENT, +} from '~/content_editor/constants'; +import { emitEditorEvent } from '../test_utils'; jest.mock('~/emoji'); describe('ContentEditor', () => { let wrapper; - let editor; + let contentEditor; + let renderMarkdown; + const uploadsPath = '/uploads'; const findEditorElement = () => wrapper.findByTestId('content-editor'); - const findErrorAlert = () => wrapper.findComponent(GlAlert); + const findEditorContent = () => wrapper.findComponent(EditorContent); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + const createWrapper = (propsData = {}) => { + renderMarkdown = jest.fn(); - const createWrapper = async (contentEditor) => { wrapper = shallowMountExtended(ContentEditor, { propsData: { - contentEditor, + renderMarkdown, + uploadsPath, + ...propsData, + }, + stubs: { + EditorStateObserver, + ContentEditorProvider, + }, + listeners: { + initialized(editor) { + contentEditor = editor; + }, }, }); }; - beforeEach(() => { - editor = createContentEditor({ renderMarkdown: () => true }); - }); - afterEach(() => { wrapper.destroy(); }); - it('renders editor content component and attaches editor instance', () => { - createWrapper(editor); + it('triggers initialized event and provides contentEditor instance as event data', () => { + createWrapper(); - const editorContent = wrapper.findComponent(EditorContent); + expect(contentEditor).not.toBeFalsy(); + }); - expect(editorContent.props().editor).toBe(editor.tiptapEditor); + it('renders EditorContent component and provides tiptapEditor instance', () => { + createWrapper(); + + const editorContent = findEditorContent(); + + expect(editorContent.props().editor).toBe(contentEditor.tiptapEditor); expect(editorContent.classes()).toContain('md'); }); + it('renders ContentEditorProvider component', () => { + createWrapper(); + + expect(wrapper.findComponent(ContentEditorProvider).exists()).toBe(true); + }); + it('renders top toolbar component', () => { - createWrapper(editor); + createWrapper(); expect(wrapper.findComponent(TopToolbar).exists()).toBe(true); }); - it.each` - isFocused | classes - ${true} | ${['md-area', 'is-focused']} - ${false} | ${['md-area']} - `( - 'has $classes class selectors when tiptapEditor.isFocused = $isFocused', - ({ isFocused, classes }) => { - editor.tiptapEditor.isFocused = isFocused; - createWrapper(editor); + it('adds is-focused class when focus event is emitted', async () => { + createWrapper(); - expect(findEditorElement().classes()).toStrictEqual(classes); - }, - ); - - it('adds isFocused class when tiptapEditor is focused', () => { - editor.tiptapEditor.isFocused = true; - createWrapper(editor); + await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' }); expect(findEditorElement().classes()).toContain('is-focused'); }); - describe('displaying error', () => { - const error = 'Content Editor error'; + it('removes is-focused class when blur event is emitted', async () => { + createWrapper(); + + await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'focus' }); + await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'blur' }); + + expect(findEditorElement().classes()).not.toContain('is-focused'); + }); + + it('emits change event when document is updated', async () => { + createWrapper(); + + await emitEditorEvent({ tiptapEditor: contentEditor.tiptapEditor, event: 'update' }); + + expect(wrapper.emitted('change')).toEqual([ + [ + { + empty: contentEditor.empty, + }, + ], + ]); + }); + + it('renders content_editor_error component', () => { + createWrapper(); + + expect(wrapper.findComponent(ContentEditorError).exists()).toBe(true); + }); + + describe('when loading content', () => { + beforeEach(async () => { + createWrapper(); + + contentEditor.emit(LOADING_CONTENT_EVENT); + + await nextTick(); + }); + + it('displays loading indicator', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('hides EditorContent component', () => { + expect(findEditorContent().exists()).toBe(false); + }); + }); + + describe('when loading content succeeds', () => { + beforeEach(async () => { + createWrapper(); + + contentEditor.emit(LOADING_CONTENT_EVENT); + await nextTick(); + contentEditor.emit(LOADING_SUCCESS_EVENT); + await nextTick(); + }); + + it('hides loading indicator', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('displays EditorContent component', () => { + expect(findEditorContent().exists()).toBe(true); + }); + }); + + describe('when loading content fails', () => { + const error = 'error'; beforeEach(async () => { - createWrapper(editor); - - editor.tiptapEditor.emit('error', error); + createWrapper(); + contentEditor.emit(LOADING_CONTENT_EVENT); + await nextTick(); + contentEditor.emit(LOADING_ERROR_EVENT, error); await nextTick(); }); - it('displays error notifications from the tiptap editor', () => { - expect(findErrorAlert().text()).toBe(error); + it('hides loading indicator', () => { + expect(findLoadingIcon().exists()).toBe(false); }); - it('allows dismissing an error alert', async () => { - findErrorAlert().vm.$emit('dismiss'); - - await nextTick(); - - expect(findErrorAlert().exists()).toBe(false); + it('displays EditorContent component', () => { + expect(findEditorContent().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index d87a1459b50..1334b1ddaad 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -10,7 +10,7 @@ import httpStatus from '~/lib/utils/http_status'; import { loadMarkdownApiResult } from '../markdown_processing_examples'; import { createTestEditor, createDocBuilder } from '../test_utils'; -describe('content_editor/extensions/image', () => { +describe('content_editor/extensions/attachment', () => { let tiptapEditor; let eq; let doc; @@ -144,8 +144,8 @@ describe('content_editor/extensions/image', () => { it('emits an error event that includes an error message', (done) => { tiptapEditor.commands.uploadAttachment({ file: imageFile }); - tiptapEditor.on('error', (message) => { - expect(message).toBe('An error occurred while uploading the image. Please try again.'); + tiptapEditor.on('error', ({ error }) => { + expect(error).toBe('An error occurred while uploading the image. Please try again.'); done(); }); }); @@ -224,8 +224,8 @@ describe('content_editor/extensions/image', () => { it('emits an error event that includes an error message', (done) => { tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); - tiptapEditor.on('error', (message) => { - expect(message).toBe('An error occurred while uploading the file. Please try again.'); + tiptapEditor.on('error', ({ error }) => { + expect(error).toBe('An error occurred while uploading the file. Please try again.'); done(); }); }); diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js index 8580d3249b9..e48687f1548 100644 --- a/spec/frontend/content_editor/services/content_editor_spec.js +++ b/spec/frontend/content_editor/services/content_editor_spec.js @@ -13,10 +13,22 @@ describe('content_editor/services/content_editor', () => { beforeEach(() => { const tiptapEditor = createTestEditor(); + jest.spyOn(tiptapEditor, 'destroy'); + serializer = { deserialize: jest.fn() }; contentEditor = new ContentEditor({ tiptapEditor, serializer }); }); + describe('.dispose', () => { + it('destroys the tiptapEditor', () => { + expect(contentEditor.tiptapEditor.destroy).not.toHaveBeenCalled(); + + contentEditor.dispose(); + + expect(contentEditor.tiptapEditor.destroy).toHaveBeenCalled(); + }); + }); + describe('when setSerializedContent succeeds', () => { beforeEach(() => { serializer.deserialize.mockResolvedValueOnce(''); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index 97cb70dbd62..082a8977710 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -352,11 +352,6 @@ describe('WikiForm', () => { await waitForPromises(); }); - it('editor is shown in a perpetual loading state', () => { - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - expect(wrapper.findComponent(ContentEditor).exists()).toBe(false); - }); - it('disables the submit button', () => { expect(findSubmitButton().props('disabled')).toBe(true); }); diff --git a/spec/lib/api/helpers/packages/dependency_proxy_helpers_spec.rb b/spec/lib/api/helpers/packages/dependency_proxy_helpers_spec.rb index 99b52236771..ae0c0f53acd 100644 --- a/spec/lib/api/helpers/packages/dependency_proxy_helpers_spec.rb +++ b/spec/lib/api/helpers/packages/dependency_proxy_helpers_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe API::Helpers::Packages::DependencyProxyHelpers do let_it_be(:helper) { Class.new.include(described_class).new } - describe 'redirect_registry_request' do + describe '#redirect_registry_request' do using RSpec::Parameterized::TableSyntax let(:options) { {} } @@ -13,7 +13,7 @@ RSpec.describe API::Helpers::Packages::DependencyProxyHelpers do subject { helper.redirect_registry_request(forward_to_registry, package_type, options) { helper.fallback } } before do - allow(helper).to receive(:options).and_return(for: API::NpmInstancePackages) + allow(helper).to receive(:options).and_return(for: described_class) end shared_examples 'executing fallback' do @@ -34,38 +34,66 @@ RSpec.describe API::Helpers::Packages::DependencyProxyHelpers do subject - expect_snowplow_event(category: 'API::NpmInstancePackages', action: 'npm_request_forward') + expect_snowplow_event(category: described_class.to_s, action: "#{package_type}_request_forward") end end - context 'with npm packages' do - let(:package_type) { :npm } + %i[npm pypi].each do |forwardable_package_type| + context "with #{forwardable_package_type} packages" do + include_context 'dependency proxy helpers context' - where(:application_setting, :forward_to_registry, :example_name) do - true | true | 'executing redirect' - true | false | 'executing fallback' - false | true | 'executing fallback' - false | false | 'executing fallback' - end + let(:package_type) { forwardable_package_type } - with_them do - before do - stub_application_setting(npm_package_requests_forwarding: application_setting) + where(:application_setting, :forward_to_registry, :example_name) do + true | true | 'executing redirect' + true | false | 'executing fallback' + false | true | 'executing fallback' + false | false | 'executing fallback' end - it_behaves_like params[:example_name] + with_them do + before do + allow_fetch_application_setting(attribute: "#{forwardable_package_type}_package_requests_forwarding", return_value: application_setting) + end + + it_behaves_like params[:example_name] + end end end - context 'with non-forwardable packages' do + context 'with non-forwardable package type' do let(:forward_to_registry) { true } before do stub_application_setting(npm_package_requests_forwarding: true) + stub_application_setting(pypi_package_requests_forwarding: true) end - Packages::Package.package_types.keys.without('npm').each do |pkg_type| + Packages::Package.package_types.keys.without('npm', 'pypi').each do |pkg_type| context "#{pkg_type}" do + let(:package_type) { pkg_type.to_sym } + + it 'raises an error' do + expect { subject }.to raise_error(ArgumentError, "Can't find application setting for package_type #{package_type}") + end + end + end + end + + describe '#registry_url' do + subject { helper.registry_url(package_type, package_name: 'test') } + + where(:package_type, :expected_result) do + :npm | 'https://registry.npmjs.org/test' + :pypi | 'https://pypi.org/simple/test/' + end + + with_them do + it { is_expected.to eq(expected_result) } + end + + Packages::Package.package_types.keys.without('npm', 'pypi').each do |pkg_type| + context "with non-forwardable package type #{pkg_type}" do let(:package_type) { pkg_type } it 'raises an error' do diff --git a/spec/requests/api/pypi_packages_spec.rb b/spec/requests/api/pypi_packages_spec.rb index a3200ef6a5b..8df2460a2b6 100644 --- a/spec/requests/api/pypi_packages_spec.rb +++ b/spec/requests/api/pypi_packages_spec.rb @@ -23,7 +23,8 @@ RSpec.describe API::PypiPackages do subject { get api(url), headers: headers } describe 'GET /api/v4/groups/:id/-/packages/pypi/simple/:package_name' do - let(:url) { "/groups/#{group.id}/-/packages/pypi/simple/#{package.name}" } + let(:package_name) { package.name } + let(:url) { "/groups/#{group.id}/-/packages/pypi/simple/#{package_name}" } let(:snowplow_gitlab_standard_context) { {} } it_behaves_like 'pypi simple API endpoint' @@ -40,7 +41,7 @@ RSpec.describe API::PypiPackages do it_behaves_like 'deploy token for package GET requests' context 'with group path as id' do - let(:url) { "/groups/#{CGI.escape(group.full_path)}/-/packages/pypi/simple/#{package.name}" } + let(:url) { "/groups/#{CGI.escape(group.full_path)}/-/packages/pypi/simple/#{package_name}"} it_behaves_like 'deploy token for package GET requests' end @@ -60,7 +61,8 @@ RSpec.describe API::PypiPackages do end describe 'GET /api/v4/projects/:id/packages/pypi/simple/:package_name' do - let(:url) { "/projects/#{project.id}/packages/pypi/simple/#{package.name}" } + let(:package_name) { package.name } + let(:url) { "/projects/#{project.id}/packages/pypi/simple/#{package_name}" } let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace } } it_behaves_like 'pypi simple API endpoint' diff --git a/spec/support/shared_contexts/lib/api/helpers/packages/dependency_proxy_helpers_shared_context.rb b/spec/support/shared_contexts/lib/api/helpers/packages/dependency_proxy_helpers_shared_context.rb new file mode 100644 index 00000000000..7c8b6250d24 --- /dev/null +++ b/spec/support/shared_contexts/lib/api/helpers/packages/dependency_proxy_helpers_shared_context.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.shared_context 'dependency proxy helpers context' do + def allow_fetch_application_setting(attribute:, return_value:) + attributes = double + allow(::Gitlab::CurrentSettings.current_application_settings).to receive(:attributes).and_return(attributes) + allow(attributes).to receive(:fetch).with(attribute, false).and_return(return_value) + end +end diff --git a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb index c15c59e1a1d..0390e60747f 100644 --- a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb @@ -46,6 +46,8 @@ RSpec.shared_examples 'handling get metadata requests' do |scope: :project| end shared_examples 'handling all conditions' do + include_context 'dependency proxy helpers context' + where(:auth, :package_name_type, :request_forward, :visibility, :user_role, :expected_result, :expected_status) do nil | :scoped_naming_convention | true | :public | nil | :accept | :ok nil | :scoped_naming_convention | false | :public | nil | :accept | :ok @@ -243,7 +245,7 @@ RSpec.shared_examples 'handling get metadata requests' do |scope: :project| project.send("add_#{user_role}", user) if user_role project.update!(visibility: visibility.to_s) package.update!(name: package_name) unless package_name == 'non-existing-package' - stub_application_setting(npm_package_requests_forwarding: request_forward) + allow_fetch_application_setting(attribute: "npm_package_requests_forwarding", return_value: request_forward) end example_name = "#{params[:expected_result]} metadata request" diff --git a/spec/support/shared_examples/requests/api/npm_packages_tags_shared_examples.rb b/spec/support/shared_examples/requests/api/npm_packages_tags_shared_examples.rb index e6b3dc74b74..86b6975bf9f 100644 --- a/spec/support/shared_examples/requests/api/npm_packages_tags_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/npm_packages_tags_shared_examples.rb @@ -10,9 +10,10 @@ end RSpec.shared_examples 'accept package tags request' do |status:| using RSpec::Parameterized::TableSyntax + include_context 'dependency proxy helpers context' before do - stub_application_setting(npm_package_requests_forwarding: false) + allow_fetch_application_setting(attribute: "npm_package_requests_forwarding", return_value: false) end context 'with valid package name' do diff --git a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb index 8a351226123..ed6d9ed43c8 100644 --- a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb @@ -228,6 +228,35 @@ RSpec.shared_examples 'pypi simple API endpoint' do it_behaves_like 'PyPI package versions', :developer, :success end + + context 'package request forward' do + include_context 'dependency proxy helpers context' + + where(:forward, :package_in_project, :shared_examples_name, :expected_status) do + true | true | 'PyPI package versions' | :success + true | false | 'process PyPI api request' | :redirect + false | true | 'PyPI package versions' | :success + false | false | 'process PyPI api request' | :not_found + end + + with_them do + let_it_be(:package) { create(:pypi_package, project: project, name: 'foobar') } + + let(:package_name) do + if package_in_project + 'foobar' + else + 'barfoo' + end + end + + before do + allow_fetch_application_setting(attribute: "pypi_package_requests_forwarding", return_value: forward) + end + + it_behaves_like params[:shared_examples_name], :reporter, params[:expected_status] + end + end end RSpec.shared_examples 'pypi file download endpoint' do