diff options
72 files changed, 1300 insertions, 305 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 1ca0d658ecd..31a8768968f 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -ffbce774bce90b5a65f5b235afe492a7266aa82f +16479e5771b69a2b4c22aade7c0a7fc2a6f897ce diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index 288d6711682..07cc0ce46bc 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -20,6 +20,7 @@ import axios from '~/lib/utils/axios_utils'; import csrf from '~/lib/utils/csrf'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; +import validation from '~/vue_shared/directives/validation'; const PRIVATE_VISIBILITY = 'private'; const INTERNAL_VISIBILITY = 'internal'; @@ -31,6 +32,13 @@ const ALLOWED_VISIBILITY = { public: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY, PUBLIC_VISIBILITY], }; +const initFormField = ({ value, required = true, skipValidation = false }) => ({ + value, + required, + state: skipValidation ? true : null, + feedback: null, +}); + export default { components: { GlForm, @@ -46,6 +54,9 @@ export default { GlFormRadioGroup, GlFormSelect, }, + directives: { + validation: validation(), + }, inject: { newGroupPath: { default: '', @@ -77,7 +88,8 @@ export default { }, projectDescription: { type: String, - required: true, + required: false, + default: '', }, projectVisibility: { type: String, @@ -85,16 +97,30 @@ export default { }, }, data() { + const form = { + state: false, + showValidation: false, + fields: { + namespace: initFormField({ + value: null, + }), + name: initFormField({ value: this.projectName }), + slug: initFormField({ value: this.projectPath }), + description: initFormField({ + value: this.projectDescription, + required: false, + skipValidation: true, + }), + visibility: initFormField({ + value: this.projectVisibility, + skipValidation: true, + }), + }, + }; return { isSaving: false, namespaces: [], - selectedNamespace: {}, - fork: { - name: this.projectName, - slug: this.projectPath, - description: this.projectDescription, - visibility: this.projectVisibility, - }, + form, }; }, computed: { @@ -106,7 +132,7 @@ export default { }, namespaceAllowedVisibility() { return ( - ALLOWED_VISIBILITY[this.selectedNamespace.visibility] || + ALLOWED_VISIBILITY[this.form.fields.namespace.value?.visibility] || ALLOWED_VISIBILITY[PUBLIC_VISIBILITY] ); }, @@ -139,16 +165,17 @@ export default { }, }, watch: { - selectedNamespace(newVal) { + // eslint-disable-next-line func-names + 'form.fields.namespace.value': function (newVal) { const { visibility } = newVal; if (this.projectAllowedVisibility.includes(visibility)) { - this.fork.visibility = visibility; + this.form.fields.visibility.value = visibility; } }, // eslint-disable-next-line func-names - 'fork.name': function (newVal) { - this.fork.slug = kebabCase(newVal); + 'form.fields.name.value': function (newVal) { + this.form.fields.slug.value = kebabCase(newVal); }, }, mounted() { @@ -166,19 +193,25 @@ export default { ); }, async onSubmit() { + this.form.showValidation = true; + + if (!this.form.state) { + return; + } + this.isSaving = true; + this.form.showValidation = false; const { projectId } = this; - const { name, slug, description, visibility } = this.fork; - const { id: namespaceId } = this.selectedNamespace; + const { name, slug, description, visibility, namespace } = this.form.fields; const postParams = { id: projectId, - name, - namespace_id: namespaceId, - path: slug, - description, - visibility, + name: name.value, + namespace_id: namespace.value.id, + path: slug.value, + description: description.value, + visibility: visibility.value, }; const forkProjectPath = `/api/:version/projects/:id/fork`; @@ -198,16 +231,34 @@ export default { </script> <template> - <gl-form method="POST" @submit.prevent="onSubmit"> + <gl-form novalidate method="POST" @submit.prevent="onSubmit"> <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> - <gl-form-group label="Project name" label-for="fork-name"> - <gl-form-input id="fork-name" v-model="fork.name" data-testid="fork-name-input" required /> + <gl-form-group + :label="__('Project name')" + label-for="fork-name" + :invalid-feedback="form.fields.name.feedback" + > + <gl-form-input + id="fork-name" + v-model="form.fields.name.value" + v-validation:[form.showValidation] + name="name" + data-testid="fork-name-input" + :state="form.fields.name.state" + required + /> </gl-form-group> <div class="gl-md-display-flex"> <div class="gl-flex-basis-half"> - <gl-form-group label="Project URL" label-for="fork-url" class="gl-md-mr-3"> + <gl-form-group + :label="__('Project URL')" + label-for="fork-url" + class="gl-md-mr-3" + :state="form.fields.namespace.state" + :invalid-feedback="s__('ForkProject|Please select a namespace')" + > <gl-form-input-group> <template #prepend> <gl-input-group-text> @@ -216,9 +267,12 @@ export default { </template> <gl-form-select id="fork-url" - v-model="selectedNamespace" + v-model="form.fields.namespace.value" + v-validation:[form.showValidation] + name="namespace" data-testid="fork-url-input" data-qa-selector="fork_namespace_dropdown" + :state="form.fields.namespace.state" required > <template slot="first"> @@ -232,11 +286,19 @@ export default { </gl-form-group> </div> <div class="gl-flex-basis-half"> - <gl-form-group label="Project slug" label-for="fork-slug" class="gl-md-ml-3"> + <gl-form-group + :label="__('Project slug')" + label-for="fork-slug" + class="gl-md-ml-3" + :invalid-feedback="form.fields.slug.feedback" + > <gl-form-input id="fork-slug" - v-model="fork.slug" + v-model="form.fields.slug.value" + v-validation:[form.showValidation] data-testid="fork-slug-input" + name="slug" + :state="form.fields.slug.state" required /> </gl-form-group> @@ -250,11 +312,13 @@ export default { </gl-link> </p> - <gl-form-group label="Project description (optional)" label-for="fork-description"> + <gl-form-group :label="__('Project description (optional)')" label-for="fork-description"> <gl-form-textarea id="fork-description" - v-model="fork.description" + v-model="form.fields.description.value" data-testid="fork-description-textarea" + name="description" + :state="form.fields.description.state" /> </gl-form-group> @@ -266,8 +330,9 @@ export default { </gl-link> </label> <gl-form-radio-group - v-model="fork.visibility" + v-model="form.fields.visibility.value" data-testid="fork-visibility-radio-group" + name="visibility" required > <gl-form-radio @@ -291,6 +356,7 @@ export default { type="submit" category="primary" variant="confirm" + class="js-no-auto-disable" data-testid="submit-button" data-qa-selector="fork_project_button" :loading="isSaving" diff --git a/app/channels/graphql_channel.rb b/app/channels/graphql_channel.rb new file mode 100644 index 00000000000..d364cc2b64b --- /dev/null +++ b/app/channels/graphql_channel.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# This is based on https://github.com/rmosolgo/graphql-ruby/blob/v1.11.8/lib/graphql/subscriptions/action_cable_subscriptions.rb#L19-L82 +# modified to work with our own ActionCableLink client + +class GraphqlChannel < ApplicationCable::Channel # rubocop:disable Gitlab/NamespacedClass + def subscribed + @subscription_ids = [] + + query = params['query'] + variables = Gitlab::Graphql::Variables.new(params['variables']).to_h + operation_name = params['operationName'] + + result = GitlabSchema.execute( + query, + context: context, + variables: variables, + operation_name: operation_name + ) + + payload = { + result: result.to_h, + more: result.subscription? + } + + # Track the subscription here so we can remove it + # on unsubscribe. + if result.context[:subscription_id] + @subscription_ids << result.context[:subscription_id] + end + + transmit(payload) + end + + def unsubscribed + @subscription_ids.each do |sid| + GitlabSchema.subscriptions.delete_subscription(sid) + end + end + + rescue_from Gitlab::Graphql::Variables::Invalid do |exception| + transmit({ errors: [{ message: exception.message }] }) + end + + private + + # When modifying the context, also update GraphqlController#context if needed + # so that we have similar context when executing queries, mutations, and subscriptions + # + # Objects added to the context may also need to be reloaded in + # `Subscriptions::BaseSubscription` so that they are not stale + def context + # is_sessionless_user is always false because we only support cookie auth in ActionCable + { channel: self, current_user: current_user, is_sessionless_user: false } + end +end diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index a13ec1daddb..b7daff04373 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -109,6 +109,8 @@ class GraphqlController < ApplicationController end end + # When modifying the context, also update GraphqlChannel#context if needed + # so that we have similar context when executing queries, mutations, and subscriptions def context @context ||= { current_user: current_user, is_sessionless_user: !!sessionless_user?, request: request } end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 8369d0e120f..3778c928ec5 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -10,6 +10,7 @@ class GitlabSchema < GraphQL::Schema DEFAULT_MAX_DEPTH = 15 AUTHENTICATED_MAX_DEPTH = 20 + use GraphQL::Subscriptions::ActionCableSubscriptions use GraphQL::Pagination::Connections use BatchLoader::GraphQL use Gitlab::Graphql::Pagination::Connections @@ -24,6 +25,7 @@ class GitlabSchema < GraphQL::Schema query Types::QueryType mutation Types::MutationType + subscription Types::SubscriptionType default_max_page_size 100 diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb new file mode 100644 index 00000000000..671c7c2cd25 --- /dev/null +++ b/app/graphql/graphql_triggers.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module GraphqlTriggers + def self.issuable_assignees_updated(issuable) + GitlabSchema.subscriptions.trigger('issuableAssigneesUpdated', { issuable_id: issuable.to_gid }, issuable) + end +end diff --git a/app/graphql/subscriptions/base_subscription.rb b/app/graphql/subscriptions/base_subscription.rb new file mode 100644 index 00000000000..5f7931787df --- /dev/null +++ b/app/graphql/subscriptions/base_subscription.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Subscriptions + class BaseSubscription < GraphQL::Schema::Subscription + object_class Types::BaseObject + field_class Types::BaseField + + def initialize(object:, context:, field:) + super + + # Reset user so that we don't use a stale user for authorization + current_user.reset if current_user + end + + def authorized?(*) + raise NotImplementedError + end + + private + + def unauthorized! + unsubscribe if context.query.subscription_update? + + raise GraphQL::ExecutionError, 'Unauthorized subscription' + end + + def current_user + context[:current_user] + end + end +end diff --git a/app/graphql/subscriptions/issuable_updated.rb b/app/graphql/subscriptions/issuable_updated.rb new file mode 100644 index 00000000000..c1d82bfcf9c --- /dev/null +++ b/app/graphql/subscriptions/issuable_updated.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Subscriptions + class IssuableUpdated < BaseSubscription + include Gitlab::Graphql::Laziness + + payload_type Types::IssuableType + + argument :issuable_id, Types::GlobalIDType[Issuable], + required: true, + description: 'ID of the issuable.' + + def subscribe(issuable_id:) + nil + end + + def authorized?(issuable_id:) + # TODO: remove this check when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + raise Gitlab::Graphql::Errors::ArgumentError, 'Invalid IssuableID' unless issuable_id.is_a?(GlobalID) + + issuable = force(GitlabSchema.find_by_gid(issuable_id)) + + unauthorized! unless issuable && Ability.allowed?(current_user, :"read_#{issuable.to_ability_name}", issuable) + + true + end + end +end diff --git a/app/graphql/types/issuable_type.rb b/app/graphql/types/issuable_type.rb new file mode 100644 index 00000000000..fc57491bb59 --- /dev/null +++ b/app/graphql/types/issuable_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + class IssuableType < BaseUnion + graphql_name 'Issuable' + description 'Represents an issuable.' + + possible_types Types::IssueType, Types::MergeRequestType + + def self.resolve_type(object, context) + case object + when Issue + Types::IssueType + when MergeRequest + Types::MergeRequestType + else + raise 'Unsupported issuable type' + end + end + end +end + +Types::IssuableType.prepend_if_ee('::EE::Types::IssuableType') diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb new file mode 100644 index 00000000000..5356a998f0d --- /dev/null +++ b/app/graphql/types/subscription_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + class SubscriptionType < ::Types::BaseObject + graphql_name 'Subscription' + + field :issuable_assignees_updated, subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the assignees of an issuable are updated.' + end +end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index cda87cbd212..4373f47877f 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -393,7 +393,7 @@ module ApplicationSettingsHelper end def integration_expanded?(substring) - @application_setting.errors.any? { |k| k.to_s.start_with?(substring) } + @application_setting.errors.messages.any? { |k, _| k.to_s.start_with?(substring) } end def instance_clusters_enabled? diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 71d8e06de76..5881997ea56 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -124,7 +124,9 @@ module Routable def set_path_errors route_path_errors = self.errors.delete(:"route.path") - self.errors[:path].concat(route_path_errors) if route_path_errors + route_path_errors&.each do |msg| + self.errors.add(:path, msg) + end end def full_name_changed? diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb index 30abd0159b3..94cbd6c5959 100644 --- a/app/models/project_services/youtrack_service.rb +++ b/app/models/project_services/youtrack_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class YoutrackService < IssueTrackerService + include ActionView::Helpers::UrlHelper + validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030 @@ -17,7 +19,12 @@ class YoutrackService < IssueTrackerService end def description - s_('IssueTracker|YouTrack issue tracker') + s_("IssueTracker|Use YouTrack as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/youtrack'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use YouTrack as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end def self.to_param diff --git a/app/models/user.rb b/app/models/user.rb index 92d11d231ec..a5ef8d64133 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1414,7 +1414,9 @@ class User < ApplicationRecord if namespace_path_errors.include?('has already been taken') && !User.exists?(username: username) self.errors.add(:base, :username_exists_as_a_different_namespace) else - self.errors[:username].concat(namespace_path_errors) + namespace_path_errors.each do |msg| + self.errors.add(:username, msg) + end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 702527d80a7..e05df029ae3 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -61,12 +61,7 @@ module Issues todo_service.update_issue(issue, current_user, old_mentioned_users) end - if issue.assignees != old_assignees - create_assignee_note(issue, old_assignees) - notification_service.async.reassigned_issue(issue, current_user, old_assignees) - todo_service.reassigned_assignable(issue, current_user, old_assignees) - track_incident_action(current_user, issue, :incident_assigned) - end + handle_assignee_changes(issue, old_assignees) if issue.previous_changes.include?('confidential') # don't enqueue immediately to prevent todos removal in case of a mistake @@ -90,6 +85,19 @@ module Issues end end + def handle_assignee_changes(issue, old_assignees) + return if issue.assignees == old_assignees + + create_assignee_note(issue, old_assignees) + notification_service.async.reassigned_issue(issue, current_user, old_assignees) + todo_service.reassigned_assignable(issue, current_user, old_assignees) + track_incident_action(current_user, issue, :incident_assigned) + + if Gitlab::ActionCable::Config.in_app? || Feature.enabled?(:broadcast_issue_updates, issue.project) + GraphqlTriggers.issuable_assignees_updated(issue) + end + end + def handle_task_changes(issuable) todo_service.resolve_todos_for_target(issuable, current_user) todo_service.update_issue(issuable, current_user) diff --git a/app/services/packages/debian/generate_distribution_key_service.rb b/app/services/packages/debian/generate_distribution_key_service.rb new file mode 100644 index 00000000000..28c97c7681e --- /dev/null +++ b/app/services/packages/debian/generate_distribution_key_service.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Packages + module Debian + class GenerateDistributionKeyService + include Gitlab::Utils::StrongMemoize + + def initialize(current_user:, params: {}) + @current_user = current_user + @params = params + end + + def execute + raise ArgumentError, 'Please provide a user' unless current_user.is_a?(User) + + generate_key + end + + private + + attr_reader :current_user, :params + + def passphrase + strong_memoize(:passphrase) do + params[:passphrase] || ::User.random_password + end + end + + def pinentry_script_content + escaped_passphrase = Shellwords.escape(passphrase) + + <<~EOF + #!/bin/sh + + echo OK Pleased to meet you + + while read -r cmd; do + case "$cmd" in + GETPIN) echo D #{escaped_passphrase}; echo OK;; + *) echo OK;; + esac + done + EOF + end + + def using_pinentry + Gitlab::Gpg.using_tmp_keychain do + home_dir = Gitlab::Gpg.current_home_dir + + File.write("#{home_dir}/pinentry.sh", pinentry_script_content, mode: 'w', perm: 0755) + + File.write("#{home_dir}/gpg-agent.conf", "pinentry-program #{home_dir}/pinentry.sh\n", mode: 'w') + + GPGME::Ctx.new(armor: true, offline: true) do |ctx| + yield ctx + end + end + end + + def generate_key_params + # https://www.gnupg.org/documentation/manuals/gnupg/Unattended-GPG-key-generation.html + '<GnupgKeyParms format="internal">' + "\n" + + { + 'Key-Type': params[:key_type] || 'RSA', + 'Key-Length': params[:key_length] || 4096, + 'Key-Usage': params[:key_usage] || 'sign', + 'Name-Real': params[:name_real] || 'GitLab Debian repository', + 'Name-Email': params[:name_email] || Gitlab.config.gitlab.email_reply_to, + 'Name-Comment': params[:name_comment] || 'GitLab Debian repository automatic signing key', + 'Expire-Date': params[:expire_date] || 0, + 'Passphrase': passphrase + }.map { |k, v| "#{k}: #{v}\n" }.join + + '</GnupgKeyParms>' + end + + def generate_key + using_pinentry do |ctx| + # Generate key + ctx.generate_key generate_key_params + + key = ctx.keys.first # rubocop:disable Gitlab/KeysFirstAndValuesFirst + fingerprint = key.fingerprint + + # Export private key + data = GPGME::Data.new + ctx.export_keys fingerprint, data, GPGME::EXPORT_MODE_SECRET + data.seek 0 + private_key = data.read + + # Export public key + data = GPGME::Data.new + ctx.export_keys fingerprint, data + data.seek 0 + public_key = data.read + + { + private_key: private_key, + public_key: public_key, + passphrase: passphrase, + fingerprint: fingerprint + } + end + end + end + end +end diff --git a/app/services/system_notes/base_service.rb b/app/services/system_notes/base_service.rb index 7341a25b133..ee7784c127b 100644 --- a/app/services/system_notes/base_service.rb +++ b/app/services/system_notes/base_service.rb @@ -13,10 +13,10 @@ module SystemNotes protected def create_note(note_summary) - note = Note.create(note_summary.note.merge(system: true)) - note.system_note_metadata = SystemNoteMetadata.new(note_summary.metadata) if note_summary.metadata? + note_params = note_summary.note.merge(system: true) + note_params[:system_note_metadata] = SystemNoteMetadata.new(note_summary.metadata) if note_summary.metadata? - note + Note.create(note_params) end def content_tag(*args) diff --git a/app/views/search/results/_user.html.haml b/app/views/search/results/_user.html.haml index 8060a1577e4..9e70d9c9baa 100644 --- a/app/views/search/results/_user.html.haml +++ b/app/views/search/results/_user.html.haml @@ -1,9 +1,9 @@ %ul.content-list %li - .avatar-cell.d-none.d-sm-block - = user_avatar(user: user, user_name: user.name, css_class: 'd-none d-sm-inline avatar s40') + .avatar-cell + = user_avatar(user: user, size: 40, user_name: user.name) .user-info - = link_to user_path(user), class: 'd-none d-sm-inline' do + = link_to user_path(user) do .item-title = user.name = user_status(user) diff --git a/changelogs/unreleased/21067-reduce-SQL-calls-when-creating-SystemNotes.yml b/changelogs/unreleased/21067-reduce-SQL-calls-when-creating-SystemNotes.yml new file mode 100644 index 00000000000..c3d881c9ded --- /dev/null +++ b/changelogs/unreleased/21067-reduce-SQL-calls-when-creating-SystemNotes.yml @@ -0,0 +1,5 @@ +--- +title: Reduce number of SQL queries when creating SystemNotes +merge_request: 59102 +author: +type: performance diff --git a/changelogs/unreleased/300626-msj-youtrack.yml b/changelogs/unreleased/300626-msj-youtrack.yml new file mode 100644 index 00000000000..5efb8c8bab0 --- /dev/null +++ b/changelogs/unreleased/300626-msj-youtrack.yml @@ -0,0 +1,5 @@ +--- +title: Review and revise YouTrack integration UI text +merge_request: 59998 +author: +type: other diff --git a/changelogs/unreleased/fix-search-users-avatar-size.yml b/changelogs/unreleased/fix-search-users-avatar-size.yml new file mode 100644 index 00000000000..2bf438146c8 --- /dev/null +++ b/changelogs/unreleased/fix-search-users-avatar-size.yml @@ -0,0 +1,5 @@ +--- +title: 'Users search: fix avatar size' +merge_request: 59883 +author: +type: fixed diff --git a/config/feature_flags/development/optimize_linkable_attributes.yml b/config/feature_flags/development/optimize_linkable_attributes.yml new file mode 100644 index 00000000000..8f35306b4ac --- /dev/null +++ b/config/feature_flags/development/optimize_linkable_attributes.yml @@ -0,0 +1,8 @@ +--- +name: optimize_linkable_attributes +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59983 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/328696 +milestone: '13.12' +type: development +group: group::source code +default_enabled: false diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md index ebb29b5c70e..6019c606c5a 100644 --- a/doc/administration/monitoring/prometheus/gitlab_metrics.md +++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md @@ -129,6 +129,7 @@ The following metrics are available: | `pipeline_graph_link_calculation_duration_seconds` | Histogram | 13.9 | Total time spent calculating links, in seconds | | | `pipeline_graph_links_total` | Histogram | 13.9 | Number of links per graph | | | `pipeline_graph_links_per_job_ratio` | Histogram | 13.9 | Ratio of links to job per graph | | +| `gitlab_ci_pipeline_security_orchestration_policy_processing_duration_seconds` | Histogram | 13.12 | Time in seconds it takes to process Security Policies in CI/CD pipeline | | ## Metrics controlled by a feature flag diff --git a/doc/administration/pages/index.md b/doc/administration/pages/index.md index ae4fa086e3f..13d91c83041 100644 --- a/doc/administration/pages/index.md +++ b/doc/administration/pages/index.md @@ -5,7 +5,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w description: 'Learn how to administer GitLab Pages.' --- -# GitLab Pages administration +# GitLab Pages administration **(FREE SELF)** > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80) in GitLab EE 8.3. > - Custom CNAMEs with TLS support were [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/173) in GitLab EE 8.5. diff --git a/doc/administration/pages/source.md b/doc/administration/pages/source.md index e1b54e11a1f..f1c3b515f68 100644 --- a/doc/administration/pages/source.md +++ b/doc/administration/pages/source.md @@ -4,7 +4,7 @@ group: Release 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 --- -# GitLab Pages administration for source installations +# GitLab Pages administration for source installations **(FREE SELF)** NOTE: Before attempting to enable GitLab Pages, first make sure you have diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 6156526772a..9773ff441c5 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -11817,6 +11817,22 @@ Represents the Geo sync and verification state of a snippet repository. | <a id="submoduletype"></a>`type` | [`EntryType!`](#entrytype) | Type of tree entry. | | <a id="submoduleweburl"></a>`webUrl` | [`String`](#string) | Web URL for the sub-module. | +### `Subscription` + +#### Fields with arguments + +##### `Subscription.issuableAssigneesUpdated` + +Triggered when the assignees of an issuable are updated. + +Returns [`Issuable`](#issuable). + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="subscriptionissuableassigneesupdatedissuableid"></a>`issuableId` | [`IssuableID!`](#issuableid) | ID of the issuable. | + ### `TaskCompletionStatus` Completion status of tasks. @@ -14181,6 +14197,12 @@ An example `IncidentManagementOncallRotationID` is: `"gid://gitlab/IncidentManag Represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. +### `IssuableID` + +A `IssuableID` is a global ID. It is encoded as a string. + +An example `IssuableID` is: `"gid://gitlab/Issuable/1"`. + ### `IssueID` A `IssueID` is a global ID. It is encoded as a string. @@ -14390,6 +14412,16 @@ abstract types. ### Unions +#### `Issuable` + +Represents an issuable. + +One of: + +- [`Epic`](#epic) +- [`Issue`](#issue) +- [`MergeRequest`](#mergerequest) + #### `PackageMetadata` Represents metadata associated with a Package. diff --git a/doc/development/distributed_tracing.md b/doc/development/distributed_tracing.md index e5293c0804c..8bd8599a0b0 100644 --- a/doc/development/distributed_tracing.md +++ b/doc/development/distributed_tracing.md @@ -89,8 +89,6 @@ The easiest way to access tracing from a GDK environment is through the [performance-bar](../administration/monitoring/performance/performance_bar.md). This can be shown by typing `p` `b` in the browser window. -![Jaeger Search UI](img/distributed_tracing_performance_bar.png) - Once the performance bar is enabled, click on the **Trace** link in the performance bar to go to the Jaeger UI. diff --git a/doc/development/img/distributed_tracing_performance_bar.png b/doc/development/img/distributed_tracing_performance_bar.png Binary files differdeleted file mode 100644 index 8c819045104..00000000000 --- a/doc/development/img/distributed_tracing_performance_bar.png +++ /dev/null diff --git a/doc/operations/metrics/dashboards/img/metrics_dashboard_template_selection_v13_3.png b/doc/operations/metrics/dashboards/img/metrics_dashboard_template_selection_v13_3.png Binary files differindex bd8401a1747..cad075ca421 100644 --- a/doc/operations/metrics/dashboards/img/metrics_dashboard_template_selection_v13_3.png +++ b/doc/operations/metrics/dashboards/img/metrics_dashboard_template_selection_v13_3.png diff --git a/lib/api/concerns/packages/debian_endpoints.rb b/lib/api/concerns/packages/debian_endpoints.rb new file mode 100644 index 00000000000..6fc7c439464 --- /dev/null +++ b/lib/api/concerns/packages/debian_endpoints.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module API + module Concerns + module Packages + module DebianEndpoints + extend ActiveSupport::Concern + + DISTRIBUTION_REGEX = %r{[a-zA-Z0-9][a-zA-Z0-9.-]*}.freeze + COMPONENT_REGEX = %r{[a-z-]+}.freeze + ARCHITECTURE_REGEX = %r{[a-z][a-z0-9]*}.freeze + LETTER_REGEX = %r{(lib)?[a-z0-9]}.freeze + PACKAGE_REGEX = API::NO_SLASH_URL_PART_REGEX + DISTRIBUTION_REQUIREMENTS = { + distribution: DISTRIBUTION_REGEX + }.freeze + COMPONENT_ARCHITECTURE_REQUIREMENTS = { + component: COMPONENT_REGEX, + architecture: ARCHITECTURE_REGEX + }.freeze + COMPONENT_LETTER_SOURCE_PACKAGE_REQUIREMENTS = { + component: COMPONENT_REGEX, + letter: LETTER_REGEX, + source_package: PACKAGE_REGEX + }.freeze + FILE_NAME_REQUIREMENTS = { + file_name: API::NO_SLASH_URL_PART_REGEX + }.freeze + + included do + feature_category :package_registry + + helpers ::API::Helpers::PackagesHelpers + helpers ::API::Helpers::Packages::BasicAuthHelpers + + format :txt + content_type :txt, 'text/plain' + + rescue_from ArgumentError do |e| + render_api_error!(e.message, 400) + end + + rescue_from ActiveRecord::RecordInvalid do |e| + render_api_error!(e.message, 400) + end + + before do + require_packages_enabled! + end + + namespace 'packages/debian' do + params do + requires :distribution, type: String, desc: 'The Debian Codename', regexp: Gitlab::Regex.debian_distribution_regex + end + + namespace 'dists/*distribution', requirements: DISTRIBUTION_REQUIREMENTS do + # GET {projects|groups}/:id/packages/debian/dists/*distribution/Release.gpg + desc 'The Release file signature' do + detail 'This feature was introduced in GitLab 13.5' + end + + route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth, authenticate_non_public: true + get 'Release.gpg' do + not_found! + end + + # GET {projects|groups}/:id/packages/debian/dists/*distribution/Release + desc 'The unsigned Release file' do + detail 'This feature was introduced in GitLab 13.5' + end + + route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth, authenticate_non_public: true + get 'Release' do + # https://gitlab.com/gitlab-org/gitlab/-/issues/5835#note_414103286 + 'TODO Release' + end + + # GET {projects|groups}/:id/packages/debian/dists/*distribution/InRelease + desc 'The signed Release file' do + detail 'This feature was introduced in GitLab 13.5' + end + + route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth, authenticate_non_public: true + get 'InRelease' do + not_found! + end + + params do + requires :component, type: String, desc: 'The Debian Component', regexp: Gitlab::Regex.debian_component_regex + requires :architecture, type: String, desc: 'The Debian Architecture', regexp: Gitlab::Regex.debian_architecture_regex + end + + namespace ':component/binary-:architecture', requirements: COMPONENT_ARCHITECTURE_REQUIREMENTS do + # GET {projects|groups}/:id/packages/debian/dists/*distribution/:component/binary-:architecture/Packages + desc 'The binary files index' do + detail 'This feature was introduced in GitLab 13.5' + end + + route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth, authenticate_non_public: true + get 'Packages' do + # https://gitlab.com/gitlab-org/gitlab/-/issues/5835#note_414103286 + 'TODO Packages' + end + end + end + + params do + requires :component, type: String, desc: 'The Debian Component', regexp: Gitlab::Regex.debian_component_regex + requires :letter, type: String, desc: 'The Debian Classification (first-letter or lib-first-letter)' + requires :source_package, type: String, desc: 'The Debian Source Package Name', regexp: Gitlab::Regex.debian_package_name_regex + end + + namespace 'pool/:component/:letter/:source_package', requirements: COMPONENT_LETTER_SOURCE_PACKAGE_REQUIREMENTS do + # GET {projects|groups}/:id/packages/debian/pool/:component/:letter/:source_package/:file_name + params do + requires :file_name, type: String, desc: 'The Debian File Name' + end + desc 'The package' do + detail 'This feature was introduced in GitLab 13.5' + end + + route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth, authenticate_non_public: true + get ':file_name', requirements: FILE_NAME_REQUIREMENTS do + # https://gitlab.com/gitlab-org/gitlab/-/issues/5835#note_414103286 + 'TODO File' + end + end + end + end + end + end + end +end diff --git a/lib/api/debian_group_packages.rb b/lib/api/debian_group_packages.rb index cf9fa388e9e..06edab662bf 100644 --- a/lib/api/debian_group_packages.rb +++ b/lib/api/debian_group_packages.rb @@ -15,8 +15,8 @@ module API authorize_read_package!(user_group) end - namespace ':id/-/packages/debian' do - include DebianPackageEndpoints + namespace ':id/-' do + include ::API::Concerns::Packages::DebianEndpoints end end end diff --git a/lib/api/debian_package_endpoints.rb b/lib/api/debian_package_endpoints.rb deleted file mode 100644 index e7689b3feff..00000000000 --- a/lib/api/debian_package_endpoints.rb +++ /dev/null @@ -1,127 +0,0 @@ -# frozen_string_literal: true - -module API - module DebianPackageEndpoints - extend ActiveSupport::Concern - - DISTRIBUTION_REGEX = %r{[a-zA-Z0-9][a-zA-Z0-9.-]*}.freeze - COMPONENT_REGEX = %r{[a-z-]+}.freeze - ARCHITECTURE_REGEX = %r{[a-z][a-z0-9]*}.freeze - LETTER_REGEX = %r{(lib)?[a-z0-9]}.freeze - PACKAGE_REGEX = API::NO_SLASH_URL_PART_REGEX - DISTRIBUTION_REQUIREMENTS = { - distribution: DISTRIBUTION_REGEX - }.freeze - COMPONENT_ARCHITECTURE_REQUIREMENTS = { - component: COMPONENT_REGEX, - architecture: ARCHITECTURE_REGEX - }.freeze - COMPONENT_LETTER_SOURCE_PACKAGE_REQUIREMENTS = { - component: COMPONENT_REGEX, - letter: LETTER_REGEX, - source_package: PACKAGE_REGEX - }.freeze - FILE_NAME_REQUIREMENTS = { - file_name: API::NO_SLASH_URL_PART_REGEX - }.freeze - - included do - feature_category :package_registry - - helpers ::API::Helpers::PackagesHelpers - helpers ::API::Helpers::Packages::BasicAuthHelpers - - format :txt - content_type :txt, 'text/plain' - - rescue_from ArgumentError do |e| - render_api_error!(e.message, 400) - end - - rescue_from ActiveRecord::RecordInvalid do |e| - render_api_error!(e.message, 400) - end - - before do - require_packages_enabled! - end - - params do - requires :distribution, type: String, desc: 'The Debian Codename', regexp: Gitlab::Regex.debian_distribution_regex - end - - namespace 'dists/*distribution', requirements: DISTRIBUTION_REQUIREMENTS do - # GET {projects|groups}/:id/packages/debian/dists/*distribution/Release.gpg - desc 'The Release file signature' do - detail 'This feature was introduced in GitLab 13.5' - end - - route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth, authenticate_non_public: true - get 'Release.gpg' do - not_found! - end - - # GET {projects|groups}/:id/packages/debian/dists/*distribution/Release - desc 'The unsigned Release file' do - detail 'This feature was introduced in GitLab 13.5' - end - - route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth, authenticate_non_public: true - get 'Release' do - # https://gitlab.com/gitlab-org/gitlab/-/issues/5835#note_414103286 - 'TODO Release' - end - - # GET {projects|groups}/:id/packages/debian/dists/*distribution/InRelease - desc 'The signed Release file' do - detail 'This feature was introduced in GitLab 13.5' - end - - route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth, authenticate_non_public: true - get 'InRelease' do - not_found! - end - - params do - requires :component, type: String, desc: 'The Debian Component', regexp: Gitlab::Regex.debian_component_regex - requires :architecture, type: String, desc: 'The Debian Architecture', regexp: Gitlab::Regex.debian_architecture_regex - end - - namespace ':component/binary-:architecture', requirements: COMPONENT_ARCHITECTURE_REQUIREMENTS do - # GET {projects|groups}/:id/packages/debian/dists/*distribution/:component/binary-:architecture/Packages - desc 'The binary files index' do - detail 'This feature was introduced in GitLab 13.5' - end - - route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth, authenticate_non_public: true - get 'Packages' do - # https://gitlab.com/gitlab-org/gitlab/-/issues/5835#note_414103286 - 'TODO Packages' - end - end - end - - params do - requires :component, type: String, desc: 'The Debian Component', regexp: Gitlab::Regex.debian_component_regex - requires :letter, type: String, desc: 'The Debian Classification (first-letter or lib-first-letter)' - requires :source_package, type: String, desc: 'The Debian Source Package Name', regexp: Gitlab::Regex.debian_package_name_regex - end - - namespace 'pool/:component/:letter/:source_package', requirements: COMPONENT_LETTER_SOURCE_PACKAGE_REQUIREMENTS do - # GET {projects|groups}/:id/packages/debian/pool/:component/:letter/:source_package/:file_name - params do - requires :file_name, type: String, desc: 'The Debian File Name' - end - desc 'The package' do - detail 'This feature was introduced in GitLab 13.5' - end - - route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth, authenticate_non_public: true - get ':file_name', requirements: FILE_NAME_REQUIREMENTS do - # https://gitlab.com/gitlab-org/gitlab/-/issues/5835#note_414103286 - 'TODO File' - end - end - end - end -end diff --git a/lib/api/debian_project_packages.rb b/lib/api/debian_project_packages.rb index 8c0db42a448..0ed828fd639 100644 --- a/lib/api/debian_project_packages.rb +++ b/lib/api/debian_project_packages.rb @@ -15,14 +15,14 @@ module API authorize_read_package! end - namespace ':id/packages/debian' do - include DebianPackageEndpoints + namespace ':id' do + include ::API::Concerns::Packages::DebianEndpoints params do requires :file_name, type: String, desc: 'The file name' end - namespace ':file_name', requirements: FILE_NAME_REQUIREMENTS do + namespace 'packages/debian/:file_name', requirements: FILE_NAME_REQUIREMENTS do content_type :json, Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE # PUT {projects|groups}/:id/packages/debian/:file_name diff --git a/lib/banzai/filter/base_relative_link_filter.rb b/lib/banzai/filter/base_relative_link_filter.rb index fd526df4c48..84a6e18e77b 100644 --- a/lib/banzai/filter/base_relative_link_filter.rb +++ b/lib/banzai/filter/base_relative_link_filter.rb @@ -10,19 +10,16 @@ module Banzai protected def linkable_attributes - strong_memoize(:linkable_attributes) do - attrs = [] - - attrs += doc.search('a:not(.gfm)').map do |el| - el.attribute('href') - end - - attrs += doc.search('img:not(.gfm), video:not(.gfm), audio:not(.gfm)').flat_map do |el| - [el.attribute('src'), el.attribute('data-src')] - end - - attrs.reject do |attr| - attr.blank? || attr.value.start_with?('//') + if Feature.enabled?(:optimize_linkable_attributes, project, default_enabled: :yaml) + # Nokorigi Nodeset#search performs badly for documents with many nodes + # + # Here we store fetched attributes in the shared variable "result" + # This variable is passed through the chain of filters and can be + # accessed by them + result[:linkable_attributes] ||= fetch_linkable_attributes + else + strong_memoize(:linkable_attributes) do + fetch_linkable_attributes end end end @@ -40,6 +37,16 @@ module Banzai def unescape_and_scrub_uri(uri) Addressable::URI.unescape(uri).scrub.delete("\0") end + + def fetch_linkable_attributes + attrs = [] + + attrs += doc.search('a:not(.gfm), img:not(.gfm), video:not(.gfm), audio:not(.gfm)').flat_map do |el| + [el.attribute('href'), el.attribute('src'), el.attribute('data-src')] + end + + attrs.reject { |attr| attr.blank? || attr.value.start_with?('//') } + end end end end diff --git a/lib/banzai/filter/upload_link_filter.rb b/lib/banzai/filter/upload_link_filter.rb index 762371e1418..ceb7547a85d 100644 --- a/lib/banzai/filter/upload_link_filter.rb +++ b/lib/banzai/filter/upload_link_filter.rb @@ -15,8 +15,16 @@ module Banzai def call return doc if context[:system_note] - linkable_attributes.each do |attr| - process_link_to_upload_attr(attr) + if Feature.enabled?(:optimize_linkable_attributes, project, default_enabled: :yaml) + # We exclude processed upload links from the linkable attributes to + # prevent further modifications by RepositoryLinkFilter + linkable_attributes.reject! do |attr| + process_link_to_upload_attr(attr) + end + else + linkable_attributes.each do |attr| + process_link_to_upload_attr(attr) + end end doc diff --git a/lib/file_size_validator.rb b/lib/file_size_validator.rb index e9868732172..ac3210b4e98 100644 --- a/lib/file_size_validator.rb +++ b/lib/file_size_validator.rb @@ -62,7 +62,7 @@ class FileSizeValidator < ActiveModel::EachValidator default_message = options[MESSAGES[key]] errors_options[:message] ||= default_message if default_message - record.errors.add(attribute, MESSAGES[key], errors_options) + record.errors.add(attribute, MESSAGES[key], **errors_options) end end diff --git a/lib/gitlab/api_authentication/token_locator.rb b/lib/gitlab/api_authentication/token_locator.rb index 09039f3fc43..6ab37487822 100644 --- a/lib/gitlab/api_authentication/token_locator.rb +++ b/lib/gitlab/api_authentication/token_locator.rb @@ -10,7 +10,7 @@ module Gitlab attr_reader :location - validates :location, inclusion: { in: %i[http_basic_auth http_token] } + validates :location, inclusion: { in: %i[http_basic_auth http_token token_param] } def initialize(location) @location = location @@ -23,6 +23,8 @@ module Gitlab extract_from_http_basic_auth request when :http_token extract_from_http_token request + when :token_param + extract_from_token_param request end end @@ -41,6 +43,13 @@ module Gitlab UsernameAndPassword.new(nil, password) end + + def extract_from_token_param(request) + password = request.query_parameters['token'] + return unless password.present? + + UsernameAndPassword.new(nil, password) + end end end end diff --git a/lib/gitlab/api_authentication/token_resolver.rb b/lib/gitlab/api_authentication/token_resolver.rb index 9234837cdf7..dd9039e37f6 100644 --- a/lib/gitlab/api_authentication/token_resolver.rb +++ b/lib/gitlab/api_authentication/token_resolver.rb @@ -15,9 +15,14 @@ module Gitlab personal_access_token job_token deploy_token + personal_access_token_from_jwt + deploy_token_from_jwt + job_token_from_jwt ] } + UsernameAndPassword = ::Gitlab::APIAuthentication::TokenLocator::UsernameAndPassword + def initialize(token_type) @token_type = token_type validate! @@ -56,6 +61,15 @@ module Gitlab when :deploy_token_with_username resolve_deploy_token_with_username raw + + when :personal_access_token_from_jwt + resolve_personal_access_token_from_jwt raw + + when :deploy_token_from_jwt + resolve_deploy_token_from_jwt raw + + when :job_token_from_jwt + resolve_job_token_from_jwt raw end end @@ -116,6 +130,33 @@ module Gitlab end end + def resolve_personal_access_token_from_jwt(raw) + with_jwt_token(raw) do |jwt_token| + break unless jwt_token['token'].is_a?(Integer) + + pat = ::PersonalAccessToken.find(jwt_token['token']) + break unless pat + + pat + end + end + + def resolve_deploy_token_from_jwt(raw) + with_jwt_token(raw) do |jwt_token| + break unless jwt_token['token'].is_a?(String) + + resolve_deploy_token(UsernameAndPassword.new(nil, jwt_token['token'])) + end + end + + def resolve_job_token_from_jwt(raw) + with_jwt_token(raw) do |jwt_token| + break unless jwt_token['token'].is_a?(String) + + resolve_job_token(UsernameAndPassword.new(nil, jwt_token['token'])) + end + end + def with_personal_access_token(raw, &block) pat = ::PersonalAccessToken.find_by_token(raw.password) return unless pat @@ -136,6 +177,13 @@ module Gitlab yield(job) end + + def with_jwt_token(raw, &block) + jwt_token = ::Gitlab::JWTToken.decode(raw.password) + raise ::Gitlab::Auth::UnauthorizedError unless jwt_token + + yield(jwt_token) + end end end end diff --git a/lib/gitlab/ci/pipeline/metrics.rb b/lib/gitlab/ci/pipeline/metrics.rb index 6cb6fd3920d..35e88ded416 100644 --- a/lib/gitlab/ci/pipeline/metrics.rb +++ b/lib/gitlab/ci/pipeline/metrics.rb @@ -13,6 +13,13 @@ module Gitlab ::Gitlab::Metrics.histogram(name, comment, labels, buckets) end + def self.pipeline_security_orchestration_policy_processing_duration_histogram + name = :gitlab_ci_pipeline_security_orchestration_policy_processing_duration_seconds + comment = 'Pipeline security orchestration policy processing duration' + + ::Gitlab::Metrics.histogram(name, comment) + end + def self.pipeline_size_histogram name = :gitlab_ci_pipeline_size_builds comment = 'Pipeline size' diff --git a/lib/gitlab/jwt_token.rb b/lib/gitlab/jwt_token.rb new file mode 100644 index 00000000000..11bc5479b6e --- /dev/null +++ b/lib/gitlab/jwt_token.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Gitlab + class JWTToken < JSONWebToken::HMACToken + HMAC_ALGORITHM = 'SHA256' + HMAC_KEY = 'gitlab-jwt' + HMAC_EXPIRES_IN = 5.minutes.freeze + + class << self + def decode(jwt) + payload = super(jwt, secret).first + + new.tap do |jwt_token| + jwt_token.id = payload.delete('jti') + jwt_token.issued_at = payload.delete('iat') + jwt_token.not_before = payload.delete('nbf') + jwt_token.expire_time = payload.delete('exp') + + payload.each do |key, value| + jwt_token[key] = value + end + end + rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::ImmatureSignature => ex + # we want to log and return on expired and errored tokens + Gitlab::ErrorTracking.track_exception(ex) + nil + end + + def secret + OpenSSL::HMAC.hexdigest( + HMAC_ALGORITHM, + ::Settings.attr_encrypted_db_key_base, + HMAC_KEY + ) + end + end + + def initialize + super(self.class.secret) + self.expire_time = self.issued_at + HMAC_EXPIRES_IN.to_i + end + + def ==(other) + self.id == other.id && + self.payload == other.payload + end + + def issued_at=(value) + super(convert_time(value)) + end + + def not_before=(value) + super(convert_time(value)) + end + + def expire_time=(value) + super(convert_time(value)) + end + + private + + def convert_time(value) + # JSONWebToken::Token truncates subsecond precision causing comparisons to + # fail unless we truncate it here first + value = value.to_i if value.is_a?(Float) + value = Time.zone.at(value) if value.is_a?(Integer) + value + end + end +end diff --git a/lib/gitlab/terraform_registry_token.rb b/lib/gitlab/terraform_registry_token.rb new file mode 100644 index 00000000000..ae7df49835f --- /dev/null +++ b/lib/gitlab/terraform_registry_token.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + class TerraformRegistryToken < JWTToken + class << self + def from_token(token) + new.tap do |terraform_registry_token| + terraform_registry_token['token'] = token.try(:token).presence || token.try(:id).presence + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1f787192137..702bdf3bf50 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -14071,6 +14071,9 @@ msgstr "" msgid "ForkProject|Internal" msgstr "" +msgid "ForkProject|Please select a namespace" +msgstr "" + msgid "ForkProject|Private" msgstr "" @@ -17971,13 +17974,16 @@ msgstr "" msgid "IssueTracker|Use Redmine as the issue tracker. %{docs_link}" msgstr "" -msgid "IssueTracker|Use a custom issue tracker that is not in the integration list. %{docs_link}" +msgid "IssueTracker|Use YouTrack as this project's issue tracker." msgstr "" -msgid "IssueTracker|Use a custom issue tracker." +msgid "IssueTracker|Use YouTrack as this project's issue tracker. %{docs_link}" msgstr "" -msgid "IssueTracker|YouTrack issue tracker" +msgid "IssueTracker|Use a custom issue tracker that is not in the integration list. %{docs_link}" +msgstr "" + +msgid "IssueTracker|Use a custom issue tracker." msgstr "" msgid "Issues" diff --git a/rubocop/cop/graphql/authorize_types.rb b/rubocop/cop/graphql/authorize_types.rb index 9bfd93aa3db..180a1a27a85 100644 --- a/rubocop/cop/graphql/authorize_types.rb +++ b/rubocop/cop/graphql/authorize_types.rb @@ -8,7 +8,7 @@ module RuboCop 'https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#type-authorization' # We want to exclude our own basetypes and scalars - ALLOWED_TYPES = %w[BaseEnum BaseScalar BasePermissionType MutationType + ALLOWED_TYPES = %w[BaseEnum BaseScalar BasePermissionType MutationType SubscriptionType QueryType GraphQL::Schema BaseUnion BaseInputObject].freeze def_node_search :authorize?, <<~PATTERN diff --git a/spec/factories/gitlab/jwt_token.rb b/spec/factories/gitlab/jwt_token.rb new file mode 100644 index 00000000000..bc00c6a5ff4 --- /dev/null +++ b/spec/factories/gitlab/jwt_token.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :jwt_token, class: 'Gitlab::JWTToken' do + skip_create + + initialize_with { new } + + trait :with_custom_payload do + transient do + custom_payload { {} } + end + + after(:build) do |jwt, evaluator| + evaluator.custom_payload.each do |key, value| + jwt[key] = value + end + end + end + end +end diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb index 2b7ea70fe5a..f60542bd695 100644 --- a/spec/features/projects/fork_spec.rb +++ b/spec/features/projects/fork_spec.rb @@ -6,7 +6,7 @@ RSpec.describe 'Project fork' do include ProjectForksHelper let(:user) { create(:user) } - let(:project) { create(:project, :public, :repository) } + let(:project) { create(:project, :public, :repository, description: 'some description') } before do sign_in(user) diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js index 2992c7f0624..6d853120232 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -1,5 +1,5 @@ -import { GlForm, GlFormInputGroup, GlFormInput } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlFormInputGroup, GlFormInput, GlForm } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; import { kebabCase } from 'lodash'; @@ -43,8 +43,8 @@ describe('ForkForm component', () => { axiosMock.onGet(DEFAULT_PROPS.endpoint).replyOnce(statusCode, data); }; - const createComponent = (props = {}, data = {}) => { - wrapper = shallowMount(ForkForm, { + const createComponentFactory = (mountFn) => (props = {}, data = {}) => { + wrapper = mountFn(ForkForm, { provide: { newGroupPath: 'some/groups/path', visibilityHelpPath: 'some/visibility/help/path', @@ -65,6 +65,9 @@ describe('ForkForm component', () => { }); }; + const createComponent = createComponentFactory(shallowMount); + const createFullComponent = createComponentFactory(mount); + beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); window.gon = { @@ -99,44 +102,6 @@ describe('ForkForm component', () => { expect(cancelButton.attributes('href')).toBe(projectFullPath); }); - it('make POST request with project param', async () => { - jest.spyOn(axios, 'post'); - - const namespaceId = 20; - - mockGetRequest(); - createComponent( - {}, - { - selectedNamespace: { - id: namespaceId, - }, - }, - ); - - wrapper.find(GlForm).vm.$emit('submit', { preventDefault: () => {} }); - - const { - projectId, - projectDescription, - projectName, - projectPath, - projectVisibility, - } = DEFAULT_PROPS; - - const url = `/api/${GON_API_VERSION}/projects/${projectId}/fork`; - const project = { - description: projectDescription, - id: projectId, - name: projectName, - namespace_id: namespaceId, - path: projectPath, - visibility: projectVisibility, - }; - - expect(axios.post).toHaveBeenCalledWith(url, project); - }); - it('has input with csrf token', () => { mockGetRequest(); createComponent(); @@ -258,9 +223,7 @@ describe('ForkForm component', () => { projectVisibility: project, }, { - selectedNamespace: { - visibility: namespace, - }, + form: { fields: { namespace: { value: { visibility: namespace } } } }, }, ); @@ -274,34 +237,101 @@ describe('ForkForm component', () => { describe('onSubmit', () => { beforeEach(() => { jest.spyOn(urlUtility, 'redirectTo').mockImplementation(); + + mockGetRequest(); + createFullComponent( + {}, + { + namespaces: MOCK_NAMESPACES_RESPONSE, + form: { + state: true, + }, + }, + ); }); - it('redirect to POST web_url response', async () => { - const webUrl = `new/fork-project`; + const selectedMockNamespaceIndex = 1; + const namespaceId = MOCK_NAMESPACES_RESPONSE[selectedMockNamespaceIndex].id; - jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } }); + const fillForm = async () => { + const namespaceOptions = findForkUrlInput().findAll('option'); - mockGetRequest(); - createComponent(); + await namespaceOptions.at(selectedMockNamespaceIndex + 1).setSelected(); + }; - await wrapper.vm.onSubmit(); + const submitForm = async () => { + await fillForm(); + const form = wrapper.find(GlForm); - expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl); + await form.trigger('submit'); + await wrapper.vm.$nextTick(); + }; + + describe('with invalid form', () => { + it('does not make POST request', async () => { + jest.spyOn(axios, 'post'); + + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('does not redirect the current page', async () => { + await submitForm(); + + expect(urlUtility.redirectTo).not.toHaveBeenCalled(); + }); }); - it('display flash when POST is unsuccessful', async () => { - const dummyError = 'Fork project failed'; + describe('with valid form', () => { + beforeEach(() => { + fillForm(); + }); - jest.spyOn(axios, 'post').mockRejectedValue(dummyError); + it('make POST request with project param', async () => { + jest.spyOn(axios, 'post'); + + await submitForm(); + + const { + projectId, + projectDescription, + projectName, + projectPath, + projectVisibility, + } = DEFAULT_PROPS; + + const url = `/api/${GON_API_VERSION}/projects/${projectId}/fork`; + const project = { + description: projectDescription, + id: projectId, + name: projectName, + namespace_id: namespaceId, + path: projectPath, + visibility: projectVisibility, + }; - mockGetRequest(); - createComponent(); + expect(axios.post).toHaveBeenCalledWith(url, project); + }); + + it('redirect to POST web_url response', async () => { + const webUrl = `new/fork-project`; + jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } }); + + await submitForm(); + + expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl); + }); + + it('display flash when POST is unsuccessful', async () => { + const dummyError = 'Fork project failed'; + + jest.spyOn(axios, 'post').mockRejectedValue(dummyError); - await wrapper.vm.onSubmit(); + await submitForm(); - expect(urlUtility.redirectTo).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledWith({ - message: dummyError, + expect(urlUtility.redirectTo).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledWith({ + message: dummyError, + }); }); }); }); diff --git a/spec/graphql/graphql_triggers_spec.rb b/spec/graphql/graphql_triggers_spec.rb new file mode 100644 index 00000000000..0b53c633077 --- /dev/null +++ b/spec/graphql/graphql_triggers_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GraphqlTriggers do + describe '.issuable_assignees_updated' do + it 'triggers the issuableAssigneesUpdated subscription' do + assignees = create_list(:user, 2) + issue = create(:issue, assignees: assignees) + + expect(GitlabSchema.subscriptions).to receive(:trigger).with( + 'issuableAssigneesUpdated', + { issuable_id: issue.to_gid }, + issue + ) + + GraphqlTriggers.issuable_assignees_updated(issue) + end + end +end diff --git a/spec/graphql/subscriptions/issuable_updated_spec.rb b/spec/graphql/subscriptions/issuable_updated_spec.rb new file mode 100644 index 00000000000..cc88b37627d --- /dev/null +++ b/spec/graphql/subscriptions/issuable_updated_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Subscriptions::IssuableUpdated do + include GraphqlHelpers + + it { expect(described_class).to have_graphql_arguments(:issuable_id) } + it { expect(described_class.payload_type).to eq(Types::IssuableType) } + + describe '#resolve' do + let_it_be(:unauthorized_user) { create(:user) } + let_it_be(:issue) { create(:issue) } + + let(:current_user) { issue.author } + let(:issuable_id) { issue.to_gid } + + subject { resolver.resolve_with_support(issuable_id: issuable_id) } + + context 'initial subscription' do + let(:resolver) { resolver_instance(described_class, ctx: { current_user: current_user }, subscription_update: false) } + + it 'returns nil' do + expect(subject).to eq(nil) + end + + context 'when user is unauthorized' do + let(:current_user) { unauthorized_user } + + it 'raises an exception' do + expect { subject }.to raise_error(GraphQL::ExecutionError) + end + end + + context 'when issue does not exist' do + let(:issuable_id) { GlobalID.parse("gid://gitlab/Issue/#{non_existing_record_id}") } + + it 'raises an exception' do + expect { subject }.to raise_error(GraphQL::ExecutionError) + end + end + + context 'when a GraphQL::ID_TYPE is provided' do + let(:issuable_id) { issue.to_gid.to_s } + + it 'raises an exception' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + end + end + end + + context 'subscription updates' do + let(:resolver) { resolver_instance(described_class, obj: issue, ctx: { current_user: current_user }, subscription_update: true) } + + it 'returns the resolved object' do + expect(subject).to eq(issue) + end + + context 'when user is unauthorized' do + let(:current_user) { unauthorized_user } + + it 'unsubscribes the user' do + expect { subject }.to throw_symbol(:graphql_subscription_unsubscribed) + end + end + end + end +end diff --git a/spec/graphql/types/issuable_type_spec.rb b/spec/graphql/types/issuable_type_spec.rb new file mode 100644 index 00000000000..992a58f524b --- /dev/null +++ b/spec/graphql/types/issuable_type_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['Issuable'] do + it 'returns possible types' do + expect(described_class.possible_types).to include(Types::IssueType, Types::MergeRequestType) + end + + describe '.resolve_type' do + it 'resolves issues' do + expect(described_class.resolve_type(build(:issue), {})).to eq(Types::IssueType) + end + + it 'resolves merge requests' do + expect(described_class.resolve_type(build(:merge_request), {})).to eq(Types::MergeRequestType) + end + + it 'raises an error for invalid types' do + expect { described_class.resolve_type(build(:user), {}) }.to raise_error 'Unsupported issuable type' + end + end +end diff --git a/spec/graphql/types/subscription_type_spec.rb b/spec/graphql/types/subscription_type_spec.rb new file mode 100644 index 00000000000..b99df374bb3 --- /dev/null +++ b/spec/graphql/types/subscription_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['Subscription'] do + it 'has the expected fields' do + expected_fields = %i[ + issuable_assignees_updated + ] + + expect(described_class).to have_graphql_fields(*expected_fields).only + end +end diff --git a/spec/lib/banzai/pipeline/post_process_pipeline_spec.rb b/spec/lib/banzai/pipeline/post_process_pipeline_spec.rb index d9f45769550..ebe1ca4d403 100644 --- a/spec/lib/banzai/pipeline/post_process_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/post_process_pipeline_spec.rb @@ -3,24 +3,56 @@ require 'spec_helper' RSpec.describe Banzai::Pipeline::PostProcessPipeline do - context 'when a document only has upload links' do - it 'does not make any Gitaly calls', :request_store do - markdown = <<-MARKDOWN.strip_heredoc - [Relative Upload Link](/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg) + subject { described_class.call(doc, context) } + + let_it_be(:project) { create(:project, :public, :repository) } - ![Relative Upload Image](/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg) - MARKDOWN + let(:context) { { project: project, ref: 'master' } } - context = { - project: create(:project, :public, :repository), - ref: 'master' - } + context 'when a document only has upload links' do + let(:doc) do + <<-HTML.strip_heredoc + <a href="/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg">Relative Upload Link</a> + <img src="/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"> + HTML + end + it 'does not make any Gitaly calls', :request_store do Gitlab::GitalyClient.reset_counts - described_class.call(markdown, context) + subject expect(Gitlab::GitalyClient.get_request_count).to eq(0) end end + + context 'when both upload and repository links are present' do + let(:html) do + <<-HTML.strip_heredoc + <a href="/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg">Relative Upload Link</a> + <img src="/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg"> + <a href="/test.jpg">Just a link</a> + HTML + end + + let(:doc) { HTML::Pipeline.parse(html) } + + it 'searches for attributes only once' do + expect(doc).to receive(:search).once.and_call_original + + subject + end + + context 'when "optimize_linkable_attributes" is disabled' do + before do + stub_feature_flags(optimize_linkable_attributes: false) + end + + it 'searches for attributes twice' do + expect(doc).to receive(:search).twice.and_call_original + + subject + end + end + end end diff --git a/spec/lib/gitlab/api_authentication/token_locator_spec.rb b/spec/lib/gitlab/api_authentication/token_locator_spec.rb index e933fd8352e..58e0b9fbc89 100644 --- a/spec/lib/gitlab/api_authentication/token_locator_spec.rb +++ b/spec/lib/gitlab/api_authentication/token_locator_spec.rb @@ -36,7 +36,7 @@ RSpec.describe Gitlab::APIAuthentication::TokenLocator do let(:request) { double(authorization: nil) } it 'returns nil' do - expect(subject).to be(nil) + expect(subject).to be_nil end end @@ -59,7 +59,7 @@ RSpec.describe Gitlab::APIAuthentication::TokenLocator do let(:request) { double(headers: {}) } it 'returns nil' do - expect(subject).to be(nil) + expect(subject).to be_nil end end @@ -72,5 +72,26 @@ RSpec.describe Gitlab::APIAuthentication::TokenLocator do end end end + + context 'with :token_param' do + let(:type) { :token_param } + + context 'without credentials' do + let(:request) { double(query_parameters: {}) } + + it 'returns nil' do + expect(subject).to be_nil + end + end + + context 'with credentials' do + let(:password) { 'bar' } + let(:request) { double(query_parameters: { 'token' => password }) } + + it 'returns the credentials' do + expect(subject.password).to eq(password) + end + end + end end end diff --git a/spec/lib/gitlab/api_authentication/token_resolver_spec.rb b/spec/lib/gitlab/api_authentication/token_resolver_spec.rb index 97a7c8ba7cf..bbc6bf0d481 100644 --- a/spec/lib/gitlab/api_authentication/token_resolver_spec.rb +++ b/spec/lib/gitlab/api_authentication/token_resolver_spec.rb @@ -160,9 +160,58 @@ RSpec.describe Gitlab::APIAuthentication::TokenResolver do it_behaves_like 'an authorized request' end end + + context 'with :personal_access_token_from_jwt' do + let(:type) { :personal_access_token_from_jwt } + let(:token) { personal_access_token } + + context 'with valid credentials' do + let(:raw) { username_and_password_from_jwt(token.id) } + + it_behaves_like 'an authorized request' + end + end + + context 'with :deploy_token_from_jwt' do + let(:type) { :deploy_token_from_jwt } + let(:token) { deploy_token } + + context 'with valid credentials' do + let(:raw) { username_and_password_from_jwt(token.token) } + + it_behaves_like 'an authorized request' + end + end + + context 'with :job_token_from_jwt' do + let(:type) { :job_token_from_jwt } + let(:token) { ci_job } + + context 'with valid credentials' do + let(:raw) { username_and_password_from_jwt(token.token) } + + it_behaves_like 'an authorized request' + end + + context 'when the job is not running' do + let(:raw) { username_and_password_from_jwt(ci_job_done.token) } + + it_behaves_like 'an unauthorized request' + end + + context 'with an invalid job token' do + let(:raw) { username_and_password_from_jwt('not a valid CI job token') } + + it_behaves_like 'an unauthorized request' + end + end end def username_and_password(username, password) ::Gitlab::APIAuthentication::TokenLocator::UsernameAndPassword.new(username, password) end + + def username_and_password_from_jwt(token) + username_and_password(nil, ::Gitlab::JWTToken.new.tap { |jwt| jwt['token'] = token }.encoded) + end end diff --git a/spec/lib/gitlab/ci/pipeline/chain/limit/deployments_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/limit/deployments_spec.rb index 23cdec61bb3..499dc3554a3 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/limit/deployments_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/limit/deployments_spec.rb @@ -74,7 +74,7 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::Deployments do it 'adds an informative error to the pipeline' do perform - expect(pipeline.errors.messages).to include(base: ['Pipeline has too many deployments! Requested 2, but the limit is 1.']) + expect(pipeline.errors.added?(:base, 'Pipeline has too many deployments! Requested 2, but the limit is 1.')).to be true end it 'increments the error metric' do diff --git a/spec/lib/gitlab/jwt_token_spec.rb b/spec/lib/gitlab/jwt_token_spec.rb new file mode 100644 index 00000000000..d89ca127393 --- /dev/null +++ b/spec/lib/gitlab/jwt_token_spec.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::JWTToken do + it_behaves_like 'a gitlab jwt token' +end diff --git a/spec/lib/gitlab/terraform_registry_token_spec.rb b/spec/lib/gitlab/terraform_registry_token_spec.rb new file mode 100644 index 00000000000..49c1c07e942 --- /dev/null +++ b/spec/lib/gitlab/terraform_registry_token_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::TerraformRegistryToken do + let_it_be(:user) { create(:user) } + + describe '.from_token' do + let(:jwt_token) { described_class.from_token(token) } + + subject { described_class.decode(jwt_token.encoded) } + + context 'with a deploy token' do + let(:deploy_token) { create(:deploy_token, username: 'deployer') } + let(:token) { deploy_token } + + it 'returns the correct token' do + expect(subject['token']).to eq jwt_token['token'] + end + end + + context 'with a job' do + let_it_be(:job) { create(:ci_build) } + + let(:token) { job } + + it 'returns the correct token' do + expect(subject['token']).to eq jwt_token['token'] + end + end + + context 'with a personal access token' do + let(:token) { create(:personal_access_token) } + + it 'returns the correct token' do + expect(subject['token']).to eq jwt_token['token'] + end + end + end + + it_behaves_like 'a gitlab jwt token' +end diff --git a/spec/models/concerns/chronic_duration_attribute_spec.rb b/spec/models/concerns/chronic_duration_attribute_spec.rb index e6dbf403b63..00e28e19bd5 100644 --- a/spec/models/concerns/chronic_duration_attribute_spec.rb +++ b/spec/models/concerns/chronic_duration_attribute_spec.rb @@ -56,8 +56,7 @@ RSpec.shared_examples 'ChronicDurationAttribute writer' do subject.send("#{virtual_field}=", '-10m') expect(subject.valid?).to be_falsey - expect(subject.errors&.messages) - .to include(base: ['Maximum job timeout has a value which could not be accepted']) + expect(subject.errors.added?(:base, 'Maximum job timeout has a value which could not be accepted')).to be true end end diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb index e34934d393a..4a8b671bab7 100644 --- a/spec/models/custom_emoji_spec.rb +++ b/spec/models/custom_emoji_spec.rb @@ -38,7 +38,7 @@ RSpec.describe CustomEmoji do new_emoji = build(:custom_emoji, name: old_emoji.name, namespace: old_emoji.namespace, group: group) expect(new_emoji).not_to be_valid - expect(new_emoji.errors.messages).to include(name: ["has already been taken"]) + expect(new_emoji.errors.messages).to eq(creator: ["can't be blank"], name: ["has already been taken"]) end it 'disallows non http and https file value' do diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 05825a4f78c..f55599b3a79 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -579,7 +579,9 @@ RSpec.describe Group do it "is false if avatar is html page" do group.update_attribute(:avatar, 'uploads/avatar.html') - expect(group.avatar_type).to eq(["file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff, ico, webp"]) + group.avatar_type + + expect(group.errors.added?(:avatar, "file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff, ico, webp")).to be true end end diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb index 5b11a7bf079..021f8598975 100644 --- a/spec/models/merge_request_diff_spec.rb +++ b/spec/models/merge_request_diff_spec.rb @@ -23,7 +23,7 @@ RSpec.describe MergeRequestDiff do expect(subject.valid?).to be false expect(subject.errors.count).to eq 3 - expect(subject.errors).to all(include('is not a valid SHA')) + expect(subject.errors.full_messages).to all(include('is not a valid SHA')) end it 'does not validate uniqueness by default' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 8c21b227ad3..dc1c418bea9 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1276,7 +1276,9 @@ RSpec.describe Project, factory_default: :keep do it 'is false if avatar is html page' do project.update_attribute(:avatar, 'uploads/avatar.html') - expect(project.avatar_type).to eq(['file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff, ico, webp']) + project.avatar_type + + expect(project.errors.added?(:avatar, "file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff, ico, webp")).to be true end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 393fd53193b..ff2ee4adc1c 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2526,8 +2526,9 @@ RSpec.describe User do it 'is false if avatar is html page' do user.update_attribute(:avatar, 'uploads/avatar.html') + user.avatar_type - expect(user.avatar_type).to eq(['file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff, ico, webp']) + expect(user.errors.added?(:avatar, "file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff, ico, webp")).to be true end end diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index fd42a84e405..88deeea49e5 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -1014,13 +1014,15 @@ RSpec.describe Issues::UpdateService, :mailer do with_them do it 'broadcasts to the issues channel based on ActionCable and feature flag values' do - expect(Gitlab::ActionCable::Config).to receive(:in_app?).and_return(action_cable_in_app_enabled) + allow(Gitlab::ActionCable::Config).to receive(:in_app?).and_return(action_cable_in_app_enabled) stub_feature_flags(broadcast_issue_updates: feature_flag_enabled) if should_broadcast expect(IssuesChannel).to receive(:broadcast_to).with(issue, event: 'updated') + expect(GraphqlTriggers).to receive(:issuable_assignees_updated).with(issue) else expect(IssuesChannel).not_to receive(:broadcast_to) + expect(GraphqlTriggers).not_to receive(:issuable_assignees_updated).with(issue) end update_issue(update_params) diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb index deeab66c4e9..b7b08390dcd 100644 --- a/spec/services/notes/build_service_spec.rb +++ b/spec/services/notes/build_service_spec.rb @@ -173,7 +173,7 @@ RSpec.describe Notes::BuildService do let(:user) { create(:user) } it 'returns `Discussion to reply to cannot be found` error' do - expect(new_note.errors.first).to include("Discussion to reply to cannot be found") + expect(new_note.errors.added?(:base, "Discussion to reply to cannot be found")).to be true end end end diff --git a/spec/services/packages/debian/generate_distribution_key_service_spec.rb b/spec/services/packages/debian/generate_distribution_key_service_spec.rb new file mode 100644 index 00000000000..b31830c2d3b --- /dev/null +++ b/spec/services/packages/debian/generate_distribution_key_service_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Debian::GenerateDistributionKeyService do + let_it_be(:user) { create(:user) } + + let(:params) { {} } + + subject { described_class.new(current_user: user, params: params) } + + let(:response) { subject.execute } + + context 'with a user' do + it 'returns an Hash', :aggregate_failures do + expect(GPGME::Ctx).to receive(:new).with(armor: true, offline: true).and_call_original + expect(User).to receive(:random_password).with(no_args).and_call_original + + expect(response).to be_a Hash + expect(response.keys).to contain_exactly(:private_key, :public_key, :fingerprint, :passphrase) + expect(response[:private_key]).to start_with('-----BEGIN PGP PRIVATE KEY BLOCK-----') + expect(response[:public_key]).to start_with('-----BEGIN PGP PUBLIC KEY BLOCK-----') + expect(response[:fingerprint].length).to eq(40) + expect(response[:passphrase].length).to be > 10 + end + end + + context 'without a user' do + let(:user) { nil } + + it 'raises an ArgumentError' do + expect { response }.to raise_error(ArgumentError, 'Please provide a user') + end + end +end diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index d714f04fbba..ebf22987d50 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -142,9 +142,9 @@ module GraphqlHelpers Class.new(::Types::BaseObject) { graphql_name name } end - def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema) + def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema, subscription_update: false) if ctx.is_a?(Hash) - q = double('Query', schema: schema) + q = double('Query', schema: schema, subscription_update?: subscription_update) ctx = GraphQL::Query::Context.new(query: q, object: obj, values: ctx) end diff --git a/spec/support/shared_examples/lib/gitlab/jwt_token_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/jwt_token_shared_examples.rb new file mode 100644 index 00000000000..5c92bb3b0d4 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/jwt_token_shared_examples.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a gitlab jwt token' do + let_it_be(:base_secret) { SecureRandom.base64(64) } + + let(:jwt_secret) do + OpenSSL::HMAC.hexdigest( + 'SHA256', + base_secret, + described_class::HMAC_KEY + ) + end + + before do + allow(Settings).to receive(:attr_encrypted_db_key_base).and_return(base_secret) + end + + describe '#secret' do + subject { described_class.secret } + + it { is_expected.to eq(jwt_secret) } + end + + describe '#decode' do + let(:encoded_jwt_token) { jwt_token.encoded } + + subject(:decoded_jwt_token) { described_class.decode(encoded_jwt_token) } + + context 'with a custom payload' do + let(:personal_access_token) { create(:personal_access_token) } + let(:jwt_token) { described_class.new.tap { |jwt_token| jwt_token['token'] = personal_access_token.token } } + + it 'returns the correct token' do + expect(decoded_jwt_token['token']).to eq jwt_token['token'] + end + + it 'returns nil and logs the exception after expiration' do + travel_to((described_class::HMAC_EXPIRES_IN + 1.minute).ago) do + encoded_jwt_token + end + + expect(Gitlab::ErrorTracking).to receive(:track_exception) + .with(instance_of(JWT::ExpiredSignature)) + + expect(decoded_jwt_token).to be_nil + end + end + end +end diff --git a/spec/validators/addressable_url_validator_spec.rb b/spec/validators/addressable_url_validator_spec.rb index 394ffc7bbea..ec3ee9aa500 100644 --- a/spec/validators/addressable_url_validator_spec.rb +++ b/spec/validators/addressable_url_validator_spec.rb @@ -19,18 +19,20 @@ RSpec.describe AddressableUrlValidator do it 'returns error when url is nil' do expect(validator.validate_each(badge, :link_url, nil)).to be_falsey - expect(badge.errors.first[1]).to eq validator.options.fetch(:message) + expect(badge.errors.added?(:link_url, validator.options.fetch(:message))).to be true end it 'returns error when url is empty' do expect(validator.validate_each(badge, :link_url, '')).to be_falsey - expect(badge.errors.first[1]).to eq validator.options.fetch(:message) + expect(badge.errors.added?(:link_url, validator.options.fetch(:message))).to be true end it 'does not allow urls with CR or LF characters' do aggregate_failures do urls_with_CRLF.each do |url| - expect(validator.validate_each(badge, :link_url, url)[0]).to eq 'is blocked: URI is invalid' + validator.validate_each(badge, :link_url, url) + + expect(badge.errors.added?(:link_url, 'is blocked: URI is invalid')).to be true end end end @@ -113,7 +115,7 @@ RSpec.describe AddressableUrlValidator do it 'does block nil url with provided error message' do expect(validator.validate_each(badge, :link_url, nil)).to be_falsey - expect(badge.errors.first[1]).to eq message + expect(badge.errors.added?(:link_url, message)).to be true end end @@ -126,7 +128,7 @@ RSpec.describe AddressableUrlValidator do subject - expect(badge.errors.first[1]).to eq 'is not allowed due to: Only allowed schemes are http, https' + expect(badge.errors.added?(:link_url, 'is not allowed due to: Only allowed schemes are http, https')).to be true end end diff --git a/spec/validators/array_members_validator_spec.rb b/spec/validators/array_members_validator_spec.rb index ff8f0da7651..c6960925487 100644 --- a/spec/validators/array_members_validator_spec.rb +++ b/spec/validators/array_members_validator_spec.rb @@ -49,7 +49,7 @@ RSpec.describe ArrayMembersValidator do object = test_class.new(children: []) expect(object.valid?).to be_falsey - expect(object.errors.messages).to eql(children: ['should be an array of children objects']) + expect(object.errors.messages).to eq(children: ['should be an array of children objects']) end end @@ -62,7 +62,7 @@ RSpec.describe ArrayMembersValidator do object = test_class.new(children: []) expect(object.valid?).to be_falsey - expect(object.errors.messages).to eql(children: ['should be an array of test objects']) + expect(object.errors.messages).to eq(children: ['should be an array of test objects']) end end end diff --git a/spec/validators/devise_email_validator_spec.rb b/spec/validators/devise_email_validator_spec.rb index 29a008f858a..64d11d4d963 100644 --- a/spec/validators/devise_email_validator_spec.rb +++ b/spec/validators/devise_email_validator_spec.rb @@ -23,7 +23,7 @@ RSpec.describe DeviseEmailValidator do subject expect(user.errors).to be_present - expect(user.errors.first[1]).to eq 'is invalid' + expect(user.errors.added?(:public_email)).to be true end it 'returns error when email is nil' do @@ -40,7 +40,7 @@ RSpec.describe DeviseEmailValidator do subject expect(user.errors).to be_present - expect(user.errors.first[1]).to eq 'is invalid' + expect(user.errors.added?(:public_email)).to be true end end end diff --git a/spec/validators/gitlab/utils/zoom_url_validator_spec.rb b/spec/validators/gitlab/utils/zoom_url_validator_spec.rb index bc8236a2f5c..392d8b3a2fe 100644 --- a/spec/validators/gitlab/utils/zoom_url_validator_spec.rb +++ b/spec/validators/gitlab/utils/zoom_url_validator_spec.rb @@ -21,7 +21,7 @@ RSpec.describe Gitlab::Utils::ZoomUrlValidator do expect(zoom_meeting.valid?).to eq(false) expect(zoom_meeting.errors).to be_present - expect(zoom_meeting.errors.first[1]).to eq 'must contain one valid Zoom URL' + expect(zoom_meeting.errors.added?(:url, 'must contain one valid Zoom URL')).to be true end end diff --git a/spec/validators/qualified_domain_array_validator_spec.rb b/spec/validators/qualified_domain_array_validator_spec.rb index 865ecffe05a..b2b13d358c4 100644 --- a/spec/validators/qualified_domain_array_validator_spec.rb +++ b/spec/validators/qualified_domain_array_validator_spec.rb @@ -52,7 +52,7 @@ RSpec.describe QualifiedDomainArrayValidator do subject expect(record.errors).to be_present - expect(record.errors.first[1]).to eq('entries cannot be nil') + expect(record.errors.added?(:domain_array, "entries cannot be nil")).to be true end it 'allows when domain is valid' do @@ -67,7 +67,7 @@ RSpec.describe QualifiedDomainArrayValidator do subject expect(record.errors).to be_present - expect(record.errors.first[1]).to eq 'unicode domains should use IDNA encoding' + expect(record.errors.added?(:domain_array, 'unicode domains should use IDNA encoding')).to be true end it 'returns error when entry is larger than 255 chars' do @@ -76,7 +76,7 @@ RSpec.describe QualifiedDomainArrayValidator do subject expect(record.errors).to be_present - expect(record.errors.first[1]).to eq 'entries cannot be larger than 255 characters' + expect(record.errors.added?(:domain_array, 'entries cannot be larger than 255 characters')).to be true end it 'returns error when entry contains HTML tags' do @@ -85,7 +85,7 @@ RSpec.describe QualifiedDomainArrayValidator do subject expect(record.errors).to be_present - expect(record.errors.first[1]).to eq 'entries cannot contain HTML tags' + expect(record.errors.added?(:domain_array, 'entries cannot contain HTML tags')).to be true end end |