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 b6068f5d19b..00000000000 Binary files a/doc/user/admin_area/settings/img/admin_package_registry_npm_package_requests_forward.png and /dev/null differ 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