diff options
62 files changed, 1102 insertions, 186 deletions
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 204ac07d401..74f620b57b6 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -1,11 +1,33 @@ import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; -import { lowlight } from 'lowlight/lib/all'; +import { textblockTypeInputRule } from '@tiptap/core'; +import { isFunction } from 'lodash'; const extractLanguage = (element) => element.getAttribute('lang'); +const backtickInputRegex = /^```([a-z]+)?[\s\n]$/; +const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/; + +const loadLanguageFromInputRule = (languageLoader) => (match) => { + const language = match[1]; + + if (isFunction(languageLoader?.loadLanguages)) { + languageLoader.loadLanguages([language]); + } + + return { + language, + }; +}; export default CodeBlockLowlight.extend({ isolating: true, + addOptions() { + return { + ...this.parent?.(), + languageLoader: {}, + }; + }, + addAttributes() { return { language: { @@ -18,6 +40,22 @@ export default CodeBlockLowlight.extend({ }, }; }, + addInputRules() { + const { languageLoader } = this.options; + + return [ + textblockTypeInputRule({ + find: backtickInputRegex, + type: this.type, + getAttributes: loadLanguageFromInputRule(languageLoader), + }), + textblockTypeInputRule({ + find: tildeInputRegex, + type: this.type, + getAttributes: loadLanguageFromInputRule(languageLoader), + }), + ]; + }, renderHTML({ HTMLAttributes }) { return [ 'pre', @@ -28,6 +66,4 @@ export default CodeBlockLowlight.extend({ ['code', {}, 0], ]; }, -}).configure({ - lowlight, }); diff --git a/app/assets/javascripts/content_editor/services/code_block_language_loader.js b/app/assets/javascripts/content_editor/services/code_block_language_loader.js new file mode 100644 index 00000000000..3c12cf614a5 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/code_block_language_loader.js @@ -0,0 +1,35 @@ +export default class CodeBlockLanguageLoader { + constructor(lowlight) { + this.lowlight = lowlight; + } + + isLanguageLoaded(language) { + return this.lowlight.registered(language); + } + + loadLanguagesFromDOM(domTree) { + const languages = []; + + domTree.querySelectorAll('pre').forEach((preElement) => { + languages.push(preElement.getAttribute('lang')); + }); + + return this.loadLanguages(languages); + } + + loadLanguages(languageList = []) { + const loaders = languageList + .filter((languageName) => !this.isLanguageLoaded(languageName)) + .map((languageName) => { + return import( + /* webpackChunkName: 'highlight.language.js' */ `highlight.js/lib/languages/${languageName}` + ) + .then(({ default: language }) => { + this.lowlight.registerLanguage(languageName, language); + }) + .catch(() => false); + }); + + return Promise.all(loaders); + } +} diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index c5638da2daf..05c831109a5 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -3,11 +3,12 @@ import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } fro /* eslint-disable no-underscore-dangle */ export class ContentEditor { - constructor({ tiptapEditor, serializer, deserializer, eventHub }) { + constructor({ tiptapEditor, serializer, deserializer, eventHub, languageLoader }) { this._tiptapEditor = tiptapEditor; this._serializer = serializer; this._deserializer = deserializer; this._eventHub = eventHub; + this._languageLoader = languageLoader; } get tiptapEditor() { @@ -34,23 +35,35 @@ export class ContentEditor { } async setSerializedContent(serializedContent) { - const { _tiptapEditor: editor, _deserializer: deserializer, _eventHub: eventHub } = this; + const { + _tiptapEditor: editor, + _deserializer: deserializer, + _eventHub: eventHub, + _languageLoader: languageLoader, + } = this; const { doc, tr } = editor.state; const selection = TextSelection.create(doc, 0, doc.content.size); try { eventHub.$emit(LOADING_CONTENT_EVENT); - const { document } = await deserializer.deserialize({ + const result = await deserializer.deserialize({ schema: editor.schema, content: serializedContent, }); - if (document) { - tr.setSelection(selection) - .replaceSelectionWith(document, false) - .setMeta('preventUpdate', true); - editor.view.dispatch(tr); + if (Object.keys(result).length === 0) { + return; } + + const { document, dom } = result; + + await languageLoader.loadLanguagesFromDOM(dom); + + tr.setSelection(selection) + .replaceSelectionWith(document, false) + .setMeta('preventUpdate', true); + editor.view.dispatch(tr); + eventHub.$emit(LOADING_SUCCESS_EVENT); } catch (e) { eventHub.$emit(LOADING_ERROR_EVENT, e); diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index d9d39a387d0..5b637eee176 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -1,5 +1,6 @@ import { Editor } from '@tiptap/vue-2'; import { isFunction } from 'lodash'; +import { lowlight } from 'lowlight/lib/core'; import eventHubFactory from '~/helpers/event_hub_factory'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; import Attachment from '../extensions/attachment'; @@ -58,6 +59,7 @@ import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; import createMarkdownDeserializer from './markdown_deserializer'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; +import CodeBlockLanguageLoader from './code_block_language_loader'; const createTiptapEditor = ({ extensions = [], ...options } = {}) => new Editor({ @@ -83,6 +85,7 @@ export const createContentEditor = ({ const eventHub = eventHubFactory(); + const languageLoader = new CodeBlockLanguageLoader(lowlight); const builtInContentEditorExtensions = [ Attachment.configure({ uploadsPath, renderMarkdown, eventHub }), Audio, @@ -91,7 +94,7 @@ export const createContentEditor = ({ BulletList, Code, ColorChip, - CodeBlockHighlight, + CodeBlockHighlight.configure({ lowlight, languageLoader }), DescriptionItem, DescriptionList, Details, @@ -105,7 +108,7 @@ export const createContentEditor = ({ FootnoteDefinition, FootnoteReference, FootnotesSection, - Frontmatter, + Frontmatter.configure({ lowlight }), Gapcursor, HardBreak, Heading, @@ -144,5 +147,5 @@ export const createContentEditor = ({ const serializer = createMarkdownSerializer({ serializerConfig }); const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); - return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer }); + return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer, languageLoader }); }; diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 6a4009b222d..39a2939f52a 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -292,3 +292,10 @@ export const TEMP_PROVIDER_LOGOS = { svg: scwLogo, }, }; + +// Use the `url` field from the GraphQL query once this issue is resolved +// https://gitlab.com/gitlab-org/gitlab/-/issues/356129 +export const TEMP_PROVIDER_URLS = { + Kontra: 'https://application.security/', + [__('Secure Code Warrior')]: 'https://www.securecodewarrior.com/', +}; diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue index 6f130bec15e..bb540303cfd 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -25,7 +25,7 @@ import { updateSecurityTrainingCache, updateSecurityTrainingOptimisticResponse, } from '~/security_configuration/graphql/cache_utils'; -import { TEMP_PROVIDER_LOGOS } from './constants'; +import { TEMP_PROVIDER_LOGOS, TEMP_PROVIDER_URLS } from './constants'; const i18n = { providerQueryErrorMessage: __( @@ -206,6 +206,7 @@ export default { }, i18n, TEMP_PROVIDER_LOGOS, + TEMP_PROVIDER_URLS, }; </script> @@ -247,7 +248,8 @@ export default { <p> {{ provider.description }} <gl-link - :href="provider.url" + v-if="$options.TEMP_PROVIDER_URLS[provider.name]" + :href="$options.TEMP_PROVIDER_URLS[provider.name]" target="_blank" @click="trackProviderLearnMoreClick(provider.id)" > diff --git a/app/controllers/groups/releases_controller.rb b/app/controllers/groups/releases_controller.rb index 6a42f30b847..db5385ecc71 100644 --- a/app/controllers/groups/releases_controller.rb +++ b/app/controllers/groups/releases_controller.rb @@ -15,11 +15,17 @@ module Groups private def releases - ReleasesFinder - .new(@group, current_user, { include_subgroups: true }) - .execute(preload: false) - .page(params[:page]) - .per(30) + if Feature.enabled?(:group_releases_finder_inoperator) + Releases::GroupReleasesFinder + .new(@group, current_user, { include_subgroups: true, page: params[:page], per: 30 }) + .execute(preload: false) + else + ReleasesFinder + .new(@group, current_user, { include_subgroups: true }) + .execute(preload: false) + .page(params[:page]) + .per(30) + end end end end diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb index b8a2cec9ac6..f293ec752ab 100644 --- a/app/controllers/projects/google_cloud/base_controller.rb +++ b/app/controllers/projects/google_cloud/base_controller.rb @@ -25,7 +25,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController end def feature_flag_enabled! - unless Feature.enabled?(:incubation_5mp_google_cloud, project) + unless Feature.enabled?(:incubation_5mp_google_cloud) track_event('feature_flag_enabled!', 'access_denied', 'feature_flag_not_enabled') access_denied! end diff --git a/app/finders/releases/group_releases_finder.rb b/app/finders/releases/group_releases_finder.rb new file mode 100644 index 00000000000..d87ba8c0b03 --- /dev/null +++ b/app/finders/releases/group_releases_finder.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true +module Releases + ## + # The GroupReleasesFinder does not support all the options of ReleasesFinder + # due to use of InOperatorOptimization for finding subprojects/subgroups + # + # order_by - only ordering by released_at is supported + # filter by tag - currently not supported + class GroupReleasesFinder + include Gitlab::Utils::StrongMemoize + + attr_reader :parent, :current_user, :params + + def initialize(parent, current_user = nil, params = {}) + @parent = parent + @current_user = current_user + @params = params + + params[:order_by] ||= 'released_at' + params[:sort] ||= 'desc' + params[:page] ||= 0 + params[:per] ||= 30 + end + + def execute(preload: true) + return Release.none unless Ability.allowed?(current_user, :read_release, parent) + + releases = get_releases(preload: preload) + + paginate_releases(releases) + end + + private + + def include_subgroups? + params.fetch(:include_subgroups, false) + end + + def accessible_projects_scope + if include_subgroups? + Project.for_group_and_its_subgroups(parent) + else + parent.projects + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def get_releases(preload: true) + Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder.new( + scope: releases_scope(preload: preload), + array_scope: accessible_projects_scope.select(:id), + array_mapping_scope: -> (project_id_expression) { Release.where(Release.arel_table[:project_id].eq(project_id_expression)) }, + finder_query: -> (order_by, id_expression) { Release.where(Release.arel_table[:id].eq(id_expression)) } + ) + .execute + end + + def releases_scope(preload: true) + scope = Release.all + scope = order_releases(scope) + scope = scope.preloaded if preload + scope + end + + def order_releases(scope) + scope.sort_by_attribute("released_at_#{params[:sort]}").order(id: params[:sort]) + end + + def paginate_releases(releases) + releases.page(params[:page].to_i).per(params[:per]) + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/models/release.rb b/app/models/release.rb index cee52e132a4..c6c0920c4d0 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -6,6 +6,7 @@ class Release < ApplicationRecord include Importable include Gitlab::Utils::StrongMemoize include EachBatch + include FromUnion cache_markdown_field :description diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb index 556ee03605d..998a5deb0fd 100644 --- a/app/models/users/credit_card_validation.rb +++ b/app/models/users/credit_card_validation.rb @@ -8,7 +8,7 @@ module Users belongs_to :user - validates :holder_name, length: { maximum: 26 } + validates :holder_name, length: { maximum: 50 } validates :network, length: { maximum: 32 } validates :last_digits, allow_nil: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 9999 diff --git a/app/views/admin/application_settings/_search_limits.html.haml b/app/views/admin/application_settings/_search_limits.html.haml index 24403fe8fd3..945c9397f0d 100644 --- a/app/views/admin/application_settings/_search_limits.html.haml +++ b/app/views/admin/application_settings/_search_limits.html.haml @@ -3,11 +3,11 @@ %fieldset .form-group - = f.label :search_rate_limit, _('Maximum authenticated requests by a user per minute'), class: 'label-bold' + = f.label :search_rate_limit, _('Maximum number of requests per minute for an authenticated user'), class: 'label-bold' .form-text.gl-text-gray-600 = _("Set this number to 0 to disable the limit.") + = f.number_field :search_rate_limit, class: 'form-control gl-form-input' - = f.label :search_rate_limit, _('Maximum number of requests per minute for an authenticated user'), class: 'label-bold' .form-group = f.label :search_rate_limit_unauthenticated, _('Maximum number of requests per minute for an unauthenticated IP address'), class: 'label-bold' = f.number_field :search_rate_limit_unauthenticated, class: 'form-control gl-form-input' diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index b0e3f8182f6..ea35b7ab9c4 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -48,7 +48,7 @@ .settings-content = render partial: 'network_rate_limits', locals: { anchor: 'js-files-limits-settings', setting_fragment: 'files_api' } -%section.settings.as-note-limits.no-animate#js-search-limits-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.as-search-limits.no-animate#js-search-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 = _('Search rate limits') diff --git a/config/feature_flags/development/group_releases_finder_inoperator.yml b/config/feature_flags/development/group_releases_finder_inoperator.yml new file mode 100644 index 00000000000..c76c328b5bf --- /dev/null +++ b/config/feature_flags/development/group_releases_finder_inoperator.yml @@ -0,0 +1,8 @@ +--- +name: group_releases_finder_inoperator +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80093 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/355463 +milestone: '14.9' +type: development +group: group::release +default_enabled: false diff --git a/db/migrate/20220310101118_update_holder_name_limit.rb b/db/migrate/20220310101118_update_holder_name_limit.rb new file mode 100644 index 00000000000..55eb8f75d70 --- /dev/null +++ b/db/migrate/20220310101118_update_holder_name_limit.rb @@ -0,0 +1,28 @@ +# 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 UpdateHolderNameLimit < Gitlab::Database::Migration[1.0] + disable_ddl_transaction! + + def up + add_text_limit :user_credit_card_validations, :holder_name, 50, constraint_name: new_constraint_name + remove_text_limit :user_credit_card_validations, :holder_name, constraint_name: old_constraint_name + end + + def down + add_text_limit :user_credit_card_validations, :holder_name, 26, validate: false, constraint_name: old_constraint_name + remove_text_limit :user_credit_card_validations, :holder_name, constraint_name: new_constraint_name + end + + private + + def old_constraint_name + check_constraint_name(:user_credit_card_validations, :holder_name, 'max_length') + end + + def new_constraint_name + check_constraint_name(:user_credit_card_validations, :holder_name, 'max_length_50') + end +end diff --git a/db/post_migrate/20220223112304_schedule_nullify_orphan_runner_id_on_ci_builds.rb b/db/post_migrate/20220223112304_schedule_nullify_orphan_runner_id_on_ci_builds.rb index c62f459d270..e4005885c3b 100644 --- a/db/post_migrate/20220223112304_schedule_nullify_orphan_runner_id_on_ci_builds.rb +++ b/db/post_migrate/20220223112304_schedule_nullify_orphan_runner_id_on_ci_builds.rb @@ -3,9 +3,9 @@ class ScheduleNullifyOrphanRunnerIdOnCiBuilds < Gitlab::Database::Migration[1.0] MIGRATION = 'NullifyOrphanRunnerIdOnCiBuilds' INTERVAL = 2.minutes - BATCH_SIZE = 100_000 - MAX_BATCH_SIZE = 100_000 # 100k * 25k = 2.5B ci_builds - SUB_BATCH_SIZE = 1_000 + BATCH_SIZE = 50_000 + MAX_BATCH_SIZE = 150_000 + SUB_BATCH_SIZE = 500 def up queue_batched_background_migration( diff --git a/db/post_migrate/20220308115219_schedule_reset_duplicate_ci_runners_token_encrypted_values_on_projects.rb b/db/post_migrate/20220308115219_schedule_reset_duplicate_ci_runners_token_encrypted_values_on_projects.rb new file mode 100644 index 00000000000..27e7af9a550 --- /dev/null +++ b/db/post_migrate/20220308115219_schedule_reset_duplicate_ci_runners_token_encrypted_values_on_projects.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class ScheduleResetDuplicateCiRunnersTokenEncryptedValuesOnProjects < Gitlab::Database::Migration[1.0] + MIGRATION = 'ResetDuplicateCiRunnersTokenEncryptedValuesOnProjects' + TOKEN_COLUMN_NAME = :runners_token_encrypted + TEMP_INDEX_NAME = "tmp_index_projects_on_id_and_#{TOKEN_COLUMN_NAME}" + BATCH_SIZE = 10_000 + DELAY_INTERVAL = 2.minutes + + disable_ddl_transaction! + + def up + add_concurrent_index :projects, [:id, TOKEN_COLUMN_NAME], where: "#{TOKEN_COLUMN_NAME} IS NOT NULL", unique: false, name: TEMP_INDEX_NAME + + queue_background_migration_jobs_by_range_at_intervals( + Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenEncryptedValuesOnProjects::Project.base_query, + MIGRATION, + DELAY_INTERVAL, + batch_size: BATCH_SIZE, + track_jobs: true + ) + end + + def down + remove_concurrent_index_by_name(:projects, name: TEMP_INDEX_NAME) + end +end diff --git a/db/post_migrate/20220308115502_schedule_reset_duplicate_ci_runners_token_values_on_projects.rb b/db/post_migrate/20220308115502_schedule_reset_duplicate_ci_runners_token_values_on_projects.rb new file mode 100644 index 00000000000..f076b0a740e --- /dev/null +++ b/db/post_migrate/20220308115502_schedule_reset_duplicate_ci_runners_token_values_on_projects.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class ScheduleResetDuplicateCiRunnersTokenValuesOnProjects < Gitlab::Database::Migration[1.0] + MIGRATION = 'ResetDuplicateCiRunnersTokenValuesOnProjects' + TOKEN_COLUMN_NAME = :runners_token + TEMP_INDEX_NAME = "tmp_index_projects_on_id_and_#{TOKEN_COLUMN_NAME}" + BATCH_SIZE = 10_000 + DELAY_INTERVAL = 2.minutes + + disable_ddl_transaction! + + def up + add_concurrent_index :projects, [:id, TOKEN_COLUMN_NAME], where: "#{TOKEN_COLUMN_NAME} IS NOT NULL", unique: false, name: TEMP_INDEX_NAME + + queue_background_migration_jobs_by_range_at_intervals( + Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenValuesOnProjects::Project.base_query, + MIGRATION, + DELAY_INTERVAL, + batch_size: BATCH_SIZE, + track_jobs: true + ) + end + + def down + remove_concurrent_index_by_name(:projects, name: TEMP_INDEX_NAME) + end +end diff --git a/db/schema_migrations/20220308115219 b/db/schema_migrations/20220308115219 new file mode 100644 index 00000000000..6e55d2fdabe --- /dev/null +++ b/db/schema_migrations/20220308115219 @@ -0,0 +1 @@ +e18ed9e6b2a98c77190ff2ce33f4d2b1984710b438e851d6a526ec8bb1f33c80
\ No newline at end of file diff --git a/db/schema_migrations/20220308115502 b/db/schema_migrations/20220308115502 new file mode 100644 index 00000000000..c379b67485c --- /dev/null +++ b/db/schema_migrations/20220308115502 @@ -0,0 +1 @@ +0aacf46a4a5b430a718336108f52c1c0bed4283846f36c2ab1de80100dcae0b4
\ No newline at end of file diff --git a/db/schema_migrations/20220310101118 b/db/schema_migrations/20220310101118 new file mode 100644 index 00000000000..c87f727c8b9 --- /dev/null +++ b/db/schema_migrations/20220310101118 @@ -0,0 +1 @@ +e4d6111f19f05b42b51e8d066e221205460514cef88ecf15ca99aa59788c4153
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 3895dcd9822..28fc3638728 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -21129,7 +21129,7 @@ CREATE TABLE user_credit_card_validations ( network text, CONSTRAINT check_1765e2b30f CHECK ((char_length(network) <= 32)), CONSTRAINT check_3eea080c91 CHECK (((last_digits >= 0) AND (last_digits <= 9999))), - CONSTRAINT check_eafe45d88b CHECK ((char_length(holder_name) <= 26)) + CONSTRAINT check_cc0c8dc0fe CHECK ((char_length(holder_name) <= 50)) ); CREATE TABLE user_custom_attributes ( @@ -29551,6 +29551,10 @@ CREATE UNIQUE INDEX tmp_index_on_tmp_project_id_on_namespaces ON namespaces USIN CREATE INDEX tmp_index_on_vulnerabilities_non_dismissed ON vulnerabilities USING btree (id) WHERE (state <> 2); +CREATE INDEX tmp_index_projects_on_id_and_runners_token ON projects USING btree (id, runners_token) WHERE (runners_token IS NOT NULL); + +CREATE INDEX tmp_index_projects_on_id_and_runners_token_encrypted ON projects USING btree (id, runners_token_encrypted) WHERE (runners_token_encrypted IS NOT NULL); + CREATE UNIQUE INDEX uniq_pkgs_deb_grp_architectures_on_distribution_id_and_name ON packages_debian_group_architectures USING btree (distribution_id, name); CREATE UNIQUE INDEX uniq_pkgs_deb_grp_components_on_distribution_id_and_name ON packages_debian_group_components USING btree (distribution_id, name); diff --git a/doc/administration/clusters/kas.md b/doc/administration/clusters/kas.md index abc3ffa539e..e5c371b9d40 100644 --- a/doc/administration/clusters/kas.md +++ b/doc/administration/clusters/kas.md @@ -9,8 +9,8 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3834) in GitLab 13.10, the GitLab agent server (KAS) became available on GitLab.com at `wss://kas.gitlab.com`. > - [Moved](https://gitlab.com/groups/gitlab-org/-/epics/6290) from GitLab Premium to GitLab Free in 14.5. -The GitLab agent server for Kubernetes (KAS) is a service that -manages the [GitLab agent for Kubernetes](../../user/clusters/agent/index.md). +The agent server is a component you install together with GitLab. It is required to +manage the [GitLab agent for Kubernetes](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent). The KAS acronym refers to the former name, `Kubernetes agent server`. diff --git a/doc/administration/instance_limits.md b/doc/administration/instance_limits.md index c7aa44f3a97..11a34f5b5f8 100644 --- a/doc/administration/instance_limits.md +++ b/doc/administration/instance_limits.md @@ -153,6 +153,17 @@ Set the limit to `0` to disable it. - **Default rate limit**: Disabled (unlimited). +### Search rate limit + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80631) in GitLab 14.9 + +This setting limits global search requests. + +| Limit | Default (requests per minute) | +|-------------------------|-------------------------------| +| Authenticated user | 30 | +| Unauthenticated user | 10 | + ## Gitaly concurrency limit Clone traffic can put a large strain on your Gitaly service. To prevent such workloads from overwhelming your Gitaly server, you can set concurrency limits in Gitaly's configuration file. diff --git a/doc/ci/environments/deployment_approvals.md b/doc/ci/environments/deployment_approvals.md index e42cde031ad..a44458f0490 100644 --- a/doc/ci/environments/deployment_approvals.md +++ b/doc/ci/environments/deployment_approvals.md @@ -81,23 +81,34 @@ Maintainer role. > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/342180/) in GitLab 14.9 -A blocked deployment is enqueued as soon as it receives the required number of approvals. A single -rejection causes the deployment to fail. The creator of a deployment cannot approve it, even if they -have permission to deploy. +You can use the UI or API to take these actions on a deployment: -You can approve or reject a deployment to a protected environment either in the UI or using the API: +- Approve it +- Allow it to proceed +- Reject it -### Using the UI +### Approve or reject a deployment using the UI + +Prerequisites: + +- Permission to deploy to the protected environment. + +To approve or reject a deployment to a protected environment using the UI: 1. On the top bar, select **Menu > Projects** and find your project. 1. On the left sidebar, select **Deployments > Environments**. 1. In the deployment's row, select **Approval options** (**{thumb-up}**). 1. Select **Approve** or **Reject**. -### Using the API +### Approve or reject a deployment using the API + +Prerequisites: + +- Permission to deploy to the protected environment. -Users who are allowed to deploy to the protected environment can approve or reject a blocked -deployment using the [Deployments API](../../api/deployments.md#approve-or-reject-a-blocked-deployment). +To approve or reject a deployment to a protected environment using the API, pass the +required attributes. For more details, see +[Approve or reject a blocked deployment](../../api/deployments.md#approve-or-reject-a-blocked-deployment). Example: diff --git a/doc/development/documentation/graphql_styleguide.md b/doc/development/documentation/graphql_styleguide.md index 5acc8bda6a6..ad19a40a3f5 100644 --- a/doc/development/documentation/graphql_styleguide.md +++ b/doc/development/documentation/graphql_styleguide.md @@ -6,7 +6,7 @@ info: "See the Technical Writers assigned to Development Guidelines: https://abo description: "Writing styles, markup, formatting, and other standards for GraphQL API's GitLab Documentation." --- -# GraphQL API +# Creating a GraphQL example page GraphQL APIs are different from [RESTful APIs](restful_api_styleguide.md). Reference information is generated in our [GraphQL reference](../../api/graphql/reference/index.md). diff --git a/doc/user/admin_area/settings/index.md b/doc/user/admin_area/settings/index.md index da8bc4062f1..052b6e26c07 100644 --- a/doc/user/admin_area/settings/index.md +++ b/doc/user/admin_area/settings/index.md @@ -130,6 +130,7 @@ The **Network** settings contain: Git LFS requests that supersede the user and IP rate limits. - [Files API Rate Limits](files_api_rate_limits.md) - Configure specific limits for Files API requests that supersede the user and IP rate limits. + - [Search rate limits](../../../administration/instance_limits.md#search-rate-limit) - Configure global search request rate limits for authenticated and unauthenticated users. - [Deprecated API Rate Limits](deprecated_api_rate_limits.md) - Configure specific limits for deprecated API requests that supersede the user and IP rate limits. - [Outbound requests](../../../security/webhooks.md) - Allow requests to the local network from hooks and services. diff --git a/doc/user/project/integrations/harbor.md b/doc/user/project/integrations/harbor.md new file mode 100644 index 00000000000..d66e2222538 --- /dev/null +++ b/doc/user/project/integrations/harbor.md @@ -0,0 +1,50 @@ +--- +stage: Ecosystem +group: Integrations +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +--- + +# Harbor container registry integration **(FREE)** + +Use Harbor as the container registry for your GitLab project. + +[Harbor](https://goharbor.io/) is an open source registry that can help you manage artifacts across cloud native compute platforms, like Kubernetes and Docker. + +This integration can help you if you need GitLab CI/CD and a container image repository. + +## Prerequisites + +In the Harbor instance, ensure that: + +- The project to be integrated has been created. +- The signed-in user has permission to pull, push, and edit images in the Harbor project. + +## Configure GitLab + +GitLab supports integrating Harbor projects at the group or project level. Complete these steps in GitLab: + +1. On the top bar, select **Menu > Projects** and find your project. +1. On the left sidebar, select **Settings > Integrations**. +1. Select **Harbor**. +1. Turn on the **Active** toggle under **Enable Integration**. +1. Provide the Harbor configuration information: + - **Harbor URL**: The base URL of Harbor instance which is being linked to this GitLab project. For example, `https://harbor.example.net`. + - **Harbor project name**: The project name in the Harbor instance. For example, `testproject`. + - **Username**: Your username in the Harbor instance, which should meet the requirements in [prerequisites](#prerequisites). + - **Password**: Password of your username. + +1. Select **Save changes**. + +After the Harbor integration is activated: + +- The global variables `$HARBOR_USER`, `$HARBOR_PASSWORD`, `$HARBOR_URL`, and `$HARBOR_PROJECT` are created for CI/CD use. +- The project-level integration settings override the group-level integration settings. + +## Secure your requests to the Harbor APIs + +For each API request through the Harbor integration, the credentials for your connection to the Harbor API use +the `username:password` combination. The following are suggestions for safe use: + +- Use TLS on the Harbor APIs you connect to. +- Follow the principle of least privilege (for access on Harbor) with your credentials. +- Have a rotation policy on your credentials. diff --git a/doc/user/project/integrations/overview.md b/doc/user/project/integrations/overview.md index 8ecc16050be..2cb62b8924e 100644 --- a/doc/user/project/integrations/overview.md +++ b/doc/user/project/integrations/overview.md @@ -43,6 +43,7 @@ Click on the service links to see further configuration instructions and details | [Flowdock](../../../api/integrations.md#flowdock) | Send notifications from GitLab to Flowdock flows. | **{dotted-circle}** No | | [GitHub](github.md) | Obtain statuses for commits and pull requests. | **{dotted-circle}** No | | [Google Chat](hangouts_chat.md) | Send notifications from your GitLab project to a room in Google Chat.| **{dotted-circle}** No | +| [Harbor](harbor.md) | Use Harbor as the container registry. | **{dotted-circle}** No | | [irker (IRC gateway)](irker.md) | Send IRC messages. | **{dotted-circle}** No | | [Jenkins](../../../integration/jenkins.md) | Run CI/CD pipelines with Jenkins. | **{check-circle}** Yes | | JetBrains TeamCity CI | Run CI/CD pipelines with TeamCity. | **{check-circle}** Yes | diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb index c3632c812f3..2e21f591667 100644 --- a/lib/api/repositories.rb +++ b/lib/api/repositories.rb @@ -248,6 +248,8 @@ module API changelog = service.execute(commit_to_changelog: false) present changelog, with: Entities::Changelog + rescue Gitlab::Changelog::Error => ex + render_api_error!("Failed to generate the changelog: #{ex.message}", 422) end desc 'Generates a changelog section for a release and commits it in a changelog file' do diff --git a/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects.rb b/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects.rb new file mode 100644 index 00000000000..80ca76ef37f --- /dev/null +++ b/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # A job to nullify duplicate runners_token_encrypted values in projects table in batches + class ResetDuplicateCiRunnersTokenEncryptedValuesOnProjects + class Project < ActiveRecord::Base # rubocop:disable Style/Documentation + include ::EachBatch + + self.table_name = 'projects' + + scope :base_query, -> do + where.not(runners_token_encrypted: nil) + end + end + + def perform(start_id, end_id) + # Reset duplicate runner tokens that would prevent creating an unique index. + duplicate_tokens = Project.base_query + .where(id: start_id..end_id) + .group(:runners_token_encrypted) + .having('COUNT(*) > 1') + .pluck(:runners_token_encrypted) + + Project.where(runners_token_encrypted: duplicate_tokens).update_all(runners_token_encrypted: nil) if duplicate_tokens.any? + + mark_job_as_succeeded(start_id, end_id) + end + + private + + def mark_job_as_succeeded(*arguments) + Gitlab::Database::BackgroundMigrationJob.mark_all_as_succeeded('ResetDuplicateCiRunnersTokenEncryptedValuesOnProjects', arguments) + end + end + end +end diff --git a/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects.rb b/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects.rb new file mode 100644 index 00000000000..d87ce6c88d3 --- /dev/null +++ b/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # A job to nullify duplicate ci_runners_token values in projects table in batches + class ResetDuplicateCiRunnersTokenValuesOnProjects + class Project < ActiveRecord::Base # rubocop:disable Style/Documentation + include ::EachBatch + + self.table_name = 'projects' + + scope :base_query, -> do + where.not(runners_token: nil) + end + end + + def perform(start_id, end_id) + # Reset duplicate runner tokens that would prevent creating an unique index. + duplicate_tokens = Project.base_query + .where(id: start_id..end_id) + .group(:runners_token) + .having('COUNT(*) > 1') + .pluck(:runners_token) + + Project.where(runners_token: duplicate_tokens).update_all(runners_token: nil) if duplicate_tokens.any? + + mark_job_as_succeeded(start_id, end_id) + end + + private + + def mark_job_as_succeeded(*arguments) + Gitlab::Database::BackgroundMigrationJob.mark_all_as_succeeded('ResetDuplicateCiRunnerValuesTokensOnProjects', arguments) + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4ff3918efb6..95267f78373 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -22841,9 +22841,6 @@ msgstr "" msgid "Maximum authenticated API requests per rate limit period per user" msgstr "" -msgid "Maximum authenticated requests by a user per minute" -msgstr "" - msgid "Maximum authenticated web requests per rate limit period per user" msgstr "" @@ -23214,7 +23211,7 @@ msgstr "" msgid "Merge automatically (%{strategy})" msgstr "" -msgid "Merge blocked: all merge request dependencies must be merged or closed." +msgid "Merge blocked: all merge request dependencies must be merged." msgstr "" msgid "Merge blocked: merge request must be marked as ready. It's still marked as draft." @@ -32351,7 +32348,7 @@ msgstr "" msgid "Saving project." msgstr "" -msgid "ScanResultPolicy|%{ifLabelStart}if%{ifLabelEnd} %{scanners} scan in an open merge request targeting the %{branches} branch(es) finds %{vulnerabilitiesAllowed} or more %{severities} vulnerabilities that are %{vulnerabilityStates}" +msgid "ScanResultPolicy|%{ifLabelStart}if%{ifLabelEnd} %{scanners} find(s) more than %{vulnerabilitiesAllowed} %{severities} %{vulnerabilityStates} vulnerabilities in an open merge request targeting %{branches}" msgstr "" msgid "ScanResultPolicy|%{thenLabelStart}Then%{thenLabelEnd} Require approval from %{approvalsRequired} of the following approvers:" diff --git a/spec/controllers/groups/releases_controller_spec.rb b/spec/controllers/groups/releases_controller_spec.rb index 582a77b1c50..8b08f913e10 100644 --- a/spec/controllers/groups/releases_controller_spec.rb +++ b/spec/controllers/groups/releases_controller_spec.rb @@ -20,11 +20,11 @@ RSpec.describe Groups::ReleasesController do context 'as json' do let(:format) { :json } - subject { get :index, params: { group_id: group }, format: format } + subject(:index) { get :index, params: { group_id: group }, format: format } context 'json_response' do before do - subject + index end it 'returns an application/json content_type' do @@ -38,7 +38,7 @@ RSpec.describe Groups::ReleasesController do context 'the user is not authorized' do before do - subject + index end it 'does not return any releases' do @@ -54,12 +54,38 @@ RSpec.describe Groups::ReleasesController do it "returns all group's public and private project's releases as JSON, ordered by released_at" do sign_in(guest) - subject + index expect(json_response.map {|r| r['tag'] } ).to match_array(%w(p2 p1 v2 v1)) end end + context 'group_releases_finder_inoperator feature flag' do + before do + sign_in(guest) + end + + it 'calls old code when disabled' do + stub_feature_flags(group_releases_finder_inoperator: false) + + allow(ReleasesFinder).to receive(:new).and_call_original + + index + + expect(ReleasesFinder).to have_received(:new) + end + + it 'calls new code when enabled' do + stub_feature_flags(group_releases_finder_inoperator: true) + + allow(Releases::GroupReleasesFinder).to receive(:new).and_call_original + + index + + expect(Releases::GroupReleasesFinder).to have_received(:new) + end + end + context 'N+1 queries' do it 'avoids N+1 database queries' do control_count = ActiveRecord::QueryRecorder.new { subject }.count diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index 33e22c377a3..df93bd773a6 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -707,6 +707,20 @@ RSpec.describe 'Admin updates settings' do include_examples 'regular throttle rate limit settings' end + + it 'changes search rate limits' do + visit network_admin_application_settings_path + + page.within('.as-search-limits') do + fill_in 'Maximum number of requests per minute for an authenticated user', with: 98 + fill_in 'Maximum number of requests per minute for an unauthenticated IP address', with: 76 + click_button 'Save changes' + end + + expect(page).to have_content "Application settings saved successfully" + expect(current_settings.search_rate_limit).to eq(98) + expect(current_settings.search_rate_limit_unauthenticated).to eq(76) + end end context 'Preferences page' do diff --git a/spec/finders/releases/group_releases_finder_spec.rb b/spec/finders/releases/group_releases_finder_spec.rb new file mode 100644 index 00000000000..b8899a8ee40 --- /dev/null +++ b/spec/finders/releases/group_releases_finder_spec.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Releases::GroupReleasesFinder do + let(:user) { create(:user) } + let(:group) { create(:group) } + let(:project) { create(:project, :repository, group: group) } + let(:params) { {} } + let(:args) { {} } + let(:repository) { project.repository } + let(:v1_0_0) { create(:release, project: project, tag: 'v1.0.0') } + let(:v1_1_0) { create(:release, project: project, tag: 'v1.1.0') } + let(:v1_1_1) { create(:release, project: project, tag: 'v1.1.1') } + + before do + v1_0_0.update_attribute(:released_at, 2.days.ago) + v1_1_0.update_attribute(:released_at, 1.day.ago) + v1_1_1.update_attribute(:released_at, 0.5.days.ago) + end + + shared_examples_for 'when the user is not part of the project' do + it 'returns no releases' do + is_expected.to be_empty + end + end + + shared_examples_for 'when the user is not part of the group' do + before do + allow(Ability).to receive(:allowed?).with(user, :read_release, group).and_return(false) + end + + it 'returns no releases' do + is_expected.to be_empty + end + end + + shared_examples_for 'preload' do + before do + allow(Ability).to receive(:allowed?).with(user, :read_release, group).and_return(true) + end + + it 'preloads associations' do + expect(Release).to receive(:preloaded).once.and_call_original + + releases + end + + context 'when preload is false' do + let(:args) { { preload: false } } + + it 'does not preload associations' do + expect(Release).not_to receive(:preloaded) + + releases + end + end + end + + describe 'when parent is a group' do + context 'without subgroups' do + let(:project2) { create(:project, :repository, namespace: group) } + let!(:v6) { create(:release, project: project2, tag: 'v6') } + + subject(:releases) { described_class.new(group, user, params).execute(**args) } + + it_behaves_like 'preload' + it_behaves_like 'when the user is not part of the group' + + context 'when the user is a project guest on one sibling project' do + before do + project.add_guest(user) + end + + it 'does not return any releases' do + expect(releases.size).to eq(0) + expect(releases).to eq([]) + end + end + + context 'when the user is a guest on the group' do + before do + group.add_guest(user) + v1_0_0.update_attribute(:released_at, 3.days.ago) + v6.update_attribute(:released_at, 2.days.ago) + v1_1_0.update_attribute(:released_at, 1.day.ago) + v1_1_1.update_attribute(:released_at, v1_1_0.released_at) + end + + it 'sorts by release date and id' do + expect(releases.size).to eq(4) + expect(releases).to eq([v1_1_1, v1_1_0, v6, v1_0_0]) + end + end + end + + describe 'with subgroups' do + let(:params) { { include_subgroups: true } } + + subject(:releases) { described_class.new(group, user, params).execute(**args) } + + context 'with a single-level subgroup' do + let(:subgroup) { create(:group, parent: group) } + let(:project2) { create(:project, :repository, namespace: subgroup) } + let!(:v6) { create(:release, project: project2, tag: 'v6') } + + it_behaves_like 'when the user is not part of the group' + + context 'when the user a project guest in the subgroup project' do + before do + project2.add_guest(user) + end + + it 'does not return any releases' do + expect(releases).to match_array([]) + end + end + + context 'when the user is a guest on the group' do + before do + group.add_guest(user) + v6.update_attribute(:released_at, 2.days.ago) + end + + it 'returns all releases' do + expect(releases).to match_array([v1_1_1, v1_1_0, v1_0_0, v6]) + end + end + end + + context 'with a multi-level subgroup' do + let(:subgroup) { create(:group, parent: group) } + let(:subsubgroup) { create(:group, parent: subgroup) } + let(:project2) { create(:project, :repository, namespace: subgroup) } + let(:project3) { create(:project, :repository, namespace: subsubgroup) } + let!(:v6) { create(:release, project: project2, tag: 'v6') } + let!(:p3) { create(:release, project: project3, tag: 'p3') } + + before do + v6.update_attribute(:released_at, 2.days.ago) + p3.update_attribute(:released_at, 3.days.ago) + end + + it_behaves_like 'when the user is not part of the group' + + context 'when the user a project guest in the subgroup and subsubgroup project' do + before do + project2.add_guest(user) + project3.add_guest(user) + end + + it 'does not return any releases' do + expect(releases).to match_array([]) + end + end + + context 'when the user a project guest in the subsubgroup project' do + before do + project3.add_guest(user) + end + + it 'does not return any releases' do + expect(releases).to match_array([]) + end + end + + context 'when the user a guest on the group' do + before do + group.add_guest(user) + end + + it 'returns all releases' do + expect(releases).to match_array([v1_1_1, v1_1_0, v6, v1_0_0, p3]) + end + end + + context 'performance testing' do + shared_examples 'avoids N+1 queries' do |query_params = {}| + context 'with subgroups' do + let(:params) { query_params } + + it 'include_subgroups avoids N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + releases + end.count + + subgroups = create_list(:group, 10, parent: group) + projects = create_list(:project, 10, namespace: subgroups[0]) + create_list(:release, 10, project: projects[0], author: user) + + expect do + releases + end.not_to exceed_all_query_limit(control_count) + end + end + end + + it_behaves_like 'avoids N+1 queries' + it_behaves_like 'avoids N+1 queries', { simple: true } + end + end + end + end +end diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js index 05fa0f79ef0..02e5b1dc271 100644 --- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -1,5 +1,5 @@ import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; -import { createTestEditor } from '../test_utils'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"> <code> @@ -12,34 +12,78 @@ const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language describe('content_editor/extensions/code_block_highlight', () => { let parsedCodeBlockHtmlFixture; let tiptapEditor; + let doc; + let codeBlock; + let languageLoader; const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html'); const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); beforeEach(() => { - tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); - parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML); + languageLoader = { loadLanguages: jest.fn() }; + tiptapEditor = createTestEditor({ + extensions: [CodeBlockHighlight.configure({ languageLoader })], + }); - tiptapEditor.commands.setContent(CODE_BLOCK_HTML); + ({ + builders: { doc, codeBlock }, + } = createDocBuilder({ + tiptapEditor, + names: { + codeBlock: { nodeType: CodeBlockHighlight.name }, + }, + })); }); - it('extracts language and params attributes from Markdown API output', () => { - const language = preElement().getAttribute('lang'); + describe('when parsing HTML', () => { + beforeEach(() => { + parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML); - expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({ - language, + tiptapEditor.commands.setContent(CODE_BLOCK_HTML); + }); + it('extracts language and params attributes from Markdown API output', () => { + const language = preElement().getAttribute('lang'); + + expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({ + language, + }); + }); + + it('adds code, highlight, and js-syntax-highlight to code block element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + + expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); }); - }); - it('adds code, highlight, and js-syntax-highlight to code block element', () => { - const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + it('adds content-editor-code-block class to the pre element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); - expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); + expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block'); + }); }); - it('adds content-editor-code-block class to the pre element', () => { - const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + describe.each` + inputRule + ${'```'} + ${'~~~'} + `('when typing $inputRule input rule', ({ inputRule }) => { + const language = 'javascript'; + + beforeEach(() => { + triggerNodeInputRule({ + tiptapEditor, + inputRuleText: `${inputRule}${language} `, + }); + }); + + it('creates a new code block and loads related language', () => { + const expectedDoc = doc(codeBlock({ language })); - expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block'); + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + + it('loads language when language loader is available', () => { + expect(languageLoader.loadLanguages).toHaveBeenCalledWith([language]); + }); }); }); diff --git a/spec/frontend/content_editor/services/code_block_language_loader_spec.js b/spec/frontend/content_editor/services/code_block_language_loader_spec.js new file mode 100644 index 00000000000..bb97c9afa41 --- /dev/null +++ b/spec/frontend/content_editor/services/code_block_language_loader_spec.js @@ -0,0 +1,70 @@ +import CodeBlockLanguageBlocker from '~/content_editor/services/code_block_language_loader'; + +describe('content_editor/services/code_block_language_loader', () => { + let languageLoader; + let lowlight; + + beforeEach(() => { + lowlight = { + languages: [], + registerLanguage: jest + .fn() + .mockImplementation((language) => lowlight.languages.push(language)), + registered: jest.fn().mockImplementation((language) => lowlight.languages.includes(language)), + }; + languageLoader = new CodeBlockLanguageBlocker(lowlight); + }); + + describe('loadLanguages', () => { + it('loads highlight.js language packages identified by a list of languages', async () => { + const languages = ['javascript', 'ruby']; + + await languageLoader.loadLanguages(languages); + + languages.forEach((language) => { + expect(lowlight.registerLanguage).toHaveBeenCalledWith(language, expect.any(Function)); + }); + }); + + describe('when language is already registered', () => { + it('does not load the language again', async () => { + const languages = ['javascript']; + + await languageLoader.loadLanguages(languages); + await languageLoader.loadLanguages(languages); + + expect(lowlight.registerLanguage).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('loadLanguagesFromDOM', () => { + it('loads highlight.js language packages identified by pre tags in a DOM fragment', async () => { + const parser = new DOMParser(); + const { body } = parser.parseFromString( + ` + <pre lang="javascript"></pre> + <pre lang="ruby"></pre> + `, + 'text/html', + ); + + await languageLoader.loadLanguagesFromDOM(body); + + expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function)); + expect(lowlight.registerLanguage).toHaveBeenCalledWith('ruby', expect.any(Function)); + }); + }); + + describe('isLanguageLoaded', () => { + it('returns true when a language is registered', async () => { + const language = 'javascript'; + + expect(languageLoader.isLanguageLoaded(language)).toBe(false); + + await languageLoader.loadLanguages([language]); + + expect(languageLoader.isLanguageLoaded(language)).toBe(true); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js index 3bc72b13302..5b7a27b501d 100644 --- a/spec/frontend/content_editor/services/content_editor_spec.js +++ b/spec/frontend/content_editor/services/content_editor_spec.js @@ -11,6 +11,7 @@ describe('content_editor/services/content_editor', () => { let contentEditor; let serializer; let deserializer; + let languageLoader; let eventHub; let doc; let p; @@ -27,8 +28,15 @@ describe('content_editor/services/content_editor', () => { serializer = { deserialize: jest.fn() }; deserializer = { deserialize: jest.fn() }; + languageLoader = { loadLanguagesFromDOM: jest.fn() }; eventHub = eventHubFactory(); - contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub }); + contentEditor = new ContentEditor({ + tiptapEditor, + serializer, + deserializer, + eventHub, + languageLoader, + }); }); describe('.dispose', () => { @@ -43,10 +51,12 @@ describe('content_editor/services/content_editor', () => { describe('when setSerializedContent succeeds', () => { let document; + const dom = {}; + const testMarkdown = '**bold text**'; beforeEach(() => { document = doc(p('document')); - deserializer.deserialize.mockResolvedValueOnce({ document }); + deserializer.deserialize.mockResolvedValueOnce({ document, dom }); }); it('emits loadingContent and loadingSuccess event in the eventHub', () => { @@ -59,14 +69,20 @@ describe('content_editor/services/content_editor', () => { expect(loadingContentEmitted).toBe(true); }); - contentEditor.setSerializedContent('**bold text**'); + contentEditor.setSerializedContent(testMarkdown); }); it('sets the deserialized document in the tiptap editor object', async () => { - await contentEditor.setSerializedContent('**bold text**'); + await contentEditor.setSerializedContent(testMarkdown); expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON()); }); + + it('passes deserialized DOM document to language loader', async () => { + await contentEditor.setSerializedContent(testMarkdown); + + expect(languageLoader.loadLanguagesFromDOM).toHaveBeenCalledWith(dom); + }); }); describe('when setSerializedContent fails', () => { diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb index 1eae854eca3..cb4eb43b88d 100644 --- a/spec/frontend/fixtures/merge_requests.rb +++ b/spec/frontend/fixtures/merge_requests.rb @@ -140,7 +140,7 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: query_name = 'ready_to_merge.query.graphql' it "#{base_output_path}#{query_name}.json" do - query = get_graphql_query_as_string("#{base_input_path}#{query_name}", ee: true) + query = get_graphql_query_as_string("#{base_input_path}#{query_name}", ee: Gitlab.ee?) post_graphql(query, current_user: user, variables: { projectPath: project.full_path, iid: merge_request.iid.to_s }) diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js index db56f77b60e..b8c1bef0ddd 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -12,6 +12,7 @@ import { TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION, TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL, } from '~/security_configuration/constants'; +import { TEMP_PROVIDER_URLS } from '~/security_configuration/components/constants'; import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; import { updateSecurityTrainingOptimisticResponse } from '~/security_configuration/graphql/cache_utils'; import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql'; @@ -145,55 +146,60 @@ describe('TrainingProviderList component', () => { expect(findCards()).toHaveLength(TEST_TRAINING_PROVIDERS_DEFAULT.data.length); }); - TEST_TRAINING_PROVIDERS_DEFAULT.data.forEach( - ({ name, description, url, isEnabled }, index) => { - it(`shows the name for card ${index}`, () => { - expect(findCards().at(index).text()).toContain(name); - }); + TEST_TRAINING_PROVIDERS_DEFAULT.data.forEach(({ name, description, isEnabled }, index) => { + it(`shows the name for card ${index}`, () => { + expect(findCards().at(index).text()).toContain(name); + }); - it(`shows the description for card ${index}`, () => { - expect(findCards().at(index).text()).toContain(description); - }); + it(`shows the description for card ${index}`, () => { + expect(findCards().at(index).text()).toContain(description); + }); + + it(`shows the learn more link for enabled card ${index}`, () => { + const learnMoreLink = findCards().at(index).find(GlLink); + const tempLogo = TEMP_PROVIDER_URLS[name]; - it(`shows the learn more link for card ${index}`, () => { - expect(findLinks().at(index).attributes()).toEqual({ + if (tempLogo) { + expect(learnMoreLink.attributes()).toEqual({ target: '_blank', - href: url, + href: TEMP_PROVIDER_URLS[name], }); - }); + } else { + expect(learnMoreLink.exists()).toBe(false); + } + }); - it(`shows the toggle with the correct value for card ${index}`, () => { - expect(findToggles().at(index).props('value')).toEqual(isEnabled); - }); + it(`shows the toggle with the correct value for card ${index}`, () => { + expect(findToggles().at(index).props('value')).toEqual(isEnabled); + }); - it(`shows a radio button to select the provider as primary within card ${index}`, () => { - const primaryProviderRadioForCurrentCard = findPrimaryProviderRadios().at(index); + it(`shows a radio button to select the provider as primary within card ${index}`, () => { + const primaryProviderRadioForCurrentCard = findPrimaryProviderRadios().at(index); - // if the given provider is not enabled it should not be possible select it as primary - expect(primaryProviderRadioForCurrentCard.find('input').attributes('disabled')).toBe( - isEnabled ? undefined : 'disabled', - ); + // if the given provider is not enabled it should not be possible select it as primary + expect(primaryProviderRadioForCurrentCard.find('input').attributes('disabled')).toBe( + isEnabled ? undefined : 'disabled', + ); - expect(primaryProviderRadioForCurrentCard.text()).toBe( - TrainingProviderList.i18n.primaryTraining, - ); - }); + expect(primaryProviderRadioForCurrentCard.text()).toBe( + TrainingProviderList.i18n.primaryTraining, + ); + }); - it('shows a info-tooltip that describes the purpose of a primary provider', () => { - const infoIcon = findPrimaryProviderRadios().at(index).find(GlIcon); - const tooltip = getBinding(infoIcon.element, 'gl-tooltip'); + it('shows a info-tooltip that describes the purpose of a primary provider', () => { + const infoIcon = findPrimaryProviderRadios().at(index).find(GlIcon); + const tooltip = getBinding(infoIcon.element, 'gl-tooltip'); - expect(infoIcon.props()).toMatchObject({ - name: 'information-o', - }); - expect(tooltip.value).toBe(TrainingProviderList.i18n.primaryTrainingDescription); + expect(infoIcon.props()).toMatchObject({ + name: 'information-o', }); + expect(tooltip.value).toBe(TrainingProviderList.i18n.primaryTrainingDescription); + }); - it('does not show loader when query is populated', () => { - expect(findLoader().exists()).toBe(false); - }); - }, - ); + it('does not show loader when query is populated', () => { + expect(findLoader().exists()).toBe(false); + }); + }); }); describe('provider logo', () => { diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js index 55f5c20e45d..18a480bf082 100644 --- a/spec/frontend/security_configuration/mock_data.js +++ b/spec/frontend/security_configuration/mock_data.js @@ -1,6 +1,6 @@ export const testProjectPath = 'foo/bar'; export const testProviderIds = [101, 102, 103]; -export const testProviderName = ['Vendor Name 1', 'Vendor Name 2', 'Vendor Name 3']; +export const testProviderName = ['Kontra', 'Secure Code Warrior', 'Other Vendor']; export const testTrainingUrls = [ 'https://www.vendornameone.com/url', 'https://www.vendornametwo.com/url', diff --git a/spec/graphql/resolvers/board_list_issues_resolver_spec.rb b/spec/graphql/resolvers/board_list_issues_resolver_spec.rb index a6b536e1158..392385d2a30 100644 --- a/spec/graphql/resolvers/board_list_issues_resolver_spec.rb +++ b/spec/graphql/resolvers/board_list_issues_resolver_spec.rb @@ -25,10 +25,10 @@ RSpec.describe Resolvers::BoardListIssuesResolver do let(:wildcard_started) { 'STARTED' } let(:filters) { { milestone_title: ["started"], milestone_wildcard_id: wildcard_started } } - it 'raises a mutually exclusive filter error when milestone wildcard and title are provided' do - expect do + it 'generates a mutually exclusive filter error when milestone wildcard and title are provided' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do resolve_board_list_issues(args: { filters: filters }) - end.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + end end it 'returns the issues in the correct order' do diff --git a/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb b/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb index b0fc78af2af..8b9874c3580 100644 --- a/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb +++ b/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb @@ -26,8 +26,10 @@ RSpec.describe Resolvers::DesignManagement::VersionInCollectionResolver do subject(:result) { resolve_version(issue.design_collection) } context 'Neither id nor sha is passed as parameters' do - it 'raises an appropriate error' do - expect { result }.to raise_error(appropriate_error) + it 'generates an appropriate error' do + expect_graphql_error_to_be_created(appropriate_error) do + result + end end end diff --git a/spec/graphql/resolvers/design_management/version_resolver_spec.rb b/spec/graphql/resolvers/design_management/version_resolver_spec.rb index af1e6a73d09..ab1d7d4d9c5 100644 --- a/spec/graphql/resolvers/design_management/version_resolver_spec.rb +++ b/spec/graphql/resolvers/design_management/version_resolver_spec.rb @@ -22,8 +22,10 @@ RSpec.describe Resolvers::DesignManagement::VersionResolver do context 'the current user is not authorized' do let(:current_user) { create(:user) } - it 'raises an error on resolution' do - expect { resolve_version }.to raise_error(::Gitlab::Graphql::Errors::ResourceNotAvailable) + it 'generates an error on resolution' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + resolve_version + end end end diff --git a/spec/graphql/resolvers/design_management/versions_resolver_spec.rb b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb index 2c9c3a47650..d98138f6385 100644 --- a/spec/graphql/resolvers/design_management/versions_resolver_spec.rb +++ b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb @@ -98,8 +98,10 @@ RSpec.describe Resolvers::DesignManagement::VersionsResolver do } end - it 'raises a suitable error' do - expect { result }.to raise_error(GraphQL::ExecutionError) + it 'generates a suitable error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + result + end end end end diff --git a/spec/graphql/resolvers/group_issues_resolver_spec.rb b/spec/graphql/resolvers/group_issues_resolver_spec.rb index e17429560ac..f5f6086cc09 100644 --- a/spec/graphql/resolvers/group_issues_resolver_spec.rb +++ b/spec/graphql/resolvers/group_issues_resolver_spec.rb @@ -86,10 +86,10 @@ RSpec.describe Resolvers::GroupIssuesResolver do end context 'release_tag filter' do - it 'returns an error when trying to filter by negated release_tag' do - expect do + it 'generates an error when trying to filter by negated release_tag' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'releaseTag filter is not allowed when parent is a group.') do resolve_issues(not: { release_tag: ['v1.0'] }) - end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'releaseTag filter is not allowed when parent is a group.') + end end end end diff --git a/spec/graphql/resolvers/issue_status_counts_resolver_spec.rb b/spec/graphql/resolvers/issue_status_counts_resolver_spec.rb index 3fbd9bd2368..77f4ce4cac5 100644 --- a/spec/graphql/resolvers/issue_status_counts_resolver_spec.rb +++ b/spec/graphql/resolvers/issue_status_counts_resolver_spec.rb @@ -70,10 +70,10 @@ RSpec.describe Resolvers::IssueStatusCountsResolver do end context 'when both assignee_username and assignee_usernames are provided' do - it 'raises a mutually exclusive filter error' do - expect do + it 'generates a mutually exclusive filter error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.') do resolve_issue_status_counts(assignee_usernames: [current_user.username], assignee_username: current_user.username) - end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.') + end end end diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb index 326c105a358..5e9a3d0a68b 100644 --- a/spec/graphql/resolvers/issues_resolver_spec.rb +++ b/spec/graphql/resolvers/issues_resolver_spec.rb @@ -78,10 +78,10 @@ RSpec.describe Resolvers::IssuesResolver do expect(resolve_issues(milestone_wildcard_id: wildcard_none)).to contain_exactly(issue2) end - it 'raises a mutually exclusive filter error when wildcard and title are provided' do - expect do + it 'generates a mutually exclusive filter error when wildcard and title are provided' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'only one of [milestoneTitle, milestoneWildcardId] arguments is allowed at the same time.') do resolve_issues(milestone_title: ["started milestone"], milestone_wildcard_id: wildcard_started) - end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [milestoneTitle, milestoneWildcardId] arguments is allowed at the same time.') + end end context 'negated filtering' do @@ -97,10 +97,10 @@ RSpec.describe Resolvers::IssuesResolver do expect(resolve_issues(not: { milestone_wildcard_id: wildcard_upcoming })).to contain_exactly(issue6) end - it 'raises a mutually exclusive filter error when wildcard and title are provided as negated filters' do - expect do + it 'generates a mutually exclusive filter error when wildcard and title are provided as negated filters' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'only one of [milestoneTitle, milestoneWildcardId] arguments is allowed at the same time.') do resolve_issues(not: { milestone_title: ["started milestone"], milestone_wildcard_id: wildcard_started }) - end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [milestoneTitle, milestoneWildcardId] arguments is allowed at the same time.') + end end end end @@ -122,10 +122,10 @@ RSpec.describe Resolvers::IssuesResolver do end context 'when release_tag_wildcard_id is also provided' do - it 'raises a mutually eclusive argument error' do - expect do + it 'generates a mutually eclusive argument error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'only one of [releaseTag, releaseTagWildcardId] arguments is allowed at the same time.') do resolve_issues(release_tag: [release1.tag], release_tag_wildcard_id: 'ANY') - end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [releaseTag, releaseTagWildcardId] arguments is allowed at the same time.') + end end end end @@ -191,10 +191,10 @@ RSpec.describe Resolvers::IssuesResolver do end context 'when both assignee_username and assignee_usernames are provided' do - it 'raises a mutually exclusive filter error' do - expect do + it 'generates a mutually exclusive filter error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.') do resolve_issues(assignee_usernames: [assignee.username], assignee_username: assignee.username) - end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.') + end end end end @@ -331,11 +331,12 @@ RSpec.describe Resolvers::IssuesResolver do stub_feature_flags(disable_anonymous_search: true) end - it 'returns an error' do + it 'generates an error' do error_message = "User must be authenticated to include the `search` argument." - expect { resolve(described_class, obj: public_project, args: { search: 'test' }, ctx: { current_user: nil }) } - .to raise_error(Gitlab::Graphql::Errors::ArgumentError, error_message) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, error_message) do + resolve(described_class, obj: public_project, args: { search: 'test' }, ctx: { current_user: nil }) + end end end diff --git a/spec/graphql/resolvers/kas/agent_configurations_resolver_spec.rb b/spec/graphql/resolvers/kas/agent_configurations_resolver_spec.rb index bdb1ced46ae..e4cf62b0361 100644 --- a/spec/graphql/resolvers/kas/agent_configurations_resolver_spec.rb +++ b/spec/graphql/resolvers/kas/agent_configurations_resolver_spec.rb @@ -34,8 +34,10 @@ RSpec.describe Resolvers::Kas::AgentConfigurationsResolver do allow(kas_client).to receive(:list_agent_config_files).and_raise(GRPC::DeadlineExceeded) end - it 'raises a graphql error' do - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, 'GRPC::DeadlineExceeded') + it 'generates a graphql error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable, 'GRPC::DeadlineExceeded') do + subject + end end end diff --git a/spec/graphql/resolvers/labels_resolver_spec.rb b/spec/graphql/resolvers/labels_resolver_spec.rb index be6229553d7..efd2596b9eb 100644 --- a/spec/graphql/resolvers/labels_resolver_spec.rb +++ b/spec/graphql/resolvers/labels_resolver_spec.rb @@ -28,7 +28,9 @@ RSpec.describe Resolvers::LabelsResolver do describe '#resolve' do context 'with unauthorized user' do it 'returns no labels' do - expect { resolve_labels(project) }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + resolve_labels(project) + end end end diff --git a/spec/graphql/resolvers/package_pipelines_resolver_spec.rb b/spec/graphql/resolvers/package_pipelines_resolver_spec.rb index 892dc641201..c757c876616 100644 --- a/spec/graphql/resolvers/package_pipelines_resolver_spec.rb +++ b/spec/graphql/resolvers/package_pipelines_resolver_spec.rb @@ -25,32 +25,40 @@ RSpec.describe Resolvers::PackagePipelinesResolver do context 'with invalid after' do let(:args) { { first: 1, after: 'not_json_string' } } - it 'raises argument error' do - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + it 'generates an argument error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do + subject + end end end context 'with invalid after key' do let(:args) { { first: 1, after: encode_cursor(foo: 3) } } - it 'raises argument error' do - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + it 'generates an argument error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do + subject + end end end context 'with invalid before' do let(:args) { { last: 1, before: 'not_json_string' } } - it 'raises argument error' do - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + it 'generates an argument error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do + subject + end end end context 'with invalid before key' do let(:args) { { last: 1, before: encode_cursor(foo: 3) } } - it 'raises argument error' do - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + it 'generates an argument error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do + subject + end end end diff --git a/spec/graphql/resolvers/paginated_tree_resolver_spec.rb b/spec/graphql/resolvers/paginated_tree_resolver_spec.rb index 82b05937aa3..4b05e9076d7 100644 --- a/spec/graphql/resolvers/paginated_tree_resolver_spec.rb +++ b/spec/graphql/resolvers/paginated_tree_resolver_spec.rb @@ -65,7 +65,11 @@ RSpec.describe Resolvers::PaginatedTreeResolver do context 'when cursor is invalid' do let(:args) { super().merge(after: 'invalid') } - it { expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) } + it 'generates an error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do + subject + end + end end it 'returns all tree entries during cursor pagination' do diff --git a/spec/graphql/resolvers/project_milestones_resolver_spec.rb b/spec/graphql/resolvers/project_milestones_resolver_spec.rb index e168291c804..2cf490c2b6a 100644 --- a/spec/graphql/resolvers/project_milestones_resolver_spec.rb +++ b/spec/graphql/resolvers/project_milestones_resolver_spec.rb @@ -103,27 +103,27 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do end context 'when start date is after end_date' do - it 'raises error' do - expect do + it 'generates an error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'startDate is after endDate') do resolve_project_milestones(start_date: Time.now, end_date: Time.now - 2.days) - end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, "startDate is after endDate") + end end end end context 'when only start_date is present' do - it 'raises error' do - expect do + it 'generates an error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/) do resolve_project_milestones(start_date: Time.now) - end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/) + end end end context 'when only end_date is present' do - it 'raises error' do - expect do + it 'generates an error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/) do resolve_project_milestones(end_date: Time.now) - end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/) + end end end @@ -174,12 +174,12 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do end context 'when user cannot read milestones' do - it 'raises error' do + it 'generates an error' do unauthorized_user = create(:user) - expect do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do resolve_project_milestones({}, { current_user: unauthorized_user }) - end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end end end end diff --git a/spec/graphql/resolvers/project_pipeline_resolver_spec.rb b/spec/graphql/resolvers/project_pipeline_resolver_spec.rb index 6a8aa39f3b2..398f8f52269 100644 --- a/spec/graphql/resolvers/project_pipeline_resolver_spec.rb +++ b/spec/graphql/resolvers/project_pipeline_resolver_spec.rb @@ -85,13 +85,15 @@ RSpec.describe Resolvers::ProjectPipelineResolver do end it 'errors when no iid or sha is passed' do - expect { resolve_pipeline(project, {}) } - .to raise_error(Gitlab::Graphql::Errors::ArgumentError) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do + resolve_pipeline(project, {}) + end end it 'errors when both iid and sha are passed' do - expect { resolve_pipeline(project, { iid: '1234', sha: 'sha' }) } - .to raise_error(Gitlab::Graphql::Errors::ArgumentError) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do + resolve_pipeline(project, { iid: '1234', sha: 'sha' }) + end end context 'when the pipeline is a dangling pipeline' do diff --git a/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb b/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb index c6d8c518fb7..b95bab41e3e 100644 --- a/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb +++ b/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb @@ -14,10 +14,10 @@ RSpec.describe Resolvers::Projects::JiraProjectsResolver do let_it_be(:project) { create(:project) } shared_examples 'no project service access' do - it 'raises error' do - expect do + it 'generates an error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do resolve_jira_projects - end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end end end @@ -89,11 +89,14 @@ RSpec.describe Resolvers::Projects::JiraProjectsResolver do .to_raise(JIRA::HTTPError.new(double(message: '{"errorMessages":["Some failure"]}'))) end - it 'raises failure error' do + it 'generates a failure error' do config_docs_link_url = Rails.application.routes.url_helpers.help_page_path('integration/jira/configure') docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: config_docs_link_url } error_message = 'An error occurred while requesting data from Jira: Some failure. Check your %{docs_link_start}Jira integration configuration</a> and try again.' % { docs_link_start: docs_link_start } - expect { resolve_jira_projects }.to raise_error(error_message) + + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::BaseError, error_message) do + resolve_jira_projects + end end end end diff --git a/spec/graphql/resolvers/timelog_resolver_spec.rb b/spec/graphql/resolvers/timelog_resolver_spec.rb index 82ed572c3ee..84fa2932829 100644 --- a/spec/graphql/resolvers/timelog_resolver_spec.rb +++ b/spec/graphql/resolvers/timelog_resolver_spec.rb @@ -85,27 +85,30 @@ RSpec.describe Resolvers::TimelogResolver do context 'when start_time and start_date are present' do let(:args) { { start_time: 6.days.ago, start_date: 6.days.ago } } - it 'returns correct error' do - expect { timelogs } - .to raise_error(error_class, /Provide either a start date or time, but not both/) + it 'generates an error' do + expect_graphql_error_to_be_created(error_class, /Provide either a start date or time, but not both/) do + timelogs + end end end context 'when end_time and end_date are present' do let(:args) { { end_time: 2.days.ago, end_date: 2.days.ago } } - it 'returns correct error' do - expect { timelogs } - .to raise_error(error_class, /Provide either an end date or time, but not both/) + it 'generates an error' do + expect_graphql_error_to_be_created(error_class, /Provide either an end date or time, but not both/) do + timelogs + end end end context 'when start argument is after end argument' do let(:args) { { start_time: 2.days.ago, end_time: 6.days.ago } } - it 'returns correct error' do - expect { timelogs } - .to raise_error(error_class, /Start argument must be before End argument/) + it 'generates an error' do + expect_graphql_error_to_be_created(error_class, /Start argument must be before End argument/) do + timelogs + end end end end @@ -276,9 +279,10 @@ RSpec.describe Resolvers::TimelogResolver do let(:args) { {} } let(:extra_args) { {} } - it 'returns correct error' do - expect { timelogs } - .to raise_error(error_class, /Provide at least one argument/) + it 'generates an error' do + expect_graphql_error_to_be_created(error_class, /Provide at least one argument/) do + timelogs + end end end diff --git a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb new file mode 100644 index 00000000000..6aea549b136 --- /dev/null +++ b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenEncryptedValuesOnProjects do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + + let(:perform) { described_class.new.perform(1, 4) } + + before do + namespaces.create!(id: 123, name: 'sample', path: 'sample') + + projects.create!(id: 1, namespace_id: 123, runners_token_encrypted: 'duplicate') + projects.create!(id: 2, namespace_id: 123, runners_token_encrypted: 'a-runners-token') + projects.create!(id: 3, namespace_id: 123, runners_token_encrypted: 'duplicate') + projects.create!(id: 4, namespace_id: 123, runners_token_encrypted: nil) + projects.create!(id: 5, namespace_id: 123, runners_token_encrypted: 'duplicate-2') + projects.create!(id: 6, namespace_id: 123, runners_token_encrypted: 'duplicate-2') + end + + describe '#up' do + before do + stub_const("#{described_class}::SUB_BATCH_SIZE", 2) + end + + it 'nullifies duplicate tokens', :aggregate_failures do + perform + + expect(projects.count).to eq(6) + expect(projects.all.pluck(:id, :runners_token_encrypted).to_h).to eq( + { 1 => nil, 2 => 'a-runners-token', 3 => nil, 4 => nil, 5 => 'duplicate-2', 6 => 'duplicate-2' } + ) + expect(projects.pluck(:runners_token_encrypted).uniq).to match_array [nil, 'a-runners-token', 'duplicate-2'] + end + end +end diff --git a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb new file mode 100644 index 00000000000..cbe762c2680 --- /dev/null +++ b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenValuesOnProjects do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + + let(:perform) { described_class.new.perform(1, 4) } + + before do + namespaces.create!(id: 123, name: 'sample', path: 'sample') + + projects.create!(id: 1, namespace_id: 123, runners_token: 'duplicate') + projects.create!(id: 2, namespace_id: 123, runners_token: 'a-runners-token') + projects.create!(id: 3, namespace_id: 123, runners_token: 'duplicate') + projects.create!(id: 4, namespace_id: 123, runners_token: nil) + projects.create!(id: 5, namespace_id: 123, runners_token: 'duplicate-2') + projects.create!(id: 6, namespace_id: 123, runners_token: 'duplicate-2') + end + + describe '#up' do + before do + stub_const("#{described_class}::SUB_BATCH_SIZE", 2) + end + + it 'nullifies duplicate tokens', :aggregate_failures do + perform + + expect(projects.count).to eq(6) + expect(projects.all.pluck(:id, :runners_token).to_h).to eq( + { 1 => nil, 2 => 'a-runners-token', 3 => nil, 4 => nil, 5 => 'duplicate-2', 6 => 'duplicate-2' } + ) + expect(projects.pluck(:runners_token).uniq).to match_array [nil, 'a-runners-token', 'duplicate-2'] + end + end +end diff --git a/spec/models/users/credit_card_validation_spec.rb b/spec/models/users/credit_card_validation_spec.rb index 43edf7ed093..34cfd500c26 100644 --- a/spec/models/users/credit_card_validation_spec.rb +++ b/spec/models/users/credit_card_validation_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Users::CreditCardValidation do it { is_expected.to belong_to(:user) } - it { is_expected.to validate_length_of(:holder_name).is_at_most(26) } + it { is_expected.to validate_length_of(:holder_name).is_at_most(50) } it { is_expected.to validate_length_of(:network).is_at_most(32) } it { is_expected.to validate_numericality_of(:last_digits).is_less_than_or_equal_to(9999) } diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index f42fc7aabc2..1d199a72d1d 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -783,6 +783,13 @@ RSpec.describe API::Repositories do expect(response).to have_gitlab_http_status(:ok) expect(json_response['notes']).to be_present end + + context 'when previous tag version does not exist' do + it_behaves_like '422 response' do + let(:request) { get api("/projects/#{project.id}/repository/changelog", user), params: { version: 'v0.0.0' } } + let(:message) { 'Failed to generate the changelog: The commit start range is unspecified, and no previous tag could be found to use instead' } + end + end end describe 'POST /projects/:id/repository/changelog' do diff --git a/spec/support/shared_examples/graphql/members_shared_examples.rb b/spec/support/shared_examples/graphql/members_shared_examples.rb index b0bdd27a95f..8e9e22f4359 100644 --- a/spec/support/shared_examples/graphql/members_shared_examples.rb +++ b/spec/support/shared_examples/graphql/members_shared_examples.rb @@ -76,8 +76,10 @@ RSpec.shared_examples 'querying members with a group' do resolve(described_class, obj: resource, args: base_args.merge(args), ctx: { current_user: other_user }) end - it 'raises an error' do - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + it 'generates an error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + subject + end end end end |