diff options
87 files changed, 1570 insertions, 557 deletions
diff --git a/.rubocop.yml b/.rubocop.yml index d52c1784aae..85c9b74b6a6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -568,6 +568,19 @@ RSpec/FactoryBot/AvoidCreate: - 'ee/spec/routes/directs/*.rb' - 'ee/spec/lib/sidebars/**/*.rb' +RSpec/BeforeAll: + Enabled: true + Include: + - 'spec/**/*.rb' + - 'ee/spec/**/*.rb' + # Conflict with RSpec/AvoidTestProf + Exclude: + - 'spec/migrations/**/*.rb' + - 'ee/spec/migrations/**/*.rb' + - 'spec/lib/gitlab/background_migration/**/*.rb' + - 'ee/spec/lib/gitlab/background_migration/**/*.rb' + - 'ee/spec/lib/ee/gitlab/background_migration/**/*.rb' + RSpec/FactoryBot/StrategyInCallback: Enabled: true Include: diff --git a/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml b/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml index 62bf63d1bb9..53954249ed4 100644 --- a/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml +++ b/.rubocop_todo/layout/line_end_string_concatenation_indentation.yml @@ -187,6 +187,7 @@ Layout/LineEndStringConcatenationIndentation: - 'lib/gitlab/path_regex.rb' - 'lib/gitlab/reference_counter.rb' - 'lib/gitlab/regex.rb' + - 'lib/gitlab/regex/bulk_imports.rb' - 'lib/gitlab/seeders/ci/runner/runner_fleet_seeder.rb' - 'lib/gitlab/slash_commands/presenters/run.rb' - 'lib/gitlab/tracking/standard_context.rb' diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml index f483c9ac5f2..797ae5068af 100644 --- a/.rubocop_todo/layout/line_length.yml +++ b/.rubocop_todo/layout/line_length.yml @@ -2821,6 +2821,8 @@ Layout/LineLength: - 'lib/gitlab/quick_actions/merge_request_actions.rb' - 'lib/gitlab/rack_attack.rb' - 'lib/gitlab/regex.rb' + - 'lib/gitlab/regex/bulk_imports.rb' + - 'lib/gitlab/regex/packages.rb' - 'lib/gitlab/relative_positioning/item_context.rb' - 'lib/gitlab/repository_size_error_message.rb' - 'lib/gitlab/sample_data_template.rb' diff --git a/.rubocop_todo/rspec/before_all.yml b/.rubocop_todo/rspec/before_all.yml new file mode 100644 index 00000000000..faffd1d1119 --- /dev/null +++ b/.rubocop_todo/rspec/before_all.yml @@ -0,0 +1,64 @@ +--- +# Cop supports --autocorrect. +RSpec/BeforeAll: + Details: grace period + Exclude: + - 'spec/finders/packages/go/version_finder_spec.rb' + - 'spec/helpers/users_helper_spec.rb' + - 'spec/lib/backup/database_spec.rb' + - 'spec/lib/gitlab/analytics/cycle_analytics/records_fetcher_spec.rb' + - 'spec/lib/gitlab/ci/artifacts/decompressed_artifact_size_validator_spec.rb' + - 'spec/lib/gitlab/ci/components/instance_path_spec.rb' + - 'spec/lib/gitlab/ci/decompressed_gzip_size_validator_spec.rb' + - 'spec/lib/gitlab/data_builder/deployment_spec.rb' + - 'spec/lib/gitlab/database/load_balancing_spec.rb' + - 'spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb' + - 'spec/lib/gitlab/database/tables_locker_spec.rb' + - 'spec/lib/gitlab/git/rugged_impl/use_rugged_spec.rb' + - 'spec/lib/gitlab/graphql/pagination/connections_spec.rb' + - 'spec/lib/gitlab/http_spec.rb' + - 'spec/lib/gitlab/import_export/decompressed_archive_size_validator_spec.rb' + - 'spec/lib/gitlab/usage/metrics/instrumentations/count_bulk_imports_entities_metric_spec.rb' + - 'spec/lib/unnested_in_filters/rewriter_spec.rb' + - 'spec/migrations/add_namespaces_emails_enabled_column_data_spec.rb' + - 'spec/migrations/add_projects_emails_enabled_column_data_spec.rb' + - 'spec/models/ci/commit_with_pipeline_spec.rb' + - 'spec/models/concerns/bulk_insert_safe_spec.rb' + - 'spec/models/concerns/bulk_insertable_associations_spec.rb' + - 'spec/models/concerns/token_authenticatable_spec.rb' + - 'spec/models/milestone_spec.rb' + - 'spec/models/postgresql/replication_slot_spec.rb' + - 'spec/rack_servers/puma_spec.rb' + - 'spec/requests/api/composer_packages_spec.rb' + - 'spec/requests/api/graphql/mutations/work_items/create_spec.rb' + - 'spec/requests/api/graphql/mutations/work_items/update_spec.rb' + - 'spec/requests/api/users_spec.rb' + - 'spec/scripts/lib/glfm/update_example_snapshots_spec.rb' + - 'spec/services/bulk_imports/relation_batch_export_service_spec.rb' + - 'spec/services/loose_foreign_keys/batch_cleaner_service_spec.rb' + - 'spec/services/loose_foreign_keys/process_deleted_records_service_spec.rb' + - 'spec/services/packages/composer/create_package_service_spec.rb' + - 'spec/services/packages/go/create_package_service_spec.rb' + - 'spec/services/work_items/widgets/hierarchy_service/create_service_spec.rb' + - 'spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb' + - 'spec/support_specs/helpers/stub_feature_flags_spec.rb' + - 'spec/tasks/gitlab/backup_rake_spec.rb' + - 'spec/tasks/gitlab/ci_secure_files/migrate_rake_spec.rb' + - 'spec/tasks/gitlab/container_registry_rake_spec.rb' + - 'spec/tasks/gitlab/db/decomposition/connection_status_rake_spec.rb' + - 'spec/tasks/gitlab/db/decomposition/rollback/bump_ci_sequences_rake_spec.rb' + - 'spec/tasks/gitlab/db/lock_writes_rake_spec.rb' + - 'spec/tasks/gitlab/db/migration_fix_15_11_rake_spec.rb' + - 'spec/tasks/gitlab/db/truncate_legacy_tables_rake_spec.rb' + - 'spec/tasks/gitlab/db/validate_config_rake_spec.rb' + - 'spec/tasks/gitlab/db_rake_spec.rb' + - 'spec/tasks/gitlab/dependency_proxy/migrate_rake_spec.rb' + - 'spec/tasks/gitlab/gitaly_rake_spec.rb' + - 'spec/tasks/gitlab/lfs/migrate_rake_spec.rb' + - 'spec/tasks/gitlab/packages/migrate_rake_spec.rb' + - 'spec/tasks/gitlab/snippets_rake_spec.rb' + - 'spec/tasks/gitlab/terraform/migrate_rake_spec.rb' + - 'spec/tasks/gitlab/workhorse_rake_spec.rb' + - 'spec/tasks/gitlab/x509/update_rake_spec.rb' + - 'spec/tasks/migrate/schema_check_rake_spec.rb' + - 'spec/workers/loose_foreign_keys/cleanup_worker_spec.rb' diff --git a/.rubocop_todo/style/percent_literal_delimiters.yml b/.rubocop_todo/style/percent_literal_delimiters.yml index bb29652fe96..e6eb7f2a47a 100644 --- a/.rubocop_todo/style/percent_literal_delimiters.yml +++ b/.rubocop_todo/style/percent_literal_delimiters.yml @@ -505,6 +505,7 @@ Style/PercentLiteralDelimiters: - 'lib/gitlab/query_limiting/transaction.rb' - 'lib/gitlab/reference_extractor.rb' - 'lib/gitlab/regex.rb' + - 'lib/gitlab/regex/bulk_imports.rb' - 'lib/gitlab/sanitizers/exception_message.rb' - 'lib/gitlab/sanitizers/exif.rb' - 'lib/gitlab/search/abuse_detection.rb' diff --git a/.rubocop_todo/style/redundant_freeze.yml b/.rubocop_todo/style/redundant_freeze.yml index 0877c752827..b524d03d94f 100644 --- a/.rubocop_todo/style/redundant_freeze.yml +++ b/.rubocop_todo/style/redundant_freeze.yml @@ -187,6 +187,7 @@ Style/RedundantFreeze: - 'lib/gitlab/rack_attack/request.rb' - 'lib/gitlab/redis/hll.rb' - 'lib/gitlab/regex.rb' + - 'lib/gitlab/regex/packages.rb' - 'lib/gitlab/robots_txt/parser.rb' - 'lib/gitlab/saas.rb' - 'lib/gitlab/sanitizers/exception_message.rb' diff --git a/.rubocop_todo/style/redundant_regexp_escape.yml b/.rubocop_todo/style/redundant_regexp_escape.yml index b1ab72bac61..7d10d3e20b3 100644 --- a/.rubocop_todo/style/redundant_regexp_escape.yml +++ b/.rubocop_todo/style/redundant_regexp_escape.yml @@ -70,6 +70,7 @@ Style/RedundantRegexpEscape: - 'lib/gitlab/quick_actions/extractor.rb' - 'lib/gitlab/quick_actions/timeline_text_and_date_time_separator.rb' - 'lib/gitlab/regex.rb' + - 'lib/gitlab/regex/packages.rb' - 'lib/gitlab/search/abuse_detection.rb' - 'lib/gitlab/task_helpers.rb' - 'lib/gitlab/url_sanitizer.rb' @@ -380,7 +380,7 @@ gem 'prometheus-client-mmap', '~> 0.26', '>= 0.26.1', require: 'prometheus/clien gem 'warning', '~> 1.3.0' group :development do - gem 'lefthook', '~> 1.4.6', require: false + gem 'lefthook', '~> 1.4.7', require: false gem 'rubocop' gem 'solargraph', '~> 0.47.2', require: false diff --git a/Gemfile.checksum b/Gemfile.checksum index 3175354b3de..f0f8e77b9c4 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -331,7 +331,7 @@ {"name":"kramdown-parser-gfm","version":"1.1.0","platform":"ruby","checksum":"fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729"}, {"name":"kubeclient","version":"4.11.0","platform":"ruby","checksum":"4985fcd749fb8c364a668a8350a49821647f03aa52d9ee6cbc582beb8e883fcc"}, {"name":"launchy","version":"2.5.0","platform":"ruby","checksum":"954243c4255920982ce682f89a42e76372dba94770bf09c23a523e204bdebef5"}, -{"name":"lefthook","version":"1.4.6","platform":"ruby","checksum":"021dd351f03b1fb0024e38454c1ab680c6ea689861612317b6c427c3680e62c5"}, +{"name":"lefthook","version":"1.4.7","platform":"ruby","checksum":"22be305995a871eaad50fe0271457190ff35f929c07705e81076fc91af1f5584"}, {"name":"letter_opener","version":"1.7.0","platform":"ruby","checksum":"095bc0d58e006e5b43ea7d219e64ecf2de8d1f7d9dafc432040a845cf59b4725"}, {"name":"letter_opener_web","version":"2.0.0","platform":"ruby","checksum":"33860ad41e1785d75456500e8ca8bba8ed71ee6eaf08a98d06bbab67c5577b6f"}, {"name":"libyajl2","version":"1.2.0","platform":"ruby","checksum":"1117cd1e48db013b626e36269bbf1cef210538ca6d2e62d3fa3db9ded005b258"}, diff --git a/Gemfile.lock b/Gemfile.lock index 1ba14709bd3..3e03bcfc095 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -936,7 +936,7 @@ GEM rest-client (~> 2.0) launchy (2.5.0) addressable (~> 2.7) - lefthook (1.4.6) + lefthook (1.4.7) letter_opener (1.7.0) launchy (~> 2.2) letter_opener_web (2.0.0) @@ -1879,7 +1879,7 @@ DEPENDENCIES knapsack (~> 1.21.1) kramdown (~> 2.3.1) kubeclient (~> 4.11.0) - lefthook (~> 1.4.6) + lefthook (~> 1.4.7) letter_opener_web (~> 2.0.0) license_finder (~> 7.0) licensee (~> 9.15) diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 1c2be99b393..b427820144d 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -57,7 +57,7 @@ export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', { anchor: 'enable-automatic-dast-run', }); -export const DAST_BADGE_TEXT = __('Available on-demand'); +export const DAST_BADGE_TEXT = __('Available on demand'); export const DAST_BADGE_TOOLTIP = __( 'On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects', ); diff --git a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue index 9e77a1d44f4..e723505f01f 100644 --- a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue +++ b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue @@ -16,8 +16,8 @@ import { TYPENAME_USER } from '~/graphql_shared/constants'; import searchUsersQuery from '~/issues/list/queries/search_users.query.graphql'; import searchLabelsQuery from '~/issues/list/queries/search_labels.query.graphql'; import searchMilestonesQuery from '~/issues/list/queries/search_milestones.query.graphql'; -import getServiceDeskIssuesQuery from '../queries/get_service_desk_issues.query.graphql'; -import getServiceDeskIssuesCounts from '../queries/get_service_desk_issues_counts.query.graphql'; +import getServiceDeskIssuesQuery from 'ee_else_ce/service_desk/queries/get_service_desk_issues.query.graphql'; +import getServiceDeskIssuesCounts from 'ee_else_ce/service_desk/queries/get_service_desk_issues_counts.query.graphql'; import { errorFetchingCounts, errorFetchingIssues, @@ -59,6 +59,8 @@ export default { 'releasesPath', 'autocompleteAwardEmojisPath', 'hasIterationsFeature', + 'hasIssueWeightsFeature', + 'hasIssuableHealthStatusFeature', 'groupPath', 'emptyStateSvgPath', 'isProject', @@ -67,6 +69,13 @@ export default { 'isServiceDeskSupported', 'hasAnyIssues', ], + props: { + eeSearchTokens: { + type: Array, + required: false, + default: () => [], + }, + }, data() { return { serviceDeskIssues: [], @@ -201,6 +210,10 @@ export default { }); } + if (this.eeSearchTokens.length) { + tokens.push(...this.eeSearchTokens); + } + tokens.sort((a, b) => a.title.localeCompare(b.title)); return tokens; diff --git a/app/assets/javascripts/service_desk/index.js b/app/assets/javascripts/service_desk/index.js index bcb5093e401..440f7748461 100644 --- a/app/assets/javascripts/service_desk/index.js +++ b/app/assets/javascripts/service_desk/index.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { parseBoolean } from '~/lib/utils/common_utils'; +import ServiceDeskListApp from 'ee_else_ce/service_desk/components/service_desk_list_app.vue'; import { gqlClient } from './graphql'; -import ServiceDeskListApp from './components/service_desk_list_app.vue'; export async function mountServiceDeskListApp() { const el = document.querySelector('.js-service-desk-list'); diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue index efd93e88fa9..28e50dceb48 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue @@ -36,7 +36,7 @@ export default { <style scoped> .fake-input { - top: 12px; - left: 33px; + top: 18px; + left: 39px; } </style> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue index bec8c191b31..a91e41585a8 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue @@ -290,10 +290,10 @@ export default { <form role="search" :aria-label="searchPlaceholder" - class="gl-relative gl-rounded-base gl-w-full" + class="gl-relative gl-rounded-base gl-w-full gl-pb-0" data-testid="global-search-form" > - <div class="gl-p-1 gl-relative"> + <div class="gl-relative gl-bg-white gl-border-b gl-mb-n1 gl-p-3"> <gl-search-box-by-type id="search" ref="searchInput" @@ -347,7 +347,7 @@ export default { <div ref="resultsList" data-testid="global-search-results" - class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2" + class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-3" @keydown="onKeydown" > <command-palette-items diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue index cd623200b03..2686d86732e 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue @@ -23,9 +23,6 @@ export default { computed: { ...mapState(['search', 'loading', 'autocompleteError']), ...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']), - isPrecededByScopedOptions() { - return this.scopedSearchOptions.length > 1; - }, }, methods: { highlightedName(val) { @@ -40,9 +37,9 @@ export default { <div> <ul v-if="!loading" class="gl-m-0 gl-p-0 gl-list-style-none"> <gl-disclosure-dropdown-group - v-for="group in autocompleteGroupedSearchOptions" + v-for="(group, index) in autocompleteGroupedSearchOptions" :key="group.name" - :class="{ 'gl-mt-0!': !isPrecededByScopedOptions }" + :class="{ 'gl-mt-0!': index === 0 }" :group="group" bordered > diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index 37df45a72a4..a4d50466f8f 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -5,6 +5,7 @@ import eventHub from '~/notes/event_hub'; import languageLoader from '~/content_editor/services/highlight_js_language_loader'; import addBlobLinksTracking from '~/blob/blob_links_tracking'; import Tracking from '~/tracking'; +import axios from '~/lib/utils/axios_utils'; import { EVENT_ACTION, EVENT_LABEL_VIEWER, @@ -53,6 +54,11 @@ export default { }; }, computed: { + isLfsBlob() { + const { storedExternally, externalStorage, simpleViewer } = this.blob; + + return storedExternally && externalStorage === 'lfs' && simpleViewer?.fileType === 'text'; + }, splitContent() { return this.content.split(/\r?\n/); }, @@ -83,6 +89,15 @@ export default { }, }, async created() { + if (this.isLfsBlob) { + await axios + .get(this.blob.externalStorageUrl || this.blob.rawPath) + .then((result) => { + this.content = result.data; + }) + .catch(() => this.$emit('error')); + } + addBlobLinksTracking(); this.trackEvent(EVENT_LABEL_VIEWER); diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 12801b272e8..2586f544d94 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -294,8 +294,8 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } .search-scope-help { - top: 0.625rem; - right: 2.5rem; + top: 1rem; + right: 3rem; } .gl-search-box-by-type-input-borderless { @@ -304,5 +304,14 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; .global-search-results { max-height: 30rem; + + .gl-new-dropdown-item { + @include gl-px-3; + } + + // Target groups + [id*='gl-disclosure-dropdown-group'] { + @include gl-px-5; + } } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 005fbc8b058..2722893d04c 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -222,6 +222,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .discussion-reply-holder { border: 1px solid $border-color; + background-color: $white; } } diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 06516a555ef..6aca2b5646c 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -87,7 +87,7 @@ } .settings-section, -.settings-section-no-bottom + .settings-section { +.settings-section-no-bottom ~ .settings-section { @include gl-pt-0; } @@ -95,7 +95,7 @@ @include gl-pt-6; } -.settings-section:not(.settings-section-no-bottom) + .settings-section { +.settings-section:not(.settings-section-no-bottom) ~ .settings-section { @include gl-border-t; } diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb index 53dd06ce638..bd6f4ea84ca 100644 --- a/app/controllers/concerns/integrations/params.rb +++ b/app/controllers/concerns/integrations/params.rb @@ -102,10 +102,14 @@ module Integrations param_values = return_value[:integration] if param_values.is_a?(ActionController::Parameters) - if %w[update test].include?(action_name) && integration.chat? && - param_values['webhook'] == BaseChatNotification::SECRET_MASK + if %w[update test].include?(action_name) && integration.chat? + param_values.delete('webhook') if param_values['webhook'] == BaseChatNotification::SECRET_MASK - param_values.delete('webhook') + if integration.try(:mask_configurable_channels?) + integration.event_channel_names.each do |channel| + param_values.delete(channel) if param_values[channel] == BaseChatNotification::SECRET_MASK + end + end end integration.secret_fields.each do |param| diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index c955032308c..e6e8cfc4020 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -60,7 +60,7 @@ class GraphqlController < ApplicationController urgency :low, [:execute] def execute - result = if Feature.enabled?(:cache_introspection_query) && introspection_query? + result = if introspection_query? execute_introspection_query else multiplex? ? execute_multiplex : execute_query diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index 7213bd074fc..af0f1bd6808 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -2,6 +2,7 @@ module EmailsHelper include AppearancesHelper + include SafeFormatHelper # Google Actions # https://developers.google.com/gmail/markup/reference/go-to-action @@ -236,6 +237,44 @@ module EmailsHelper end end + def member_about_to_expire_text(member_source, days_to_expire, format: nil) + days_formatted = pluralize(days_to_expire, 'day') + + case member_source + when Project + url = project_url(member_source) + when Group + url = group_url(member_source) + end + + case format + when :html + link_to = generate_link(member_source.human_name, url).html_safe + safe_format(_("Your membership in %{link_to} %{project_or_group_name} will expire in %{days_formatted}."), link_to: link_to, project_or_group_name: member_source.model_name.singular, days_formatted: days_formatted) + else + _("Your membership in %{project_or_group} %{project_or_group_name} will expire in %{days_formatted}.") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular, days_formatted: days_formatted } + end + end + + def member_about_to_expire_link(member, member_source, format: nil) + project_or_group = member_source.human_name + + case member_source + when Project + url = project_project_members_url(member_source, search: member.user.username) + when Group + url = group_group_members_url(member_source, search: member.user.username) + end + + case format + when :html + link_to = generate_link("#{member_source.class.name.downcase} membership", url).html_safe + safe_format(_('For additional information, review your %{link_to} or contact your %{project_or_group} owner.'), link_to: link_to, project_or_group: project_or_group) + else + _('For additional information, review your %{project_or_group} membership: %{url} or contact your %{project_or_group} owner.') % { project_or_group: project_or_group, url: url } + end + end + def group_membership_expiration_changed_text(member, group) if member.expires? days = (member.expires_at - Date.today).to_i diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 33c955f94ee..221d359c8c6 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -133,6 +133,22 @@ module Emails subject: subject(subject)) end + def member_about_to_expire_email(member_source_type, member_id) + @member_source_type = member_source_type + @member_id = member_id + + return unless member_exists? + return unless member.expires_at + + @days_to_expire = (member.expires_at - Date.today).to_i + + return if @days_to_expire <= 0 + + email_with_layout( + to: member.user.notification_email_for(notification_group), + subject: subject(s_("Your membership will expire in %{days_to_expire} days") % { days_to_expire: @days_to_expire })) + end + # rubocop: disable CodeReuse/ActiveRecord def member @member ||= Member.find_by(id: @member_id) diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 93d4625c344..4c6ae930cc5 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -166,6 +166,13 @@ class NotifyPreview < ActionMailer::Preview Notify.member_invited_email('project', member.id, '1234').message end + def member_about_to_expire_email + cleanup do + member = project.add_member(user, Gitlab::Access::GUEST, expires_at: 7.days.from_now.to_date) + Notify.member_about_to_expire_email('project', member.id).message + end + end + def pages_domain_enabled_email cleanup do pages_domain = PagesDomain.new(domain: 'my.example.com', project: project, verified_at: Time.now, enabled_until: 1.week.from_now) diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index c9de4d2b3bb..7140e57961f 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -23,7 +23,6 @@ module Integrations ].freeze SECRET_MASK = '************' - CHANNEL_LIMIT_PER_EVENT = 10 attribute :category, default: 'chat' @@ -186,6 +185,14 @@ module Integrations true end + def channel_limit_per_event + 10 + end + + def mask_configurable_channels? + false + end + private def should_execute?(object_kind) @@ -314,13 +321,13 @@ module Integrations def validate_channel_limit supported_events.each do |event| count = channels_for_event(event).count - next unless count > CHANNEL_LIMIT_PER_EVENT + next unless count > channel_limit_per_event errors.add( event_channel_name(event).to_sym, format( s_('SlackIntegration|cannot have more than %{limit} channels'), - limit: CHANNEL_LIMIT_PER_EVENT + limit: channel_limit_per_event ) ) end diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index 061c491034d..99072179c8c 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -10,7 +10,7 @@ module Integrations field :webhook, section: SECTION_TYPE_CONNECTION, - help: 'e.g. https://discordapp.com/api/webhooks/…', + help: 'e.g. https://discord.com/api/webhooks/…', required: true field :notify_only_broken_pipelines, @@ -45,7 +45,7 @@ module Integrations end def default_channel_placeholder - # No-op. + s_('DiscordService|Override the default webhook (e.g. https://discord.com/api/webhooks/…)') end def self.supported_events @@ -72,10 +72,23 @@ module Integrations ] end + def configurable_channels? + true + end + + def channel_limit_per_event + 1 + end + + def mask_configurable_channels? + true + end + private def notify(message, opts) - client = Discordrb::Webhooks::Client.new(url: webhook) + webhook_url = opts[:channel]&.first || webhook + client = Discordrb::Webhooks::Client.new(url: webhook_url) client.execute do |builder| builder.add_embed do |embed| diff --git a/app/models/member.rb b/app/models/member.rb index f164ea244b4..cdf40eaa8f5 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -153,6 +153,7 @@ class Member < ApplicationRecord scope :not_accepted_invitations, -> { invite.where(invite_accepted_at: nil) } scope :not_accepted_invitations_by_user, -> (user) { not_accepted_invitations.where(created_by: user) } scope :not_expired, -> (today = Date.current) { where(arel_table[:expires_at].gt(today).or(arel_table[:expires_at].eq(nil))) } + scope :expiring_and_not_notified, ->(date) { where("expiry_notified_at is null AND expires_at >= ? AND expires_at <= ?", Date.current, date) } scope :created_today, -> do now = Date.current diff --git a/app/models/ml/model_version.rb b/app/models/ml/model_version.rb index 4bf37e228ab..6d0e7c35865 100644 --- a/app/models/ml/model_version.rb +++ b/app/models/ml/model_version.rb @@ -5,7 +5,7 @@ module Ml validates :project, :model, presence: true validates :version, - format: Gitlab::Regex.ml_model_version_regex, + format: Gitlab::Regex.semver_regex, uniqueness: { scope: [:project, :model_id] }, presence: true, length: { maximum: 255 } diff --git a/app/serializers/integrations/event_entity.rb b/app/serializers/integrations/event_entity.rb index 1cbd6114581..f7cac23f30c 100644 --- a/app/serializers/integrations/event_entity.rb +++ b/app/serializers/integrations/event_entity.rb @@ -23,7 +23,10 @@ module Integrations integration.event_channel_name(event) end expose :value do |event| - integration.event_channel_value(event) + value = integration.event_channel_value(event) + next BaseChatNotification::SECRET_MASK if value.present? && integration.mask_configurable_channels? + + value end expose :placeholder do |_event| integration.default_channel_placeholder diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index b2c0fffc12d..3a3d0e53aae 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -36,6 +36,7 @@ module Members member.attributes = params return unless member.changed? + member.expiry_notified_at = nil if member.expires_at_changed? member.tap(&:save!) end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index d305c8c03cf..ceafebddfcf 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -529,6 +529,12 @@ class NotificationService mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later end + def member_about_to_expire(member) + return true unless member.notifiable?(:mention) + + mailer.member_about_to_expire_email(member.real_source_type, member.id).deliver_later + end + # Group invite def invite_group_member(group_member, token) mailer.member_invited_email(group_member.real_source_type, group_member.id, token).deliver_later diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index 472ba2f84a0..4979f7e28e7 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -1,49 +1,49 @@ -.gl-border-b.gl-pb-3.gl-mb-6 - .row - .col-lg-4 - %h4.gl-mt-0 +.settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 = s_('AdminUsers|Access') - .col-lg-8 - .form-group.gl-form-group{ role: 'group' } - = f.label :projects_limit, class: 'gl-display-block col-form-label' - = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input' - - .form-group.gl-form-group{ role: 'group' } - = f.gitlab_ui_checkbox_component :can_create_group, s_('AdminUsers|Can create group') - = f.gitlab_ui_checkbox_component :private_profile, s_('AdminUsers|Private profile') - - %fieldset.form-group.gl-form-group - %legend.col-form-label.col-form-label - = s_('AdminUsers|Access level') - - editing_current_user = (current_user == @user) - - = f.gitlab_ui_radio_component :access_level, :regular, - s_('AdminUsers|Regular'), - radio_options: { disabled: editing_current_user }, - help_text: s_('AdminUsers|Regular users have access to their groups and projects.') - - = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user - - - help_text = s_('AdminUsers|The user has unlimited access to all groups, projects, users, and features.') - - help_text += ' ' + s_('AdminUsers|You cannot remove your own administrator access.') if editing_current_user - = f.gitlab_ui_radio_component :access_level, :admin, - s_('AdminUsers|Administrator'), - radio_options: { disabled: editing_current_user }, - help_text: help_text - - .form-group.gl-form-group{ role: 'group' } - = f.gitlab_ui_checkbox_component :external, - s_('AdminUsers|External'), - help_text: s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.') - .hidden{ data: user_internal_regex_data } - .gl-display-flex.gl-align-items-baseline - %row.hidden#warning_external_automatically_set - = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning - - .form-group.gl-form-group{ role: 'group' } - - @user.credit_card_validation || @user.build_credit_card_validation - = f.fields_for :credit_card_validation do |ff| - = ff.gitlab_ui_checkbox_component :credit_card_validated_at, - s_('AdminUsers|Validate user account'), - help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user. Validated users can use free CI minutes on shared runners.'), - checkbox_options: { checked: @user.credit_card_validated_at.present? } + + .form-group.gl-form-group{ role: 'group' } + = f.label :projects_limit, class: 'gl-display-block col-form-label' + = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input gl-form-input-sm' + + .form-group.gl-form-group{ role: 'group' } + = f.gitlab_ui_checkbox_component :can_create_group, s_('AdminUsers|Can create group') + = f.gitlab_ui_checkbox_component :private_profile, s_('AdminUsers|Private profile') + + %fieldset.form-group.gl-form-group + %legend.col-form-label.col-form-label + = s_('AdminUsers|Access level') + - editing_current_user = (current_user == @user) + + = f.gitlab_ui_radio_component :access_level, :regular, + s_('AdminUsers|Regular'), + radio_options: { disabled: editing_current_user }, + help_text: s_('AdminUsers|Regular users have access to their groups and projects.') + + = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user + + - help_text = s_('AdminUsers|The user has unlimited access to all groups, projects, users, and features.') + - help_text += ' ' + s_('AdminUsers|You cannot remove your own administrator access.') if editing_current_user + = f.gitlab_ui_radio_component :access_level, :admin, + s_('AdminUsers|Administrator'), + radio_options: { disabled: editing_current_user }, + help_text: help_text + + .form-group.gl-form-group{ role: 'group' } + = f.gitlab_ui_checkbox_component :external, + s_('AdminUsers|External'), + help_text: s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.') + .hidden{ data: user_internal_regex_data } + .gl-display-flex.gl-align-items-baseline + %row.hidden#warning_external_automatically_set + = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning + + .form-group.gl-form-group{ role: 'group' } + - @user.credit_card_validation || @user.build_credit_card_validation + = f.fields_for :credit_card_validation do |ff| + = ff.gitlab_ui_checkbox_component :credit_card_validated_at, + s_('AdminUsers|Validate user account'), + help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user. Validated users can use free CI minutes on shared runners.'), + checkbox_options: { checked: @user.credit_card_validated_at.present? } diff --git a/app/views/admin/users/_admin_notes.html.haml b/app/views/admin/users/_admin_notes.html.haml index dce008afb26..85796246c83 100644 --- a/app/views/admin/users/_admin_notes.html.haml +++ b/app/views/admin/users/_admin_notes.html.haml @@ -1,9 +1,9 @@ -.gl-mb-3 - .row - .col-lg-4 - %h4.gl-mt-0 +.settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 = _('Admin notes') - .col-lg-8 - .form-group.gl-form-group{ role: 'group' } - = f.label :note, s_('Admin|Note') - = f.text_area :note, class: 'form-control gl-form-input gl-form-textarea' + + .form-group.gl-form-group{ role: 'group' } + = f.label :note, s_('Admin|Note') + = f.text_area :note, class: 'form-control gl-form-input gl-form-textarea' diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 8822d52c3c0..ffe7e128d60 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -2,42 +2,42 @@ = gitlab_ui_form_for [:admin, @user], html: { class: 'fieldset-form' } do |f| = form_errors(@user) - .gl-border-b.gl-pb-3.gl-mb-6 - .row - .col-lg-4 - %h4.gl-mt-0 + .settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 = _('Account') - .col-lg-8 - .form-group.gl-form-group{ role: 'group' } - = f.label :name, _('Name'), class: 'gl-display-block col-form-label' - = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input' - - .form-group.gl-form-group{ role: 'group' } - = f.label :username, _('Username'), class: 'gl-display-block col-form-label' - = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input' - - .form-group.gl-form-group{ role: 'group' } - = f.label :email, _('Email'), class: 'gl-display-block col-form-label' - = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input' - - .gl-border-b.gl-pb-3.gl-mb-6 - .row - .col-lg-4 - %h4.gl-mt-0 + + .form-group.gl-form-group{ role: 'group' } + = f.label :name, _('Name'), class: 'gl-display-block col-form-label' + = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input gl-form-input-lg' + + .form-group.gl-form-group{ role: 'group' } + = f.label :username, _('Username'), class: 'gl-display-block col-form-label' + = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input gl-form-input-lg' + + .form-group.gl-form-group{ role: 'group' } + = f.label :email, _('Email'), class: 'gl-display-block col-form-label' + = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input gl-form-input-lg' + + .settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 = _('Password') - .col-lg-8 - - if @user.new_record? - = render Pajamas::AlertComponent.new(variant: :info, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| - - c.with_body do - = s_('AdminUsers|Reset link will be generated and sent to the user. User will be forced to set the password on first sign in.') - - else - .form-group.gl-form-group{ role: 'group' } - = f.label :password, _('Password'), class: 'gl-display-block col-form-label' - = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation' - = render_if_exists 'shared/password_requirements_list' - .form-group.gl-form-group{ role: 'group' } - = f.label :password_confirmation, _('Password confirmation'), class: 'gl-display-block col-form-label' - = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input' + + - if @user.new_record? + = render Pajamas::AlertComponent.new(variant: :info, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| + - c.with_body do + = s_('AdminUsers|Reset link will be generated and sent to the user. User will be forced to set the password on first sign in.') + - else + .form-group.gl-form-group{ role: 'group' } + = f.label :password, _('Password'), class: 'gl-display-block col-form-label' + = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation gl-form-input-lg' + = render_if_exists 'shared/password_requirements_list' + .form-group.gl-form-group{ role: 'group' } + = f.label :password_confirmation, _('Password confirmation'), class: 'gl-display-block col-form-label' + = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input gl-form-input-lg' = render partial: 'access_levels', locals: { f: f } @@ -45,42 +45,42 @@ = render_if_exists 'admin/users/limits', f: f - .gl-border-b.gl-pb-6.gl-mb-6 - .row - .col-lg-4 + .settings-section + .settings-sticky-header + .settings-sticky-header-inner %h4.gl-mt-0 = _('Profile') - .col-lg-8 - .form-group.gl-form-group{ role: 'group' } - = f.label :avatar, s_('AdminUsers|Avatar'), class: 'gl-display-block col-form-label' - = f.file_field :avatar - .form-group.gl-form-group{ role: 'group' } - = f.label :skype, s_('AdminUsers|Skype'), class: 'gl-display-block col-form-label' - = f.text_field :skype, class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :avatar, s_('AdminUsers|Avatar'), class: 'gl-display-block col-form-label gl-form-input-lg' + = f.file_field :avatar + + .form-group.gl-form-group{ role: 'group' } + = f.label :skype, s_('AdminUsers|Skype'), class: 'gl-display-block col-form-label' + = f.text_field :skype, class: 'form-control gl-form-input gl-form-input-lg' - .form-group.gl-form-group{ role: 'group' } - = f.label :linkedin, s_('AdminUsers|Linkedin'), class: 'gl-display-block col-form-label' - = f.text_field :linkedin, class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :linkedin, s_('AdminUsers|Linkedin'), class: 'gl-display-block col-form-label' + = f.text_field :linkedin, class: 'form-control gl-form-input gl-form-input-lg' - .form-group.gl-form-group{ role: 'group' } - = f.label :twitter, _('Twitter'), class: 'gl-display-block col-form-label' - = f.text_field :twitter, class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :twitter, _('Twitter'), class: 'gl-display-block col-form-label' + = f.text_field :twitter, class: 'form-control gl-form-input gl-form-input-lg' - .form-group.gl-form-group{ role: 'group' } - = f.label :website_url, s_('AdminUsers|Website URL'), class: 'gl-display-block col-form-label' - = f.text_field :website_url, class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :website_url, s_('AdminUsers|Website URL'), class: 'gl-display-block col-form-label' + = f.text_field :website_url, class: 'form-control gl-form-input gl-form-input-lg' = render_if_exists 'admin/users/custom_attributes', f: f = render 'admin/users/admin_notes', f: f - %div + .settings-sticky-footer - if @user.new_record? - = f.submit _('Create user'), pajamas_button: true + = f.submit _('Create user'), pajamas_button: true, class: 'gl-mr-3' = render Pajamas::ButtonComponent.new(href: admin_users_path) do = _('Cancel') - else - = f.submit _('Save changes'), pajamas_button: true + = f.submit _('Save changes'), pajamas_button: true, class: 'gl-mr-3' = render Pajamas::ButtonComponent.new(href: admin_user_path(@user)) do = _('Cancel') diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 4825f192d4d..345a1cc0225 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -21,7 +21,8 @@ = recaptcha_tags nonce: content_security_policy_nonce - if remember_me_enabled? - = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { autocomplete: 'off' } + .form-group + = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { autocomplete: 'off' } = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { class: "js-sign-in-button #{'js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' } }) do = _('Sign in') diff --git a/app/views/notify/member_about_to_expire_email.html.haml b/app/views/notify/member_about_to_expire_email.html.haml new file mode 100644 index 00000000000..a9f92d90ae6 --- /dev/null +++ b/app/views/notify/member_about_to_expire_email.html.haml @@ -0,0 +1,6 @@ += email_default_heading(say_hi(@member.user)) + +%p + = member_about_to_expire_text(@member_source, @days_to_expire, format: :html) +%p + = member_about_to_expire_link(@member, @member_source, format: :html) diff --git a/app/views/notify/member_about_to_expire_email.text.erb b/app/views/notify/member_about_to_expire_email.text.erb new file mode 100644 index 00000000000..0c6e78bf501 --- /dev/null +++ b/app/views/notify/member_about_to_expire_email.text.erb @@ -0,0 +1,5 @@ +<%= say_hi(@member.user) %> + +<%= member_about_to_expire_text(@member_source, @days_to_expire) %> + +<%= member_about_to_expire_link(@member, @member_source) %> diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 4f2d101239b..e2d199b9e51 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -552,6 +552,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:members_expiring + :worker_name: Members::ExpiringWorker + :feature_category: :system_access + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: cronjob:metrics_global_metrics_update :worker_name: Metrics::GlobalMetricsUpdateWorker :feature_category: :metrics @@ -2955,6 +2964,15 @@ :weight: 2 :idempotent: :tags: [] +- :name: members_expiring_email_notification + :worker_name: Members::ExpiringEmailNotificationWorker + :feature_category: :system_access + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: merge :worker_name: MergeWorker :feature_category: :source_code_management diff --git a/app/workers/members/expiring_email_notification_worker.rb b/app/workers/members/expiring_email_notification_worker.rb new file mode 100644 index 00000000000..1d0a6eb254a --- /dev/null +++ b/app/workers/members/expiring_email_notification_worker.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Members + class ExpiringEmailNotificationWorker # rubocop:disable Scalability/CronWorkerContext + include ApplicationWorker + + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency + feature_category :system_access + urgency :low + idempotent! + + def perform(member_id) + notification_service = NotificationService.new + member = ::Member.find_by_id(member_id) + + return unless member + return unless Feature.enabled?(:member_expiring_email_notification, member.source.root_ancestor) + return if member.expiry_notified_at.present? + + with_context(user: member.user) do + notification_service.member_about_to_expire(member) + Gitlab::AppLogger.info(message: "Notifying user about expiring membership", member_id: member.id) + + member.update(expiry_notified_at: Time.current) + end + end + end +end diff --git a/app/workers/members/expiring_worker.rb b/app/workers/members/expiring_worker.rb new file mode 100644 index 00000000000..0d631af3a7c --- /dev/null +++ b/app/workers/members/expiring_worker.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Members + class ExpiringWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + data_consistency :sticky + feature_category :system_access + urgency :low + + BATCH_LIMIT = 500 + + def perform + return unless Feature.enabled?(:member_expiring_email_notification) + + limit_date = Member::DAYS_TO_EXPIRE.days.from_now.to_date + + expiring_members = Member.active.where(users: { user_type: :human }).expiring_and_not_notified(limit_date) # rubocop: disable CodeReuse/ActiveRecord + + expiring_members.each_batch(of: BATCH_LIMIT) do |members| + members.pluck_primary_key.each do |member_id| + Members::ExpiringEmailNotificationWorker.perform_async(member_id) + end + end + end + end +end diff --git a/config/feature_flags/development/cache_introspection_query.yml b/config/feature_flags/development/member_expiring_email_notification.yml index d0b12993631..1775cc67b52 100644 --- a/config/feature_flags/development/cache_introspection_query.yml +++ b/config/feature_flags/development/member_expiring_email_notification.yml @@ -1,8 +1,8 @@ --- -name: cache_introspection_query -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/120279 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/412457 -milestone: '16.1' +name: member_expiring_email_notification +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/124577 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/416581 +milestone: '16.3' type: development -group: group::application performance +group: group::authentication and authorization default_enabled: false diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 50d26236a29..cda625fab13 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -511,6 +511,9 @@ Settings.cron_jobs['import_export_project_cleanup_worker']['job_class'] = 'Impor Settings.cron_jobs['ci_archive_traces_cron_worker'] ||= {} Settings.cron_jobs['ci_archive_traces_cron_worker']['cron'] ||= '17 * * * *' Settings.cron_jobs['ci_archive_traces_cron_worker']['job_class'] = 'Ci::ArchiveTracesCronWorker' +Settings.cron_jobs['members_expiring_worker'] ||= {} +Settings.cron_jobs['members_expiring_worker']['cron'] ||= '0 1 * * *' +Settings.cron_jobs['members_expiring_worker']['job_class'] = 'Members::ExpiringWorker' Settings.cron_jobs['remove_expired_members_worker'] ||= {} Settings.cron_jobs['remove_expired_members_worker']['cron'] ||= '10 0 * * *' Settings.cron_jobs['remove_expired_members_worker']['job_class'] = 'RemoveExpiredMembersWorker' diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 0205d24d48b..7fe4db435a6 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -345,6 +345,8 @@ - 2 - - mailers - 2 +- - members_expiring_email_notification + - 1 - - merge - 5 - - merge_request_cleanup_refs diff --git a/db/migrate/20230707003301_add_expiry_notified_at_to_member.rb b/db/migrate/20230707003301_add_expiry_notified_at_to_member.rb new file mode 100644 index 00000000000..e890325e5fa --- /dev/null +++ b/db/migrate/20230707003301_add_expiry_notified_at_to_member.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddExpiryNotifiedAtToMember < Gitlab::Database::Migration[2.1] + disable_ddl_transaction! + + TABLE_NAME = 'members' + COLUMN_NAME = 'expiry_notified_at' + + def up + with_lock_retries do + add_column(TABLE_NAME, COLUMN_NAME, :datetime_with_timezone) + end + end + + def down + with_lock_retries do + remove_column TABLE_NAME, COLUMN_NAME + end + end +end diff --git a/db/post_migrate/20230714015909_add_index_for_member_expiring_query.rb b/db/post_migrate/20230714015909_add_index_for_member_expiring_query.rb new file mode 100644 index 00000000000..4d98d4792af --- /dev/null +++ b/db/post_migrate/20230714015909_add_index_for_member_expiring_query.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AddIndexForMemberExpiringQuery < Gitlab::Database::Migration[2.1] + INDEX_NAME = 'index_members_on_expiring_at_access_level_id' + + disable_ddl_transaction! + + def up + add_concurrent_index :members, + [:expires_at, :access_level, :id], + where: 'requested_at IS NULL AND expiry_notified_at IS NULL', + name: INDEX_NAME + end + + def down + remove_concurrent_index_by_name :members, INDEX_NAME + end +end diff --git a/db/schema_migrations/20230707003301 b/db/schema_migrations/20230707003301 new file mode 100644 index 00000000000..b1d5d84ee1b --- /dev/null +++ b/db/schema_migrations/20230707003301 @@ -0,0 +1 @@ +dac0b6b1f86685bd19aed528c75197adfee06154ea68efdb854f4b17deb973fa
\ No newline at end of file diff --git a/db/schema_migrations/20230714015909 b/db/schema_migrations/20230714015909 new file mode 100644 index 00000000000..c5362b4a82d --- /dev/null +++ b/db/schema_migrations/20230714015909 @@ -0,0 +1 @@ +54f82e196c756ca60c4d7300ef1987a6b1ca50d4ef87bf89bb62eb577164365c
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index d1f21674c21..18e82de6a74 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -18018,6 +18018,7 @@ CREATE TABLE members ( invite_email_success boolean DEFAULT true NOT NULL, member_namespace_id bigint, member_role_id bigint, + expiry_notified_at timestamp with time zone, CONSTRAINT check_508774aac0 CHECK ((member_namespace_id IS NOT NULL)) ); @@ -31872,6 +31873,8 @@ CREATE INDEX index_members_on_access_level ON members USING btree (access_level) CREATE INDEX index_members_on_expires_at ON members USING btree (expires_at); +CREATE INDEX index_members_on_expiring_at_access_level_id ON members USING btree (expires_at, access_level, id) WHERE ((requested_at IS NULL) AND (expiry_notified_at IS NULL)); + CREATE INDEX index_members_on_invite_email ON members USING btree (invite_email); CREATE UNIQUE INDEX index_members_on_invite_token ON members USING btree (invite_token); diff --git a/doc/api/integrations.md b/doc/api/integrations.md index b1cb6ed6560..aafda55b9f2 100644 --- a/doc/api/integrations.md +++ b/doc/api/integrations.md @@ -637,6 +637,8 @@ Send notifications about project events to a Discord channel. ### Create/Edit Discord integration +> `_channel` parameters [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125621) in GitLab 16.3. + Set Discord integration for a project. ```plaintext @@ -650,15 +652,24 @@ Parameters: | `webhook` | string | true | Discord webhook. For example, `https://discord.com/api/webhooks/…` | | `branches_to_be_notified` | string | false | Branches to send notifications for. Valid options are `all`, `default`, `protected`, and `default_and_protected`. The default value is "default" | | `confidential_issues_events` | boolean | false | Enable notifications for confidential issue events | +| `confidential_issue_channel` | string | false | The webhook override to receive confidential issues events notifications | | `confidential_note_events` | boolean | false | Enable notifications for confidential note events | +| `confidential_note_channel` | string | false | The webhook override to receive confidential note events notifications | | `issues_events` | boolean | false | Enable notifications for issue events | +| `issue_channel` | string | false | The webhook override to receive issues events notifications | | `merge_requests_events` | boolean | false | Enable notifications for merge request events | +| `merge_request_channel` | string | false | The webhook override to receive merge request events notifications | | `note_events` | boolean | false | Enable notifications for note events | +| `note_channel` | string | false | The webhook override to receive note events notifications | | `notify_only_broken_pipelines` | boolean | false | Send notifications for broken pipelines | | `pipeline_events` | boolean | false | Enable notifications for pipeline events | +| `pipeline_channel` | string | false | The webhook override to receive pipeline events notifications | | `push_events` | boolean | false | Enable notifications for push events | +| `push_channel` | string | false | The webhook override to receive push events notifications | | `tag_push_events` | boolean | false | Enable notifications for tag push events | +| `tag_push_channel` | string | false | The webhook override to receive tag push events notifications | | `wiki_page_events` | boolean | false | Enable notifications for wiki page events | +| `wiki_page_channel` | string | false | The webhook override to receive wiki page events notifications | ### Disable Discord integration diff --git a/doc/ci/examples/authenticating-with-hashicorp-vault/index.md b/doc/ci/examples/authenticating-with-hashicorp-vault/index.md index f45c60bdd1f..ad7de0c93b8 100644 --- a/doc/ci/examples/authenticating-with-hashicorp-vault/index.md +++ b/doc/ci/examples/authenticating-with-hashicorp-vault/index.md @@ -5,13 +5,13 @@ info: To determine the technical writer assigned to the Stage/Group associated w type: tutorial --- -# Authenticating and reading secrets with HashiCorp Vault (Deprecated) **(PREMIUM)** +# Authenticating and reading secrets with HashiCorp Vault **(PREMIUM)** WARNING: -Authenticating with HashiCorp Vault by using `CI_JOB_JWT` was [deprecated in GitLab 15.9](../../../update/deprecations.md#old-versions-of-json-web-tokens-are-deprecated) -and the token is scheduled to be removed in GitLab 16.5. This change is a breaking change. -Use [ID tokens to authenticate with HashiCorp Vault](../../secrets/id_token_authentication.md#automatic-id-token-authentication-with-hashicorp-vault) -instead. +Authenticating with `CI_JOB_JWT` was [deprecated in GitLab 15.9](../../../update/deprecations.md#old-versions-of-json-web-tokens-are-deprecated) +and the token is scheduled to be removed in GitLab 16.5. Use +[ID tokens to authenticate with HashiCorp Vault](../../secrets/id_token_authentication.md#automatic-id-token-authentication-with-hashicorp-vault) +instead, as demonstrated on this page. This tutorial demonstrates how to authenticate, configure, and read secrets with HashiCorp's Vault from GitLab CI/CD. @@ -249,7 +249,7 @@ Now, configure the JWT Authentication method: ```shell $ vault write auth/jwt/config \ jwks_url="https://gitlab.example.com/-/jwks" \ - bound_issuer="gitlab.example.com" + bound_issuer="https://gitlab.example.com" ``` [`bound_issuer`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#bound_issuer) specifies that only a JWT with the issuer (that is, the `iss` claim) set to `gitlab.example.com` can use this method to authenticate, and that the JWKS endpoint (`https://gitlab.example.com/-/jwks`) should be used to validate the token. diff --git a/doc/development/secure_coding_guidelines.md b/doc/development/secure_coding_guidelines.md index 8d6f36bb189..2c85cb19fab 100644 --- a/doc/development/secure_coding_guidelines.md +++ b/doc/development/secure_coding_guidelines.md @@ -1412,3 +1412,64 @@ If circumstances dictate that local storage is the only option, a couple of prec - Local storage should only be used for the minimal amount of data possible. Consider alternative storage formats. - If you have to store sensitive data using local storage, do so for the minimum time possible, calling `localStorage.removeItem` on the item as soon as we're done with it. Another alternative is to call `localStorage.clear()`. + +## Logging + +Logging is the tracking of events that happen in the system for the purposes of future investigation or processing. + +### Purpose of logging + +Logging helps track events for debugging. Logging also allows the application to generate an audit trail that you can use for security incident identification and analysis. + +### What type of events should be logged + +- Failures + - Login failures + - Input/output validation failures + - Authentication failures + - Authorization failures + - Session management failures + - Timeout errors +- Account lockouts +- Use of invalid access tokens +- Authentication and authorization events + - Access token creation/revocation/expiry + - Configuration changes by administrators + - User creation or modification + - Password change + - User creation + - Email change +- Sensitive operations + - Any operation on sensitive files or resources + - New runner registration + +### What should be captured in the logs + +- The application logs must record attributes of the event, which helps auditors identify the time/date, IP, user ID, and event details. +- To avoid resource depletion, make sure the proper level for logging is used (for example, `information`, `error`, or `fatal`). + +### What should not be captured in the logs + +- Personal user information. +- Credentials like access tokens or passwords. If credentials must be captured for debugging purposes, log the internal ID of the credential (if available) instead. Never log credentials under any circumstances. + - When [debug logging](../ci/variables/index.md#enable-debug-logging) is enabled, all masked CI/CD variables are visible in job logs. Consider using [protected variables](../ci/variables/index.md#protect-a-cicd-variable) when possible so that sensitive CI/CD variables are only available to pipelines running on protected branches or protected tags. +- Any data supplied by the user without proper validation. +- Any information that might be considered sensitive (for example, credentials, passwords, tokens, keys, or secrets). Here is an [example](https://gitlab.com/gitlab-org/gitlab/-/issues/383142) of sensitive information being leaked through logs. + +### Protecting log files + +- Access to the log files should be restricted so that only the intended party can modify the logs. +- External user input should not be directly captured in the logs without any validation. This could lead to unintended modification of logs through log injection attacks. +- An audit trail for log edits must be available. +- To avoid data loss, logs must be saved on different storage. + +### Who to contact if you have questions + +For general guidance, contact the [Application Security](https://about.gitlab.com/handbook/security/security-engineering/application-security/) team. + +### Related topics + +- [Log system in GitLab](../administration/logs/index.md) +- [Audit event development guidelines](../development/audit_event_guide/index.md)) +- [Security logging overview](https://about.gitlab.com/handbook/security/security-engineering/security-logging/) +- [OWASP logging cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) diff --git a/doc/integration/mattermost/index.md b/doc/integration/mattermost/index.md index d8558647e4b..3459e7974cf 100644 --- a/doc/integration/mattermost/index.md +++ b/doc/integration/mattermost/index.md @@ -272,7 +272,7 @@ There are 4 users on local instance ### Use `mmctl` through a remote connection For remote connections or local connections where the socket cannot be used, -create a non SSO user and give that user administrator privileges. Those credentials +create a non-SSO user and give that user administrator privileges. Those credentials can then be used to authenticate `mmctl`: ```shell @@ -311,46 +311,41 @@ This setting can also be configured in `/var/opt/gitlab/mattermost/config.json`. ## Upgrading GitLab Mattermost -Below is a list of Mattermost versions for GitLab 11.10 and later: - -| GitLab Version | Mattermost Version | -| :------------ |:----------------| -| 11.11 | 5.10 | -| 12.0 | 5.11 | -| 12.1 | 5.12 | -| 12.2 | 5.13 | -| 12.3 | 5.14 | -| 12.4 | 5.15 | -| 12.5 | 5.16 | -| 12.6 | 5.17 | -| 12.7 | 5.17 | -| 12.8 | 5.19 | -| 12.9 | 5.20 | -| 12.10 | 5.21 | -| 13.0 | 5.22 | -| 13.1 | 5.23 | -| 13.2 | 5.24 | -| 13.3 | 5.25 | -| 13.4 | 5.26 | -| 13.5 | 5.27 | -| 13.6 | 5.28 | -| 13.7 | 5.29 | -| 13.8 | 5.30 | -| 13.9 | 5.31 | -| 13.10 | 5.32 | -| 13.11 | 5.33 | -| 13.12 | 5.34 | -| 14.0 | 5.35 | -| 14.1 | 5.36 | -| 14.2 | 5.37 | -| 14.3 | 5.38 | -| 14.4 | 5.39 | -| 14.5 | 5.39 | -| 14.6 | 6.1 | -| 14.7 | 6.2 | - -- GitLab 14.5 remained on Mattermost 5.39 -- GitLab 14.6 updates to Mattermost 6.1 instead of 6.0 +Below is a list of Mattermost versions for GitLab 14.0 and later: + +| GitLab version | Mattermost version | +|:---------------|:-------------------| +| 14.0 | 5.35 | +| 14.1 | 5.36 | +| 14.2 | 5.37 | +| 14.3 | 5.38 | +| 14.4 | 5.39 | +| 14.5 | 5.39 | +| 14.6 | 6.1 | +| 14.7 | 6.2 | +| 14.8 | 6.3 | +| 14.9 | 6.4 | +| 14.10 | 6.5 | +| 15.0 | 6.6 | +| 15.1 | 6.7 | +| 15.2 | 7.0 | +| 15.3 | 7.1 | +| 15.4 | 7.2 | +| 15.5 | 7.3 | +| 15.6 | 7.4 | +| 15.7 | 7.5 | +| 15.8 | 7.5 | +| 15.9 | 7.7 | +| 15.10 | 7.8 | +| 15.11 | 7.9 | +| 16.0 | 7.10 | +| 16.1 | 7.10 | +| 16.2 | 7.10 | + +- GitLab 14.5 remained on Mattermost 5.39. +- GitLab 14.6 updates to Mattermost 6.1 instead of 6.0. +- GitLab 15.8 remained on Mattermost 7.5. +- GitLab 16.1 and 16.2 remained on Mattermost 7.10. NOTE: When upgrading the Mattermost version, it is essential to check the diff --git a/doc/integration/vault.md b/doc/integration/vault.md index c93e3e53949..3e408f2c9ae 100644 --- a/doc/integration/vault.md +++ b/doc/integration/vault.md @@ -84,7 +84,7 @@ and scopes given to GitLab when you created the application. Run the following command in the terminal: ```shell -vault write auth/oidc/role/demo -<<EOF +vault write auth/oidc/role/demo - <<EOF { "user_claim": "sub", "allowed_redirect_uris": "<your_vault_instance_redirect_uris>", diff --git a/doc/user/project/integrations/discord_notifications.md b/doc/user/project/integrations/discord_notifications.md index 180e39e3254..dca10aa732b 100644 --- a/doc/user/project/integrations/discord_notifications.md +++ b/doc/user/project/integrations/discord_notifications.md @@ -24,14 +24,18 @@ and configure it in GitLab. ## Configure created webhook in GitLab +> Event webhook overrides [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125621) in GitLab 16.3. + With the webhook URL created in the Discord channel, you can set up the Discord Notifications integration in GitLab. 1. On the left sidebar, at the top, select **Search GitLab** (**{search}**) to find your project. 1. Select **Settings > Integrations**. 1. Select **Discord Notifications**. 1. Ensure that the **Active** toggle is enabled. -1. Check the checkboxes corresponding to the GitLab events for which you want to send notifications to Discord. -1. Paste the webhook URL that you copied from the create Discord webhook step. +1. Paste the webhook URL that you [created earlier](#create-webhook) into the **Webhook** field. +1. Select the checkboxes corresponding to the GitLab events for which you want to send notifications to Discord. +1. Optionally for each checkbox that you select, enter a new Discord webhook URL that you have [configured](#create-webhook) + to override the default one in the **Webhook** field. 1. Configure the remaining options and select the **Save changes** button. The Discord channel you created the webhook for now receives notification of the GitLab events that were configured. diff --git a/doc/user/project/ml/experiment_tracking/mlflow_client.md b/doc/user/project/ml/experiment_tracking/mlflow_client.md index 7fd5c7cca92..d83c0346415 100644 --- a/doc/user/project/ml/experiment_tracking/mlflow_client.md +++ b/doc/user/project/ml/experiment_tracking/mlflow_client.md @@ -76,30 +76,30 @@ candidate metadata. To associate a candidate to a CI/CD job: GitLab supports these methods from the MLflow client. Other methods might be supported but were not tested. More information can be found in the [MLflow Documentation](https://www.mlflow.org/docs/1.28.0/python_api/mlflow.html). -| Method | Supported | Version Added | Comments | -|--------------------------|------------------|----------------|----------| -| `get_experiment` | Yes | 15.11 | | -| `get_experiment_by_name` | Yes | 15.11 | | -| `set_experiment` | Yes | 15.11 | | -| `get_run` | Yes | 15.11 | | -| `start_run` | Yes | 15.11 | | -| `log_artifact` | Yes with caveat | 15.11 | (15.11) `artifact_path` must be empty string. Does not support directories. -| `log_artifacts` | Yes with caveat | 15.11 | (15.11) `artifact_path` must be empty string. Does not support directories. -| `log_batch` | Yes | 15.11 | | -| `log_metric` | Yes | 15.11 | | -| `log_metrics` | Yes | 15.11 | | -| `log_param` | Yes | 15.11 | | -| `log_params` | Yes | 15.11 | | -| `log_figure` | Yes | 15.11 | | -| `log_image` | Yes | 15.11 | | -| `log_text` | Yes with caveat | 15.11 | (15.11) Does not support directories. -| `log_dict` | Yes with caveat | 15.11 | (15.11) Does not support directories. -| `set_tag` | Yes | 15.11 | | -| `set_tags` | Yes | 15.11 | | -| `set_terminated` | Yes | 15.11 | | -| `end_run` | Yes | 15.11 | | -| `update_run` | Yes | 15.11 | | -| `log_model` | Partial | 15.11 | (15.11) Saves the artifacts, but not the model data. `artifact_path` must be empty. +| Method | Supported | Version Added | Comments | +|--------------------------|------------------|----------------|-------------------------------------------------------------------------------------| +| `get_experiment` | Yes | 15.11 | | +| `get_experiment_by_name` | Yes | 15.11 | | +| `set_experiment` | Yes | 15.11 | | +| `get_run` | Yes | 15.11 | | +| `start_run` | Yes | 15.11 | (16.3) If a name is not provided, the candidate receives a random nickname. | +| `log_artifact` | Yes with caveat | 15.11 | (15.11) `artifact_path` must be empty. Does not support directories. | +| `log_artifacts` | Yes with caveat | 15.11 | (15.11) `artifact_path` must be empty. Does not support directories. | +| `log_batch` | Yes | 15.11 | | +| `log_metric` | Yes | 15.11 | | +| `log_metrics` | Yes | 15.11 | | +| `log_param` | Yes | 15.11 | | +| `log_params` | Yes | 15.11 | | +| `log_figure` | Yes | 15.11 | | +| `log_image` | Yes | 15.11 | | +| `log_text` | Yes with caveat | 15.11 | (15.11) Does not support directories. | +| `log_dict` | Yes with caveat | 15.11 | (15.11) Does not support directories. | +| `set_tag` | Yes | 15.11 | | +| `set_tags` | Yes | 15.11 | | +| `set_terminated` | Yes | 15.11 | | +| `end_run` | Yes | 15.11 | | +| `update_run` | Yes | 15.11 | | +| `log_model` | Partial | 15.11 | (15.11) Saves the artifacts, but not the model data. `artifact_path` must be empty. | ## Limitations diff --git a/lib/api/helpers/integrations_helpers.rb b/lib/api/helpers/integrations_helpers.rb index 09dd69ef03b..d9c3d95e12d 100644 --- a/lib/api/helpers/integrations_helpers.rb +++ b/lib/api/helpers/integrations_helpers.rb @@ -453,7 +453,8 @@ module API desc: 'Branches for which notifications are to be sent' }, chat_notification_flags, - chat_notification_events + chat_notification_events, + chat_notification_channels ].flatten, 'drone-ci' => [ { diff --git a/lib/api/ml_model_packages.rb b/lib/api/ml_model_packages.rb index 35d231d9fe1..8a7a8fc9525 100644 --- a/lib/api/ml_model_packages.rb +++ b/lib/api/ml_model_packages.rb @@ -50,7 +50,7 @@ module API requires :model_name, type: String, desc: 'Model name', regexp: Gitlab::Regex.ml_model_name_regex, file_path: true requires :model_version, type: String, desc: 'Model version', - regexp: Gitlab::Regex.ml_model_version_regex + regexp: Gitlab::Regex.semver_regex requires :file_name, type: String, desc: 'Package file name', regexp: Gitlab::Regex.ml_model_file_name_regex, file_path: true optional :status, type: String, values: ALLOWED_STATUSES, desc: 'Package status' diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index ed1f134ff70..c5b93cf58d6 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -2,312 +2,10 @@ module Gitlab module Regex - module Packages - CONAN_RECIPE_FILES = %w[conanfile.py conanmanifest.txt conan_sources.tgz conan_export.tgz].freeze - CONAN_PACKAGE_FILES = %w[conaninfo.txt conanmanifest.txt conan_package.tgz].freeze - - PYPI_NORMALIZED_NAME_REGEX_STRING = '[-_.]+' - - # see https://github.com/apache/maven/blob/c1dfb947b509e195c75d4275a113598cf3063c3e/maven-artifact/src/main/java/org/apache/maven/artifact/Artifact.java#L46 - MAVEN_SNAPSHOT_DYNAMIC_PARTS = /\A.{0,1000}(-\d{8}\.\d{6}-\d+).{0,1000}\z/.freeze - - API_PATH_REGEX = %r{^/api/v\d+/(projects/[^/]+/|groups?/[^/]+/-/)?packages/[A-Za-z]+}.freeze - - def conan_package_reference_regex - @conan_package_reference_regex ||= %r{\A[A-Za-z0-9]+\z}.freeze - end - - def conan_revision_regex - @conan_revision_regex ||= %r{\A0\z}.freeze - end - - def conan_recipe_user_channel_regex - %r{\A(_|#{conan_name_regex})\z}.freeze - end - - def conan_recipe_component_regex - # https://docs.conan.io/en/latest/reference/conanfile/attributes.html#name - @conan_recipe_component_regex ||= %r{\A#{conan_name_regex}\z}.freeze - end - - def composer_package_version_regex - # see https://github.com/composer/semver/blob/31f3ea725711245195f62e54ffa402d8ef2fdba9/src/VersionParser.php#L215 - @composer_package_version_regex ||= %r{\Av?((\d++)(\.(?:\d++|[xX*]))?(\.(?:\d++|[xX*]))?(\.(?:\d++|[xX*]))?)?\z}.freeze - end - - def composer_dev_version_regex - @composer_dev_version_regex ||= %r{(^dev-)|(-dev$)}.freeze - end - - def package_name_regex - @package_name_regex ||= - %r{ - \A\@? - (?> # atomic group to prevent backtracking - (([\w\-\.\+]*)\/)*([\w\-\.]+) - ) - @? - (?> # atomic group to prevent backtracking - (([\w\-\.\+]*)\/)*([\w\-\.]*) - ) - \z - }x.freeze - end - - def maven_file_name_regex - @maven_file_name_regex ||= %r{\A[A-Za-z0-9\.\_\-\+]+\z}.freeze - end - - def maven_path_regex - @maven_path_regex ||= %r{\A\@?(([\w\-\.]*)/)*([\w\-\.\+]*)\z}.freeze - end - - def maven_app_name_regex - @maven_app_name_regex ||= /\A[\w\-\.]+\z/.freeze - end - - def maven_version_regex - @maven_version_regex ||= /\A(?!.*\.\.)[\w+.-]+\z/.freeze - end - - def maven_app_group_regex - maven_app_name_regex - end - - def npm_package_name_regex - @npm_package_name_regex ||= %r{\A(?:@(#{Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX})/)?[-+\.\_a-zA-Z0-9]+\z}o - end - - def npm_package_name_regex_message - 'should be a valid NPM package name: https://github.com/npm/validate-npm-package-name#naming-rules.' - end - - def nuget_package_name_regex - @nuget_package_name_regex ||= %r{\A[-+\.\_a-zA-Z0-9]+\z}.freeze - end - - def nuget_version_regex - @nuget_version_regex ||= / - \A#{_semver_major_regex} - \.#{_semver_minor_regex} - (\.#{_semver_patch_regex})? - (\.\d*)? - #{_semver_prerelease_build_regex}\z - /x.freeze - end - - def terraform_module_package_name_regex - @terraform_module_package_name_regex ||= %r{\A[-a-z0-9]+\/[-a-z0-9]+\z}.freeze - end - - def pypi_version_regex - # See the official regex: https://github.com/pypa/packaging/blob/16.7/packaging/version.py#L159 - - @pypi_version_regex ||= %r{ - \A(?: - v? - (?:([0-9]+)!)? (?# epoch) - ([0-9]+(?:\.[0-9]+)*) (?# release segment) - ([-_\.]?((a|b|c|rc|alpha|beta|pre|preview))[-_\.]?([0-9]+)?)? (?# pre-release) - ((?:-([0-9]+))|(?:[-_\.]?(post|rev|r)[-_\.]?([0-9]+)?))? (?# post release) - ([-_\.]?(dev)[-_\.]?([0-9]+)?)? (?# dev release) - (?:\+([a-z0-9]+(?:[-_\.][a-z0-9]+)*))? (?# local version) - )\z}xi.freeze - end - - def debian_package_name_regex - # See official parser - # https://git.dpkg.org/cgit/dpkg/dpkg.git/tree/lib/dpkg/parsehelp.c?id=9e0c88ec09475f4d1addde9cdba1ad7849720356#n122 - # @debian_package_name_regex ||= %r{\A[a-z0-9][-+\._a-z0-9]*\z}i.freeze - # But we prefer a more strict version from Lintian - # https://salsa.debian.org/lintian/lintian/-/blob/5080c0068ffc4a9ddee92022a91d0c2ff53e56d1/lib/Lintian/Util.pm#L116 - @debian_package_name_regex ||= %r{\A[a-z0-9][-+\.a-z0-9]+\z}.freeze - end - - def debian_version_regex - # See official parser: https://git.dpkg.org/cgit/dpkg/dpkg.git/tree/lib/dpkg/parsehelp.c?id=9e0c88ec09475f4d1addde9cdba1ad7849720356#n205 - @debian_version_regex ||= %r{ - \A(?: - (?:([0-9]{1,9}):)? (?# epoch) - ([0-9][0-9a-z\.+~]*) (?# version) - (-[0-9a-z\.+~]+){0,14} (?# -revision) - (?<!-) - )\z}xi.freeze - end - - def debian_architecture_regex - # See official parser: https://git.dpkg.org/cgit/dpkg/dpkg.git/tree/lib/dpkg/arch.c?id=9e0c88ec09475f4d1addde9cdba1ad7849720356#n43 - # But we limit to lower case - @debian_architecture_regex ||= %r{\A#{::Packages::Debian::ARCHITECTURE_REGEX}\z}o.freeze - end - - def debian_distribution_regex - @debian_distribution_regex ||= %r{\A#{::Packages::Debian::DISTRIBUTION_REGEX}\z}io.freeze - end - - def debian_component_regex - @debian_component_regex ||= %r{\A#{::Packages::Debian::COMPONENT_REGEX}\z}o.freeze - end - - def debian_direct_upload_filename_regex - @debian_direct_upload_filename_regex ||= %r{\A.*\.(deb|udeb|ddeb)\z}o.freeze - end - - def helm_channel_regex - @helm_channel_regex ||= %r{\A([a-zA-Z0-9](\.|-|_)?){1,255}(?<!\.|-|_)\z}.freeze - end - - def helm_package_regex - @helm_package_regex ||= %r{#{helm_channel_regex}}.freeze - end - - def helm_version_regex - # identical to semver_regex, with optional preceding 'v' - @helm_version_regex ||= Regexp.new("\\Av?#{::Gitlab::Regex.unbounded_semver_regex.source}\\z", ::Gitlab::Regex.unbounded_semver_regex.options) - end - - def unbounded_semver_regex - # See the official regex: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string - - # The order of the alternatives in <prerelease> are intentionally - # reordered to be greedy. Without this change, the unbounded regex would - # only partially match "v0.0.0-20201230123456-abcdefabcdef". - @unbounded_semver_regex ||= / - #{_semver_major_minor_patch_regex}#{_semver_prerelease_build_regex} - /x.freeze - end - - def semver_regex - @semver_regex ||= Regexp.new("\\A#{::Gitlab::Regex.unbounded_semver_regex.source}\\z", ::Gitlab::Regex.unbounded_semver_regex.options).freeze - end - - def semver_regex_message - 'should follow SemVer: https://semver.org' - end - - # These partial semver regexes are intended for use in composing other - # regexes rather than being used alone. - def _semver_major_minor_patch_regex - @_semver_major_minor_patch_regex ||= / - #{_semver_major_regex}\.#{_semver_minor_regex}\.#{_semver_patch_regex} - /x.freeze - end - - def _semver_major_regex - @_semver_major_regex ||= / - (?<major>0|[1-9]\d*) - /x.freeze - end - - def _semver_minor_regex - @_semver_minor_regex ||= / - (?<minor>0|[1-9]\d*) - /x.freeze - end - - def _semver_patch_regex - @_semver_patch_regex ||= / - (?<patch>0|[1-9]\d*) - /x.freeze - end - - def _semver_prerelease_build_regex - @_semver_prerelease_build_regex ||= / - (?:-(?<prerelease>(?:\d*[a-zA-Z-][0-9a-zA-Z-]*|[1-9]\d*|0)(?:\.(?:\d*[a-zA-Z-][0-9a-zA-Z-]*|[1-9]\d*|0))*))? - (?:\+(?<build>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))? - /x.freeze - end - - def prefixed_semver_regex - # identical to semver_regex, except starting with 'v' - @prefixed_semver_regex ||= Regexp.new("\\Av#{::Gitlab::Regex.unbounded_semver_regex.source}\\z", ::Gitlab::Regex.unbounded_semver_regex.options) - end - - def go_package_regex - # A Go package name looks like a URL but is not; it: - # - Must not have a scheme, such as http:// or https:// - # - Must not have a port number, such as :8080 or :8443 - - @go_package_regex ||= %r{ - \b (?# word boundary) - (?<domain> - [0-9a-z](?:(?:-|[0-9a-z]){0,61}[0-9a-z])? (?# first domain) - (?:\.[0-9a-z](?:(?:-|[0-9a-z]){0,61}[0-9a-z])?)* (?# inner domains) - \.[a-z]{2,} (?# top-level domain) - ) - (?<path>/(?: - [-/$_.+!*'(),0-9a-z] (?# plain URL character) - | %[0-9a-f]{2})* (?# URL encoded character) - )? (?# path) - \b (?# word boundary) - }ix.freeze - end - - def generic_package_version_regex - maven_version_regex - end - - def generic_package_name_regex - maven_file_name_regex - end - - def generic_package_file_name_regex - generic_package_name_regex - end - - def sha256_regex - @sha256_regex ||= /\A[0-9a-f]{64}\z/i.freeze - end - - def slack_link_regex - @slack_link_regex ||= /<(.*[|].*)>/i.freeze - end - - private - - def conan_name_regex - @conan_name_regex ||= %r{[a-zA-Z0-9_][a-zA-Z0-9_\+\.-]{1,49}}.freeze - end - end - - module BulkImports - def bulk_import_destination_namespace_path_regex - # This regexp validates the string conforms to rules for a destination_namespace path: - # i.e does not start with a non-alphanumeric character, - # contains only alphanumeric characters, forward slashes, periods, and underscores, - # does not end with a period or forward slash, and has a relative path structure - # with no http protocol chars or leading or trailing forward slashes - # eg 'source/full/path' or 'destination_namespace' not 'https://example.com/destination/namespace/path' - # the regex also allows for an empty string ('') to be accepted as this is allowed in - # a bulk_import POST request - @bulk_import_destination_namespace_path_regex ||= %r/((\A\z)|(\A[0-9a-z]*(-_.)?[0-9a-z])(\/?[0-9a-z]*[-_.]?[0-9a-z])+\z)/i - end - - def bulk_import_source_full_path_regex - # This regexp validates the string conforms to rules for a source_full_path path: - # i.e does not start with a non-alphanumeric character except for periods or underscores, - # contains only alphanumeric characters, forward slashes, periods, and underscores, - # does not end with a period or forward slash, and has a relative path structure - # with no http protocol chars or leading or trailing forward slashes - # eg 'source/full/path' or 'destination_namespace' not 'https://example.com/source/full/path' - @bulk_import_source_full_path_regex ||= %r/\A([.]?)[^\W](\/?([-_.+]*)*[0-9a-z][-_]*)+\z/i - end - - def bulk_import_source_full_path_regex_message - bulk_import_destination_namespace_path_regex_message - end - - def bulk_import_destination_namespace_path_regex_message - "must have a relative path structure " \ - "with no HTTP protocol characters, or leading or trailing forward slashes. " \ - "Path segments must not start or end with a special character, " \ - "and must not contain consecutive special characters." - end - end - extend self - extend Packages extend BulkImports + extend MergeRequests + extend Packages def group_path_regex # This regexp validates the string conforms to rules for a group slug: @@ -598,10 +296,6 @@ module Gitlab @utc_date_regex ||= /\A[0-9]{4}-[0-9]{2}-[0-9]{2}\z/.freeze end - def merge_request_draft - /\A(?i)(\[draft\]|\(draft\)|draft:)/ - end - def issue @issue ||= /(?<issue>\d+)(?<format>\+s{,1})?(?=\W|\z)/ end @@ -610,10 +304,6 @@ module Gitlab @work_item ||= /(?<work_item>\d+)(?<format>\+s{,1})?(?=\W|\z)/ end - def merge_request - @merge_request ||= /(?<merge_request>\d+)(?<format>\+s{,1})?/ - end - def base64_regex @base64_regex ||= %r{(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?}.freeze end @@ -636,10 +326,6 @@ module Gitlab @x509_subject_key_identifier_regex ||= /\A(?:\h{2}:)*\h{2}\z/.freeze end - def ml_model_version_regex - maven_version_regex - end - def ml_model_name_regex package_name_regex end diff --git a/lib/gitlab/regex/bulk_imports.rb b/lib/gitlab/regex/bulk_imports.rb new file mode 100644 index 00000000000..e9ec24b831f --- /dev/null +++ b/lib/gitlab/regex/bulk_imports.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Gitlab + module Regex + module BulkImports + def bulk_import_destination_namespace_path_regex + # This regexp validates the string conforms to rules for a destination_namespace path: + # i.e does not start with a non-alphanumeric character, + # contains only alphanumeric characters, forward slashes, periods, and underscores, + # does not end with a period or forward slash, and has a relative path structure + # with no http protocol chars or leading or trailing forward slashes + # eg 'source/full/path' or 'destination_namespace' not 'https://example.com/destination/namespace/path' + # the regex also allows for an empty string ('') to be accepted as this is allowed in + # a bulk_import POST request + @bulk_import_destination_namespace_path_regex ||= %r/((\A\z)|(\A[0-9a-z]*(-_.)?[0-9a-z])(\/?[0-9a-z]*[-_.]?[0-9a-z])+\z)/i + end + + def bulk_import_source_full_path_regex + # This regexp validates the string conforms to rules for a source_full_path path: + # i.e does not start with a non-alphanumeric character except for periods or underscores, + # contains only alphanumeric characters, forward slashes, periods, and underscores, + # does not end with a period or forward slash, and has a relative path structure + # with no http protocol chars or leading or trailing forward slashes + # eg 'source/full/path' or 'destination_namespace' not 'https://example.com/source/full/path' + @bulk_import_source_full_path_regex ||= %r/\A([.]?)[^\W](\/?([-_.+]*)*[0-9a-z][-_]*)+\z/i + end + + def bulk_import_source_full_path_regex_message + bulk_import_destination_namespace_path_regex_message + end + + def bulk_import_destination_namespace_path_regex_message + "must have a relative path structure " \ + "with no HTTP protocol characters, or leading or trailing forward slashes. " \ + "Path segments must not start or end with a special character, " \ + "and must not contain consecutive special characters." + end + end + end +end diff --git a/lib/gitlab/regex/merge_requests.rb b/lib/gitlab/regex/merge_requests.rb new file mode 100644 index 00000000000..a3212d2c7d6 --- /dev/null +++ b/lib/gitlab/regex/merge_requests.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Gitlab + module Regex + module MergeRequests + def merge_request + @merge_request ||= /(?<merge_request>\d+)(?<format>\+s{,1})?/ + end + + def merge_request_draft + /\A(?i)(\[draft\]|\(draft\)|draft:)/ + end + end + end +end diff --git a/lib/gitlab/regex/packages.rb b/lib/gitlab/regex/packages.rb new file mode 100644 index 00000000000..107f2070801 --- /dev/null +++ b/lib/gitlab/regex/packages.rb @@ -0,0 +1,273 @@ +# frozen_string_literal: true + +module Gitlab + module Regex + module Packages + CONAN_RECIPE_FILES = %w[conanfile.py conanmanifest.txt conan_sources.tgz conan_export.tgz].freeze + CONAN_PACKAGE_FILES = %w[conaninfo.txt conanmanifest.txt conan_package.tgz].freeze + + PYPI_NORMALIZED_NAME_REGEX_STRING = '[-_.]+' + + # see https://github.com/apache/maven/blob/c1dfb947b509e195c75d4275a113598cf3063c3e/maven-artifact/src/main/java/org/apache/maven/artifact/Artifact.java#L46 + MAVEN_SNAPSHOT_DYNAMIC_PARTS = /\A.{0,1000}(-\d{8}\.\d{6}-\d+).{0,1000}\z/.freeze + + API_PATH_REGEX = %r{^/api/v\d+/(projects/[^/]+/|groups?/[^/]+/-/)?packages/[A-Za-z]+}.freeze + + def conan_package_reference_regex + @conan_package_reference_regex ||= %r{\A[A-Za-z0-9]+\z}.freeze + end + + def conan_revision_regex + @conan_revision_regex ||= %r{\A0\z}.freeze + end + + def conan_recipe_user_channel_regex + %r{\A(_|#{conan_name_regex})\z}.freeze + end + + def conan_recipe_component_regex + # https://docs.conan.io/en/latest/reference/conanfile/attributes.html#name + @conan_recipe_component_regex ||= %r{\A#{conan_name_regex}\z}.freeze + end + + def composer_package_version_regex + # see https://github.com/composer/semver/blob/31f3ea725711245195f62e54ffa402d8ef2fdba9/src/VersionParser.php#L215 + @composer_package_version_regex ||= %r{\Av?((\d++)(\.(?:\d++|[xX*]))?(\.(?:\d++|[xX*]))?(\.(?:\d++|[xX*]))?)?\z}.freeze + end + + def composer_dev_version_regex + @composer_dev_version_regex ||= %r{(^dev-)|(-dev$)}.freeze + end + + def package_name_regex + @package_name_regex ||= + %r{ + \A\@? + (?> # atomic group to prevent backtracking + (([\w\-\.\+]*)\/)*([\w\-\.]+) + ) + @? + (?> # atomic group to prevent backtracking + (([\w\-\.\+]*)\/)*([\w\-\.]*) + ) + \z + }x.freeze + end + + def maven_file_name_regex + @maven_file_name_regex ||= %r{\A[A-Za-z0-9\.\_\-\+]+\z}.freeze + end + + def maven_path_regex + @maven_path_regex ||= %r{\A\@?(([\w\-\.]*)/)*([\w\-\.\+]*)\z}.freeze + end + + def maven_app_name_regex + @maven_app_name_regex ||= /\A[\w\-\.]+\z/.freeze + end + + def maven_version_regex + @maven_version_regex ||= /\A(?!.*\.\.)[\w+.-]+\z/.freeze + end + + def maven_app_group_regex + maven_app_name_regex + end + + def npm_package_name_regex + @npm_package_name_regex ||= %r{\A(?:@(#{Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX})/)?[-+\.\_a-zA-Z0-9]+\z}o + end + + def npm_package_name_regex_message + 'should be a valid NPM package name: https://github.com/npm/validate-npm-package-name#naming-rules.' + end + + def nuget_package_name_regex + @nuget_package_name_regex ||= %r{\A[-+\.\_a-zA-Z0-9]+\z}.freeze + end + + def nuget_version_regex + @nuget_version_regex ||= / + \A#{_semver_major_regex} + \.#{_semver_minor_regex} + (\.#{_semver_patch_regex})? + (\.\d*)? + #{_semver_prerelease_build_regex}\z + /x.freeze + end + + def terraform_module_package_name_regex + @terraform_module_package_name_regex ||= %r{\A[-a-z0-9]+\/[-a-z0-9]+\z}.freeze + end + + def pypi_version_regex + # See the official regex: https://github.com/pypa/packaging/blob/16.7/packaging/version.py#L159 + + @pypi_version_regex ||= %r{ + \A(?: + v? + (?:([0-9]+)!)? (?# epoch) + ([0-9]+(?:\.[0-9]+)*) (?# release segment) + ([-_\.]?((a|b|c|rc|alpha|beta|pre|preview))[-_\.]?([0-9]+)?)? (?# pre-release) + ((?:-([0-9]+))|(?:[-_\.]?(post|rev|r)[-_\.]?([0-9]+)?))? (?# post release) + ([-_\.]?(dev)[-_\.]?([0-9]+)?)? (?# dev release) + (?:\+([a-z0-9]+(?:[-_\.][a-z0-9]+)*))? (?# local version) + )\z}xi.freeze + end + + def debian_package_name_regex + # See official parser + # https://git.dpkg.org/cgit/dpkg/dpkg.git/tree/lib/dpkg/parsehelp.c?id=9e0c88ec09475f4d1addde9cdba1ad7849720356#n122 + # @debian_package_name_regex ||= %r{\A[a-z0-9][-+\._a-z0-9]*\z}i.freeze + # But we prefer a more strict version from Lintian + # https://salsa.debian.org/lintian/lintian/-/blob/5080c0068ffc4a9ddee92022a91d0c2ff53e56d1/lib/Lintian/Util.pm#L116 + @debian_package_name_regex ||= %r{\A[a-z0-9][-+\.a-z0-9]+\z}.freeze + end + + def debian_version_regex + # See official parser: https://git.dpkg.org/cgit/dpkg/dpkg.git/tree/lib/dpkg/parsehelp.c?id=9e0c88ec09475f4d1addde9cdba1ad7849720356#n205 + @debian_version_regex ||= %r{ + \A(?: + (?:([0-9]{1,9}):)? (?# epoch) + ([0-9][0-9a-z\.+~]*) (?# version) + (-[0-9a-z\.+~]+){0,14} (?# -revision) + (?<!-) + )\z}xi.freeze + end + + def debian_architecture_regex + # See official parser: https://git.dpkg.org/cgit/dpkg/dpkg.git/tree/lib/dpkg/arch.c?id=9e0c88ec09475f4d1addde9cdba1ad7849720356#n43 + # But we limit to lower case + @debian_architecture_regex ||= %r{\A#{::Packages::Debian::ARCHITECTURE_REGEX}\z}o.freeze + end + + def debian_distribution_regex + @debian_distribution_regex ||= %r{\A#{::Packages::Debian::DISTRIBUTION_REGEX}\z}io.freeze + end + + def debian_component_regex + @debian_component_regex ||= %r{\A#{::Packages::Debian::COMPONENT_REGEX}\z}o.freeze + end + + def debian_direct_upload_filename_regex + @debian_direct_upload_filename_regex ||= %r{\A.*\.(deb|udeb|ddeb)\z}o.freeze + end + + def helm_channel_regex + @helm_channel_regex ||= %r{\A([a-zA-Z0-9](\.|-|_)?){1,255}(?<!\.|-|_)\z}.freeze + end + + def helm_package_regex + @helm_package_regex ||= %r{#{helm_channel_regex}}.freeze + end + + def helm_version_regex + # identical to semver_regex, with optional preceding 'v' + @helm_version_regex ||= Regexp.new("\\Av?#{::Gitlab::Regex.unbounded_semver_regex.source}\\z", ::Gitlab::Regex.unbounded_semver_regex.options) + end + + def unbounded_semver_regex + # See the official regex: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + + # The order of the alternatives in <prerelease> are intentionally + # reordered to be greedy. Without this change, the unbounded regex would + # only partially match "v0.0.0-20201230123456-abcdefabcdef". + @unbounded_semver_regex ||= / + #{_semver_major_minor_patch_regex}#{_semver_prerelease_build_regex} + /x.freeze + end + + def semver_regex + @semver_regex ||= Regexp.new("\\A#{::Gitlab::Regex.unbounded_semver_regex.source}\\z", ::Gitlab::Regex.unbounded_semver_regex.options).freeze + end + + def semver_regex_message + 'should follow SemVer: https://semver.org' + end + + # These partial semver regexes are intended for use in composing other + # regexes rather than being used alone. + def _semver_major_minor_patch_regex + @_semver_major_minor_patch_regex ||= / + #{_semver_major_regex}\.#{_semver_minor_regex}\.#{_semver_patch_regex} + /x.freeze + end + + def _semver_major_regex + @_semver_major_regex ||= / + (?<major>0|[1-9]\d*) + /x.freeze + end + + def _semver_minor_regex + @_semver_minor_regex ||= / + (?<minor>0|[1-9]\d*) + /x.freeze + end + + def _semver_patch_regex + @_semver_patch_regex ||= / + (?<patch>0|[1-9]\d*) + /x.freeze + end + + def _semver_prerelease_build_regex + @_semver_prerelease_build_regex ||= / + (?:-(?<prerelease>(?:\d*[a-zA-Z-][0-9a-zA-Z-]*|[1-9]\d*|0)(?:\.(?:\d*[a-zA-Z-][0-9a-zA-Z-]*|[1-9]\d*|0))*))? + (?:\+(?<build>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))? + /x.freeze + end + + def prefixed_semver_regex + # identical to semver_regex, except starting with 'v' + @prefixed_semver_regex ||= Regexp.new("\\Av#{::Gitlab::Regex.unbounded_semver_regex.source}\\z", ::Gitlab::Regex.unbounded_semver_regex.options) + end + + def go_package_regex + # A Go package name looks like a URL but is not; it: + # - Must not have a scheme, such as http:// or https:// + # - Must not have a port number, such as :8080 or :8443 + + @go_package_regex ||= %r{ + \b (?# word boundary) + (?<domain> + [0-9a-z](?:(?:-|[0-9a-z]){0,61}[0-9a-z])? (?# first domain) + (?:\.[0-9a-z](?:(?:-|[0-9a-z]){0,61}[0-9a-z])?)* (?# inner domains) + \.[a-z]{2,} (?# top-level domain) + ) + (?<path>/(?: + [-/$_.+!*'(),0-9a-z] (?# plain URL character) + | %[0-9a-f]{2})* (?# URL encoded character) + )? (?# path) + \b (?# word boundary) + }ix.freeze + end + + def generic_package_version_regex + maven_version_regex + end + + def generic_package_name_regex + maven_file_name_regex + end + + def generic_package_file_name_regex + generic_package_name_regex + end + + def sha256_regex + @sha256_regex ||= /\A[0-9a-f]{64}\z/i.freeze + end + + def slack_link_regex + @slack_link_regex ||= /<(.*[|].*)>/i.freeze + end + + private + + def conan_name_regex + @conan_name_regex ||= %r{[a-zA-Z0-9_][a-zA-Z0-9_\+\.-]{1,49}}.freeze + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c1a96a97fe2..4b557fd1aef 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -6982,7 +6982,7 @@ msgstr "" msgid "Available group runners: %{runners}" msgstr "" -msgid "Available on-demand" +msgid "Available on demand" msgstr "" msgid "Avatar for %{assigneeName}" @@ -16586,6 +16586,9 @@ msgstr "" msgid "DiscordService|Discord Notifications" msgstr "" +msgid "DiscordService|Override the default webhook (e.g. https://discord.com/api/webhooks/…)" +msgstr "" + msgid "DiscordService|Send notifications about project events to a Discord channel." msgstr "" @@ -19827,9 +19830,15 @@ msgstr "" msgid "For a faster browsing experience, some files are collapsed by default." msgstr "" +msgid "For additional information, review your %{link_to} or contact your %{project_or_group} owner." +msgstr "" + msgid "For additional information, review your %{link_to} or contact your group owner." msgstr "" +msgid "For additional information, review your %{project_or_group} membership: %{url} or contact your %{project_or_group} owner." +msgstr "" + msgid "For additional information, review your group membership: %{link_to} or contact your group owner." msgstr "" @@ -42120,6 +42129,9 @@ msgstr "" msgid "SecurityReports|%{firstProject}, %{secondProject}, and %{rest}" msgstr "" +msgid "SecurityReports|A comment is required when dismissing." +msgstr "" + msgid "SecurityReports|Activity" msgstr "" @@ -42444,6 +42456,9 @@ msgstr "" msgid "SecurityReports|These vulnerabilities were detected in external sources. They are not necessarily tied to your GitLab project. For example, running containers, URLs, and so on." msgstr "" +msgid "SecurityReports|This selection is required." +msgstr "" + msgid "SecurityReports|To widen your search, change or remove filters above" msgstr "" @@ -53880,6 +53895,15 @@ msgstr "" msgid "Your membership in %{group} no longer expires." msgstr "" +msgid "Your membership in %{link_to} %{project_or_group_name} will expire in %{days_formatted}." +msgstr "" + +msgid "Your membership in %{project_or_group} %{project_or_group_name} will expire in %{days_formatted}." +msgstr "" + +msgid "Your membership will expire in %{days_to_expire} days" +msgstr "" + msgid "Your name" msgstr "" diff --git a/qa/gdk/gdk.yml b/qa/gdk/gdk.yml index 649ac9a60c6..36bf901033c 100644 --- a/qa/gdk/gdk.yml +++ b/qa/gdk/gdk.yml @@ -18,6 +18,7 @@ gitlab: rails: bootsnap: false hostname: gdk.test + application_settings_cache_seconds: 0 gitlab_k8s_agent: enabled: false gitlab_pages: diff --git a/qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb index 78604f08390..7233318bb13 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb @@ -44,7 +44,12 @@ module QA it( 'allows enforcing 2FA via UI and logging in with 2FA', - testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347931' + testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347931', + quarantine: { + type: :bug, + only: { condition: -> { QA::Runtime::Env.super_sidebar_enabled? } }, + issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/409336' + } ) do enforce_two_factor_authentication_on_group(group) diff --git a/rubocop/cop/rspec/before_all.rb b/rubocop/cop/rspec/before_all.rb new file mode 100644 index 00000000000..3fbb9447bc6 --- /dev/null +++ b/rubocop/cop/rspec/before_all.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rubocop-rspec' + +module Rubocop + module Cop + module RSpec + # This cop checks for `before(:all) in RSpec tests` + # + # @example + # + # bad + # + # before(:all) do + # project.repository.add_tag(user, 'v1.2.3', 'master') + # end + # + # good + # + # before_all do + # project.repository.add_tag(user, 'v1.2.3', 'master') + # end + # + class BeforeAll < RuboCop::Cop::Base + extend RuboCop::Cop::AutoCorrector + + MSG = "Prefer using `before_all` over `before(:all)`. See https://docs.gitlab.com/ee/development/testing_guide/best_practices.html#common-test-setup" + + RESTRICT_ON_SEND = %i[before].freeze + + def_node_matcher :before_all_block?, <<~PATTERN + (send nil? :before (sym :all) ...) + PATTERN + + def on_send(node) + return unless before_all_block?(node) + + add_offense(node) do |corrector| + replacement = 'before_all' + corrector.replace(node.source_range, replacement) + end + end + end + end + end +end diff --git a/spec/controllers/projects/settings/integrations_controller_spec.rb b/spec/controllers/projects/settings/integrations_controller_spec.rb index 8c1cdf784aa..49851c82cc5 100644 --- a/spec/controllers/projects/settings/integrations_controller_spec.rb +++ b/spec/controllers/projects/settings/integrations_controller_spec.rb @@ -379,6 +379,26 @@ RSpec.describe Projects::Settings::IntegrationsController, feature_category: :in end end end + + context 'with chat notification integration which masks channel params' do + let_it_be(:integration) do + create(:discord_integration, project: project, note_channel: 'https://discord.com/api/webhook/note') + end + + let(:message) { 'Discord Notifications settings saved and active.' } + + it_behaves_like 'integration update' + + context 'with masked channel param' do + let(:integration_params) { { active: true, note_channel: '************' } } + + it_behaves_like 'integration update' + + it 'does not update the channel' do + expect(integration.reload.note_channel).to eq('https://discord.com/api/webhook/note') + end + end + end end describe 'as JSON' do diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index a89edc19cc7..0fa54e72b7c 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -254,6 +254,13 @@ FactoryBot.define do active { false } end + factory :discord_integration, class: 'Integrations::Discord' do + chat_notification + project + active { true } + type { 'Integrations::Discord' } + end + factory :mattermost_integration, class: 'Integrations::Mattermost' do chat_notification project diff --git a/spec/factories/ml/model_versions.rb b/spec/factories/ml/model_versions.rb index 61d42fa5b5c..456d1b1e913 100644 --- a/spec/factories/ml/model_versions.rb +++ b/spec/factories/ml/model_versions.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :ml_model_versions, class: '::Ml::ModelVersion' do - sequence(:version) { |n| "version#{n}" } + sequence(:version) { |n| "1.0.#{n}-alpha+test" } model { association :ml_models } project { model.project } diff --git a/spec/frontend/service_desk/components/service_desk_list_app_spec.js b/spec/frontend/service_desk/components/service_desk_list_app_spec.js index ffee902e161..59b9da141b8 100644 --- a/spec/frontend/service_desk/components/service_desk_list_app_spec.js +++ b/spec/frontend/service_desk/components/service_desk_list_app_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import { cloneDeep } from 'lodash'; import * as Sentry from '@sentry/browser'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -9,8 +10,8 @@ import { issuableListTabs } from '~/vue_shared/issuable/list/constants'; import { TYPENAME_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { STATUS_CLOSED, STATUS_OPEN } from '~/service_desk/constants'; -import getServiceDeskIssuesQuery from '~/service_desk/queries/get_service_desk_issues.query.graphql'; -import getServiceDeskIssuesCountsQuery from '~/service_desk/queries/get_service_desk_issues_counts.query.graphql'; +import getServiceDeskIssuesQuery from 'ee_else_ce/service_desk/queries/get_service_desk_issues.query.graphql'; +import getServiceDeskIssuesCountsQuery from 'ee_else_ce/service_desk/queries/get_service_desk_issues_counts.query.graphql'; import ServiceDeskListApp from '~/service_desk/components/service_desk_list_app.vue'; import InfoBanner from '~/service_desk/components/info_banner.vue'; import { @@ -39,6 +40,8 @@ describe('ServiceDeskListApp', () => { releasesPath: 'releases/path', autocompleteAwardEmojisPath: 'autocomplete/award/emojis/path', hasIterationsFeature: true, + hasIssueWeightsFeature: true, + hasIssuableHealthStatusFeature: true, groupPath: 'group/path', emptyStateSvgPath: 'empty-state.svg', isProject: true, @@ -48,7 +51,12 @@ describe('ServiceDeskListApp', () => { hasAnyIssues: true, }; - const defaultQueryResponse = getServiceDeskIssuesQueryResponse; + let defaultQueryResponse = getServiceDeskIssuesQueryResponse; + if (IS_EE) { + defaultQueryResponse = cloneDeep(getServiceDeskIssuesQueryResponse); + defaultQueryResponse.data.project.issues.nodes[0].healthStatus = null; + defaultQueryResponse.data.project.issues.nodes[0].weight = 5; + } const mockServiceDeskIssuesQueryResponseHandler = jest .fn() diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js index 83d7d298e17..a486d13a856 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -1,6 +1,8 @@ import hljs from 'highlight.js/lib/core'; import Vue from 'vue'; import VueRouter from 'vue-router'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index'; @@ -16,6 +18,7 @@ import { CODEOWNERS_LANGUAGE, SVELTE_LANGUAGE, } from '~/vue_shared/components/source_viewer/constants'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import waitForPromises from 'helpers/wait_for_promises'; import LineHighlighter from '~/blob/line_highlighter'; import eventHub from '~/notes/event_hub'; @@ -26,6 +29,7 @@ jest.mock('highlight.js/lib/core'); jest.mock('~/vue_shared/components/source_viewer/plugins/index'); Vue.use(VueRouter); const router = new VueRouter(); +const mockAxios = new MockAdapter(axios); const generateContent = (content, totalLines = 1, delimiter = '\n') => { let generatedContent = ''; @@ -72,6 +76,42 @@ describe('Source Viewer component', () => { return createComponent(); }); + describe('Displaying LFS blob', () => { + const rawPath = '/org/project/-/raw/file.xml'; + const externalStorageUrl = 'http://127.0.0.1:9000/lfs-objects/91/12/1341234'; + const rawTextBlob = 'This is the external content'; + const blob = { + storedExternally: true, + externalStorage: 'lfs', + simpleViewer: { fileType: 'text' }, + rawPath, + }; + + afterEach(() => { + mockAxios.reset(); + }); + + it('Uses externalStorageUrl to fetch content if present', async () => { + mockAxios.onGet(externalStorageUrl).replyOnce(HTTP_STATUS_OK, rawTextBlob); + + await createComponent({ ...blob, externalStorageUrl }); + + expect(mockAxios.history.get).toHaveLength(1); + expect(mockAxios.history.get[0].url).toBe(externalStorageUrl); + expect(wrapper.vm.$data.content).toBe(rawTextBlob); + }); + + it('Falls back to rawPath to fetch content', async () => { + mockAxios.onGet(rawPath).replyOnce(HTTP_STATUS_OK, rawTextBlob); + + await createComponent(blob); + + expect(mockAxios.history.get).toHaveLength(1); + expect(mockAxios.history.get[0].url).toBe(rawPath); + expect(wrapper.vm.$data.content).toBe(rawTextBlob); + }); + }); + describe('event tracking', () => { it('fires a tracking event when the component is created', () => { const eventData = { label: EVENT_LABEL_VIEWER, property: language }; diff --git a/spec/helpers/ci/variables_helper_spec.rb b/spec/helpers/ci/variables_helper_spec.rb index 9c3236ace72..13970dd95b4 100644 --- a/spec/helpers/ci/variables_helper_spec.rb +++ b/spec/helpers/ci/variables_helper_spec.rb @@ -3,9 +3,71 @@ require 'spec_helper' RSpec.describe Ci::VariablesHelper, feature_category: :secrets_management do + describe '#create_deploy_token_path' do + let_it_be(:group) { build_stubbed(:group) } + let_it_be(:project) { build_stubbed(:project) } + + it 'returns the project deploy token path' do + expect(helper.create_deploy_token_path(project)).to eq( + create_deploy_token_project_settings_repository_path(project, {}) + ) + end + + it 'returns the group deploy token path' do + expect(helper.create_deploy_token_path(group)).to eq( + create_deploy_token_group_settings_repository_path(group, {}) + ) + end + end + + describe '#ci_variable_protected?' do + let(:variable) { build_stubbed(:ci_variable, key: 'test_key', value: 'test_value', protected: true) } + + context 'when variable is provided and only_key_value is false' do + it 'expect ci_variable_protected? to return true' do + expect(helper.ci_variable_protected?(variable, false)).to eq(true) + end + end + + context 'when variable is not provided / provided and only_key_value is true' do + it 'is equal to the value of ci_variable_protected_by_default?' do + expect(helper.ci_variable_protected?(nil, true)).to eq( + helper.ci_variable_protected_by_default? + ) + + expect(helper.ci_variable_protected?(variable, true)).to eq( + helper.ci_variable_protected_by_default? + ) + end + end + end + + describe '#ci_variable_masked?' do + let(:variable) { build_stubbed(:ci_variable, key: 'test_key', value: 'test_value', masked: true) } + + context 'when variable is provided and only_key_value is false' do + it 'expect ci_variable_masked? to return true' do + expect(helper.ci_variable_masked?(variable, false)).to eq(true) + end + end + + context 'when variable is not provided / provided and only_key_value is true' do + it 'expect ci_variable_masked? to return false' do + expect(helper.ci_variable_masked?(nil, true)).to eq(false) + expect(helper.ci_variable_masked?(variable, true)).to eq(false) + end + end + end + describe '#ci_variable_maskable_raw_regex' do it 'converts to a javascript regex' do expect(helper.ci_variable_maskable_raw_regex).to eq("^\\S{8,}$") end end + + describe '#ci_variable_maskable_regex' do + it 'converts to a javascript regex' do + expect(helper.ci_variable_maskable_regex).to eq("^[a-zA-Z0-9_+=/@:.~-]{8,}$") + end + end end diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index 092d3c07716..c91b99caba2 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -832,6 +832,7 @@ RSpec.describe Gitlab::Regex, feature_category: :tooling do it { is_expected.to match('1.2.3') } it { is_expected.to match('1.2.3-beta') } it { is_expected.to match('1.2.3-alpha.3') } + it { is_expected.to match('1.2.3-alpha.3+abcd') } it { is_expected.not_to match('1') } it { is_expected.not_to match('1.2') } it { is_expected.not_to match('1./2.3') } diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 629dfdaf55e..976fe214c95 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -2056,6 +2056,68 @@ RSpec.describe Notify do end end + describe 'membership about to expire' do + context "with group membership" do + let_it_be(:group_member) { create(:group_member, source: group, expires_at: 7.days.from_now) } + + subject { described_class.member_about_to_expire_email("Namespace", group_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like 'a user cannot unsubscribe through footer link' + it_behaves_like 'appearance header and footer enabled' + it_behaves_like 'appearance header and footer not enabled' + + it 'contains all the useful information' do + is_expected.to deliver_to group_member.user.email + is_expected.to have_subject "Your membership will expire in 7 days" + is_expected.to have_body_text "group will expire in 7 days." + is_expected.to have_body_text group_url(group) + is_expected.to have_body_text group_group_members_url(group) + end + end + + context "with project membership" do + let_it_be(:project_member) { create(:project_member, source: project, expires_at: 7.days.from_now) } + + subject { described_class.member_about_to_expire_email('Project', project_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like 'a user cannot unsubscribe through footer link' + it_behaves_like 'appearance header and footer enabled' + it_behaves_like 'appearance header and footer not enabled' + + it 'contains all the useful information' do + is_expected.to deliver_to project_member.user.email + is_expected.to have_subject "Your membership will expire in 7 days" + is_expected.to have_body_text "project will expire in 7 days." + is_expected.to have_body_text project_url(project) + is_expected.to have_body_text project_project_members_url(project) + end + end + + context "with expired membership" do + let_it_be(:project_member) { create(:project_member, source: project, expires_at: Date.today) } + + subject { described_class.member_about_to_expire_email('Project', project_member.id) } + + it 'not deliver expiry email' do + should_not_email_anyone + end + end + + context "with expiry notified membership" do + let_it_be(:project_member) { create(:project_member, source: project, expires_at: 7.days.from_now, expiry_notified_at: Date.today) } + + subject { described_class.member_about_to_expire_email('Project', project_member.id) } + + it 'not deliver expiry email' do + should_not_email_anyone + end + end + end + describe 'admin notification' do let(:example_site_path) { root_path } let(:user) { create(:user) } diff --git a/spec/migrations/20230714015909_add_index_for_member_expiring_query_spec.rb b/spec/migrations/20230714015909_add_index_for_member_expiring_query_spec.rb new file mode 100644 index 00000000000..524354ecc9a --- /dev/null +++ b/spec/migrations/20230714015909_add_index_for_member_expiring_query_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe AddIndexForMemberExpiringQuery, :migration, feature_category: :groups_and_projects do + let(:index_name) { 'index_members_on_expiring_at_access_level_id' } + + it 'correctly migrates up and down' do + expect(subject).not_to be_index_exists_by_name(:members, index_name) + + migrate! + + expect(subject).to be_index_exists_by_name(:members, index_name) + end +end diff --git a/spec/migrations/add_expiry_notified_at_to_member_spec.rb b/spec/migrations/add_expiry_notified_at_to_member_spec.rb new file mode 100644 index 00000000000..30eaf06529e --- /dev/null +++ b/spec/migrations/add_expiry_notified_at_to_member_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe AddExpiryNotifiedAtToMember, feature_category: :system_access do + let(:members) { table(:members) } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(members.column_names).not_to include('expiry_notified_at') + } + + migration.after -> { + members.reset_column_information + expect(members.column_names).to include('expiry_notified_at') + } + end + end +end diff --git a/spec/models/integrations/discord_spec.rb b/spec/models/integrations/discord_spec.rb index 42ea4a287fe..7ab7308ac1c 100644 --- a/spec/models/integrations/discord_spec.rb +++ b/spec/models/integrations/discord_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe Integrations::Discord do +RSpec.describe Integrations::Discord, feature_category: :integrations do it_behaves_like "chat integration", "Discord notifications" do let(:client) { Discordrb::Webhooks::Client } let(:client_arguments) { { url: webhook_url } } @@ -20,6 +20,26 @@ RSpec.describe Integrations::Discord do end end + describe 'validations' do + let_it_be(:project) { create(:project) } + + subject { integration } + + describe 'only allows one channel on events' do + context 'when given more than one channel' do + let(:integration) { build(:discord_integration, project: project, note_channel: 'webhook1,webhook2') } + + it { is_expected.not_to be_valid } + end + + context 'when given one channel' do + let(:integration) { build(:discord_integration, project: project, note_channel: 'webhook1') } + + it { is_expected.to be_valid } + end + end + end + describe '#execute' do include StubRequests diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index d21edea9751..f8aaae3edad 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -351,6 +351,19 @@ RSpec.describe Member, feature_category: :groups_and_projects do it { is_expected.to include(expiring_tomorrow, not_expiring) } end + describe '.expiring_and_not_notified' do + let_it_be(:expiring_in_5_days) { create(:group_member, expires_at: 5.days.from_now) } + let_it_be(:expiring_in_5_days_with_notified) { create(:group_member, expires_at: 5.days.from_now, expiry_notified_at: Date.today) } + let_it_be(:expiring_in_7_days) { create(:group_member, expires_at: 7.days.from_now) } + let_it_be(:expiring_in_10_days) { create(:group_member, expires_at: 10.days.from_now) } + let_it_be(:not_expiring) { create(:group_member) } + + subject { described_class.expiring_and_not_notified(7.days.from_now.to_date) } + + it { is_expected.not_to include(expiring_in_5_days_with_notified, expiring_in_10_days, not_expiring) } + it { is_expected.to include(expiring_in_5_days, expiring_in_7_days) } + end + describe '.created_today' do let_it_be(:now) { Time.current } let_it_be(:created_today) { create(:group_member, created_at: now.beginning_of_day) } diff --git a/spec/models/ml/model_version_spec.rb b/spec/models/ml/model_version_spec.rb index 8a57a7bc378..4bb272fef5d 100644 --- a/spec/models/ml/model_version_spec.rb +++ b/spec/models/ml/model_version_spec.rb @@ -21,7 +21,7 @@ RSpec.describe Ml::ModelVersion, feature_category: :mlops do end describe 'validation' do - let_it_be(:valid_version) { 'valid_version' } + let_it_be(:valid_version) { '1.0.0' } let_it_be(:valid_package) do build_stubbed(:ml_model_package, project: base_project, version: valid_version, name: model1.name) end @@ -95,7 +95,7 @@ RSpec.describe Ml::ModelVersion, feature_category: :mlops do end describe '#find_or_create!' do - let_it_be(:existing_model_version) { create(:ml_model_versions, model: model1, version: 'abc') } + let_it_be(:existing_model_version) { create(:ml_model_versions, model: model1, version: '1.0.0') } let(:version) { existing_model_version.version } let(:package) { nil } @@ -110,7 +110,7 @@ RSpec.describe Ml::ModelVersion, feature_category: :mlops do end context 'if model version does not exist' do - let(:version) { 'new_version' } + let(:version) { '2.0.0' } let(:package) { create(:ml_model_package, project: model1.project, name: model1.name, version: version) } it 'creates another model version', :aggregate_failures do diff --git a/spec/requests/api/ci/jobs_spec.rb b/spec/requests/api/ci/jobs_spec.rb index ed0cec46a42..c7b7131a600 100644 --- a/spec/requests/api/ci/jobs_spec.rb +++ b/spec/requests/api/ci/jobs_spec.rb @@ -238,7 +238,7 @@ RSpec.describe API::Ci::Jobs, feature_category: :continuous_integration do ] end - before(:all) do + before_all do project.update!(group: group) end diff --git a/spec/rubocop/cop/rspec/before_all_spec.rb b/spec/rubocop/cop/rspec/before_all_spec.rb new file mode 100644 index 00000000000..5cf22bc4093 --- /dev/null +++ b/spec/rubocop/cop/rspec/before_all_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'rubocop_spec_helper' + +require_relative '../../../../rubocop/cop/rspec/before_all' + +RSpec.describe Rubocop::Cop::RSpec::BeforeAll, feature_category: :tooling do + context 'when using before(:all)' do + let(:source) do + <<~SRC + before(:all) do + ^^^^^^^^^^^^ Prefer using `before_all` over `before(:all)`. [...] + create_table_structure + end + SRC + end + + let(:corrected_source) do + <<~SRC + before_all do + create_table_structure + end + SRC + end + + it 'registers an offense and corrects', :aggregate_failures do + expect_offense(source) + + expect_correction(corrected_source) + end + end + + context 'when using before_all' do + let(:source) do + <<~SRC + before_all do + create_table_structure + end + SRC + end + + it 'does not register an offense' do + expect_no_offenses(source) + end + end + + context 'when using before(:each)' do + let(:source) do + <<~SRC + before(:each) do + create_table_structure + end + SRC + end + + it 'does not register an offense' do + expect_no_offenses(source) + end + end + + context 'when using before' do + let(:source) do + <<~SRC + before do + create_table_structure + end + SRC + end + + it 'does not register an offense' do + expect_no_offenses(source) + end + end +end diff --git a/spec/serializers/integrations/event_entity_spec.rb b/spec/serializers/integrations/event_entity_spec.rb index 1b72b5d290c..a15c1bea61a 100644 --- a/spec/serializers/integrations/event_entity_spec.rb +++ b/spec/serializers/integrations/event_entity_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Integrations::EventEntity do +RSpec.describe Integrations::EventEntity, feature_category: :integrations do let(:request) { EntityRequest.new(integration: integration) } subject { described_class.new(event, request: request, integration: integration).as_json } @@ -38,5 +38,24 @@ RSpec.describe Integrations::EventEntity do expect(subject[:field][:placeholder]).to eq('#general, #development') end end + + context 'with integration with fields when channels are masked' do + let(:integration) { create(:integrations_slack, note_events: false, note_channel: 'note-channel') } + let(:event) { 'note' } + + before do + allow(integration).to receive(:mask_configurable_channels?).and_return(true) + end + + it 'exposes correct attributes' do + expect(subject[:description]).to eq('Trigger event for new comments.') + expect(subject[:name]).to eq('note_events') + expect(subject[:title]).to eq('Note') + expect(subject[:value]).to eq(false) + expect(subject[:field][:name]).to eq('note_channel') + expect(subject[:field][:value]).to eq(Integrations::BaseChatNotification::SECRET_MASK) + expect(subject[:field][:placeholder]).to eq('#general, #development') + end + end end end diff --git a/spec/services/members/update_service_spec.rb b/spec/services/members/update_service_spec.rb index 1c4b1abcfdb..3860543a85e 100644 --- a/spec/services/members/update_service_spec.rb +++ b/spec/services/members/update_service_spec.rb @@ -219,6 +219,25 @@ RSpec.describe Members::UpdateService, feature_category: :groups_and_projects do end end end + + context 'when project members expiration date is updated with expiry_notified_at' do + let_it_be(:params) { { expires_at: 20.days.from_now } } + + before do + group_project.group.add_owner(current_user) + members.each do |member| + member.update!(expiry_notified_at: Date.today) + end + end + + it "clear expiry_notified_at" do + subject + + members.each do |member| + expect(member.reload.expiry_notified_at).to be_nil + end + end + end end shared_examples 'updating a group' do @@ -250,6 +269,24 @@ RSpec.describe Members::UpdateService, feature_category: :groups_and_projects do subject end end + + context 'when group members expiration date is updated with expiry_notified_at' do + let_it_be(:params) { { expires_at: 20.days.from_now } } + + before do + members.each do |member| + member.update!(expiry_notified_at: Date.today) + end + end + + it "clear expiry_notified_at" do + subject + + members.each do |member| + expect(member.reload.expiry_notified_at).to be_nil + end + end + end end subject { update_service.execute(members, permission: permission) } diff --git a/spec/services/ml/find_or_create_model_version_service_spec.rb b/spec/services/ml/find_or_create_model_version_service_spec.rb index 5c29a3430d8..1211a9b1165 100644 --- a/spec/services/ml/find_or_create_model_version_service_spec.rb +++ b/spec/services/ml/find_or_create_model_version_service_spec.rb @@ -33,7 +33,7 @@ RSpec.describe ::Ml::FindOrCreateModelVersionService, feature_category: :mlops d context 'when model version does not exist' do let(:project) { existing_version.project } let(:name) { 'a_new_model' } - let(:version) { 'a_new_version' } + let(:version) { '2.0.0' } let(:package) { create(:ml_model_package, project: project, name: name, version: version) } diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 1d1dd045a09..d3cc367649f 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -3380,6 +3380,27 @@ RSpec.describe NotificationService, :mailer, feature_category: :team_planning do end end + describe '#member_about_to_expire' do + let_it_be(:group_member) { create(:group_member, expires_at: 7.days.from_now.to_date) } + let_it_be(:project_member) { create(:project_member, expires_at: 7.days.from_now.to_date) } + + context "with group member" do + it 'emails the user that their group membership will be expired' do + notification.member_about_to_expire(group_member) + + should_email(group_member.user) + end + end + + context "with project member" do + it 'emails the user that their project membership will be expired' do + notification.member_about_to_expire(project_member) + + should_email(project_member.user) + end + end + end + def create_member! create(:project_member, user: added_user, project: project) end diff --git a/spec/workers/members/expiring_email_notification_worker_spec.rb b/spec/workers/members/expiring_email_notification_worker_spec.rb new file mode 100644 index 00000000000..600a81b37b8 --- /dev/null +++ b/spec/workers/members/expiring_email_notification_worker_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Members::ExpiringEmailNotificationWorker, type: :worker, feature_category: :system_access do + subject(:worker) { described_class.new } + + let_it_be(:member) { create(:project_member, :guest, expires_at: 7.days.from_now.to_date) } + let_it_be(:notified_member) do + create(:project_member, :guest, expires_at: 7.days.from_now.to_date, expiry_notified_at: Date.today) + end + + describe '#perform' do + context "with not notified member" do + it "notify member" do + expect_next_instance_of(NotificationService) do |notification_service| + expect(notification_service).to receive(:member_about_to_expire).with(member) + end + + worker.perform(member.id) + + expect(member.reload.expiry_notified_at).to be_present + end + end + + context "with notified member" do + it "not notify member" do + expect_next_instance_of(NotificationService) do |notification_service| + expect(notification_service).not_to receive(:member_about_to_expire).with(notified_member) + end + + worker.perform(notified_member.id) + end + end + + context "when feature member_expiring_email_notification is disabled" do + before do + stub_feature_flags(member_expiring_email_notification: false) + end + + it "not notify member" do + expect_next_instance_of(NotificationService) do |notification_service| + expect(notification_service).not_to receive(:member_about_to_expire).with(member) + end + + worker.perform(member.id) + end + end + end +end diff --git a/spec/workers/members/expiring_worker_spec.rb b/spec/workers/members/expiring_worker_spec.rb new file mode 100644 index 00000000000..3f46548dbb3 --- /dev/null +++ b/spec/workers/members/expiring_worker_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Members::ExpiringWorker, type: :worker, feature_category: :system_access do + subject(:worker) { described_class.new } + + describe '#perform' do + let_it_be(:expiring_7_days_project_member) { create(:project_member, :guest, expires_at: 7.days.from_now) } + let_it_be(:expiring_7_days_group_member) { create(:group_member, :guest, expires_at: 7.days.from_now) } + let_it_be(:expiring_10_days_project_member) { create(:project_member, :guest, expires_at: 10.days.from_now) } + let_it_be(:expiring_5_days_project_member) { create(:project_member, :guest, expires_at: 5.days.from_now) } + let_it_be(:expiring_7_days_blocked_project_member) do + create(:project_member, :guest, :blocked, expires_at: 7.days.from_now) + end + + let(:notifiy_worker) { Members::ExpiringEmailNotificationWorker } + + it "notifies only active users with membership expiring in less than 7 days" do + expect(notifiy_worker).to receive(:perform_async).with(expiring_7_days_project_member.id) + expect(notifiy_worker).to receive(:perform_async).with(expiring_7_days_group_member.id) + expect(notifiy_worker).to receive(:perform_async).with(expiring_5_days_project_member.id) + + worker.perform + end + end +end |