Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.rubocop_todo/rspec/missing_feature_category.yml8
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock2
-rw-r--r--app/assets/javascripts/lib/utils/error_utils.js149
-rw-r--r--db/docs/cluster_enabled_grants.yml2
-rw-r--r--db/docs/dependency_proxy_group_settings.yml2
-rw-r--r--db/docs/namespace_details.yml2
-rw-r--r--db/docs/namespace_settings.yml2
-rw-r--r--db/docs/user_group_callouts.yml2
-rw-r--r--doc/administration/backup_restore/backup_large_reference_architectures.md10
-rw-r--r--doc/ci/git_submodules.md2
-rw-r--r--locale/gitlab.pot4
-rw-r--r--qa/lib/gitlab/page/group/settings/usage_quotas.rb7
-rw-r--r--spec/frontend/lib/utils/error_util_spec.js194
-rw-r--r--spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb3
16 files changed, 368 insertions, 25 deletions
diff --git a/.rubocop_todo/rspec/missing_feature_category.yml b/.rubocop_todo/rspec/missing_feature_category.yml
index 7129ed871da..11e9a6a9a7c 100644
--- a/.rubocop_todo/rspec/missing_feature_category.yml
+++ b/.rubocop_todo/rspec/missing_feature_category.yml
@@ -1009,15 +1009,7 @@ RSpec/MissingFeatureCategory:
- 'ee/spec/models/boards/epic_list_user_preference_spec.rb'
- 'ee/spec/models/boards/epic_user_preference_spec.rb'
- 'ee/spec/models/broadcast_message_spec.rb'
- - 'ee/spec/models/ci/bridge_spec.rb'
- 'ee/spec/models/ci/daily_build_group_report_result_spec.rb'
- - 'ee/spec/models/ci/minutes/context_spec.rb'
- - 'ee/spec/models/ci/minutes/namespace_monthly_usage_spec.rb'
- - 'ee/spec/models/ci/minutes/notification_spec.rb'
- - 'ee/spec/models/ci/minutes/project_monthly_usage_spec.rb'
- - 'ee/spec/models/ci/minutes/usage_spec.rb'
- - 'ee/spec/models/ci/pipeline_spec.rb'
- - 'ee/spec/models/ci/processable_spec.rb'
- 'ee/spec/models/ci/sources/project_spec.rb'
- 'ee/spec/models/ci/subscriptions/project_spec.rb'
- 'ee/spec/models/commit_spec.rb'
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index f79e948a1f3..117ac1eafa6 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-e6fe6870aaee291695fed50c2ef9a5de7b3dc356
+e6affcb248bd2254076d25f5904428e48559acaf
diff --git a/Gemfile b/Gemfile
index caae7994572..8ff877f7b2e 100644
--- a/Gemfile
+++ b/Gemfile
@@ -352,7 +352,7 @@ gem 'sentry-sidekiq', '~> 5.8.0'
# PostgreSQL query parsing
#
-gem 'pg_query', '~> 4.2.1'
+gem 'pg_query', '~> 4.2.3'
gem 'gitlab-schema-validation', path: 'gems/gitlab-schema-validation'
diff --git a/Gemfile.lock b/Gemfile.lock
index 7a9f6850465..ac7985383ae 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1935,7 +1935,7 @@ DEPENDENCIES
parslet (~> 1.8)
peek (~> 1.1)
pg (~> 1.5.3)
- pg_query (~> 4.2.1)
+ pg_query (~> 4.2.3)
png_quantizator (~> 0.2.1)
premailer-rails (~> 1.10.3)
prometheus-client-mmap (~> 0.27)
diff --git a/app/assets/javascripts/lib/utils/error_utils.js b/app/assets/javascripts/lib/utils/error_utils.js
new file mode 100644
index 00000000000..82dba803c3e
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/error_utils.js
@@ -0,0 +1,149 @@
+import { isEmpty, isString, isObject } from 'lodash';
+import { sprintf, __ } from '~/locale';
+
+export class ActiveModelError extends Error {
+ constructor(errorAttributeMap = {}, ...params) {
+ // Pass remaining arguments (including vendor specific ones) to parent constructor
+ super(...params);
+
+ // Maintains proper stack trace for where our error was thrown (only available on V8)
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, ActiveModelError);
+ }
+
+ this.name = 'ActiveModelError';
+ // Custom debugging information
+ this.errorAttributeMap = errorAttributeMap;
+ }
+}
+
+const DEFAULT_ERROR = {
+ message: __('Something went wrong. Please try again.'),
+ links: {},
+};
+
+/**
+ * @typedef {Object<ErrorAttribute,ErrorType[]>} ErrorAttributeMap - Map of attributes to error details
+ * @typedef {string} ErrorAttribute - the error attribute https://api.rubyonrails.org/v7.0.4.2/classes/ActiveModel/Error.html
+ * @typedef {string} ErrorType - the error type https://api.rubyonrails.org/v7.0.4.2/classes/ActiveModel/Error.html
+ *
+ * @example { "email": ["taken", ...] }
+ * // returns `${UNLINKED_ACCOUNT_ERROR}`, i.e. the `EMAIL_TAKEN_ERROR_TYPE` error message
+ *
+ * @param {ErrorAttributeMap} errorAttributeMap
+ * @param {Object} errorDictionary
+ * @returns {(null|string)} null or error message if found
+ */
+function getMessageFromType(errorAttributeMap = {}, errorDictionary = {}) {
+ if (!isObject(errorAttributeMap)) {
+ return null;
+ }
+
+ return Object.keys(errorAttributeMap).reduce((_, attribute) => {
+ const errorType = errorAttributeMap[attribute].find(
+ (type) => errorDictionary[`${attribute}:${type}`.toLowerCase()],
+ );
+ if (errorType) {
+ return errorDictionary[`${attribute}:${errorType}`.toLowerCase()];
+ }
+
+ return null;
+ }, null);
+}
+
+/**
+ * @example "Email has already been taken, Email is invalid"
+ * // returns `${UNLINKED_ACCOUNT_ERROR}`, i.e. the `EMAIL_TAKEN_ERROR_TYPE` error message
+ *
+ * @param {string} errorString
+ * @param {Object} errorDictionary
+ * @returns {(null|string)} null or error message if found
+ */
+function getMessageFromErrorString(errorString, errorDictionary = {}) {
+ if (isEmpty(errorString) || !isString(errorString)) {
+ return null;
+ }
+
+ const messages = errorString.split(', ');
+ const errorMessage = messages.find((message) => errorDictionary[message.toLowerCase()]);
+ if (errorMessage) {
+ return errorDictionary[errorMessage.toLowerCase()];
+ }
+
+ return {
+ message: errorString,
+ links: {},
+ };
+}
+
+/**
+ * Receives an Error and attempts to extract the `errorAttributeMap` in
+ * case it is an `ActiveModelError` and returns the message if it exists.
+ * If a match is not found it will attempt to map a message from the
+ * Error.message to be returned.
+ * Otherwise, it will return a general error message.
+ *
+ * @param {Error|String} systemError
+ * @param {Object} errorDictionary
+ * @param {Object} defaultError
+ * @returns error message
+ */
+export function mapSystemToFriendlyError(
+ systemError,
+ errorDictionary = {},
+ defaultError = DEFAULT_ERROR,
+) {
+ if (systemError instanceof String || typeof systemError === 'string') {
+ const messageFromErrorString = getMessageFromErrorString(systemError, errorDictionary);
+ if (messageFromErrorString) {
+ return messageFromErrorString;
+ }
+ return defaultError;
+ }
+
+ if (!(systemError instanceof Error)) {
+ return defaultError;
+ }
+
+ const { errorAttributeMap, message } = systemError;
+ const messageFromType = getMessageFromType(errorAttributeMap, errorDictionary);
+ if (messageFromType) {
+ return messageFromType;
+ }
+
+ const messageFromErrorString = getMessageFromErrorString(message, errorDictionary);
+ if (messageFromErrorString) {
+ return messageFromErrorString;
+ }
+
+ return defaultError;
+}
+
+function generateLinks(links) {
+ return Object.keys(links).reduce((allLinks, link) => {
+ /* eslint-disable-next-line @gitlab/require-i18n-strings */
+ const linkStart = `${link}Start`;
+ /* eslint-disable-next-line @gitlab/require-i18n-strings */
+ const linkEnd = `${link}End`;
+
+ return {
+ ...allLinks,
+ [linkStart]: `<a href="${links[link]}" target="_blank" rel="noopener noreferrer">`,
+ [linkEnd]: '</a>',
+ };
+ }, {});
+}
+
+export const generateHelpTextWithLinks = (error) => {
+ if (isString(error)) {
+ return error;
+ }
+
+ if (isEmpty(error)) {
+ /* eslint-disable-next-line @gitlab/require-i18n-strings */
+ throw new Error('The error cannot be empty.');
+ }
+
+ const links = generateLinks(error.links);
+ return sprintf(error.message, links, false);
+};
diff --git a/db/docs/cluster_enabled_grants.yml b/db/docs/cluster_enabled_grants.yml
index 4c6bef3db0e..59f896f198d 100644
--- a/db/docs/cluster_enabled_grants.yml
+++ b/db/docs/cluster_enabled_grants.yml
@@ -7,4 +7,4 @@ feature_categories:
description: Persists information about namespaces which got an extended life for certificate based clusters
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87149
milestone: '15.1'
-gitlab_schema: gitlab_main
+gitlab_schema: gitlab_main_cell
diff --git a/db/docs/dependency_proxy_group_settings.yml b/db/docs/dependency_proxy_group_settings.yml
index 53ec18594e0..1f9a2995adf 100644
--- a/db/docs/dependency_proxy_group_settings.yml
+++ b/db/docs/dependency_proxy_group_settings.yml
@@ -7,4 +7,4 @@ feature_categories:
description: Group-level settings for the dependency proxy
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/10676
milestone: '11.11'
-gitlab_schema: gitlab_main
+gitlab_schema: gitlab_main_cell
diff --git a/db/docs/namespace_details.yml b/db/docs/namespace_details.yml
index 6b91798bc57..d434f580f1c 100644
--- a/db/docs/namespace_details.yml
+++ b/db/docs/namespace_details.yml
@@ -7,4 +7,4 @@ feature_categories:
description: Used to store details for namespaces
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82958
milestone: '15.3'
-gitlab_schema: gitlab_main
+gitlab_schema: gitlab_main_cell
diff --git a/db/docs/namespace_settings.yml b/db/docs/namespace_settings.yml
index 48187919e43..df723c37fdc 100644
--- a/db/docs/namespace_settings.yml
+++ b/db/docs/namespace_settings.yml
@@ -7,4 +7,4 @@ feature_categories:
description: Stores settings per namespace
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36321
milestone: '13.2'
-gitlab_schema: gitlab_main
+gitlab_schema: gitlab_main_cell
diff --git a/db/docs/user_group_callouts.yml b/db/docs/user_group_callouts.yml
index 41028319708..188fd06e60b 100644
--- a/db/docs/user_group_callouts.yml
+++ b/db/docs/user_group_callouts.yml
@@ -7,4 +7,4 @@ feature_categories:
description: TODO
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68785
milestone: '14.3'
-gitlab_schema: gitlab_main
+gitlab_schema: gitlab_main_cell
diff --git a/doc/administration/backup_restore/backup_large_reference_architectures.md b/doc/administration/backup_restore/backup_large_reference_architectures.md
index b8983613f56..84f8437fc00 100644
--- a/doc/administration/backup_restore/backup_large_reference_architectures.md
+++ b/doc/administration/backup_restore/backup_large_reference_architectures.md
@@ -15,7 +15,6 @@ This document describes how to:
This document is intended for environments using:
- [Linux package (Omnibus) and cloud-native hybrid reference architectures 3,000 users and up](../reference_architectures/index.md)
-- Highly-automated deployment tooling such as [GitLab Environment Toolkit](https://gitlab.com/gitlab-org/gitlab-environment-toolkit)
- [Amazon RDS](https://aws.amazon.com/rds/) for PostgreSQL data
- [Amazon S3](https://aws.amazon.com/s3/) for object storage
- [Object storage](../object_storage.md) to store everything possible, including [blobs](backup_gitlab.md#blobs) and [Container Registry](backup_gitlab.md#container-registry)
@@ -49,7 +48,7 @@ There is a feature proposal to add the ability to back up repositories directly
1. Spin up a VM with 8 vCPU and 7.2 GB memory. This node will be used to back up Git repositories. Note that
[a Praefect node cannot be used to back up Git data](https://gitlab.com/gitlab-org/gitlab/-/issues/396343#note_1385950340).
- 1. Configure the node as another **GitLab Rails** node as defined in your [reference architecture](../reference_architectures/index.md). Use the [GitLab Environment Toolkit `gitlab_rails.yml` playbook](https://gitlab.com/gitlab-org/gitlab-environment-toolkit/-/blob/2.8.5/ansible/playbooks/gitlab_rails.yml). As with other GitLab Rails nodes, this node must have access to your main Postgres database as well as to Gitaly Cluster.
+ 1. Configure the node as another **GitLab Rails** node as defined in your [reference architecture](../reference_architectures/index.md). As with other GitLab Rails nodes, this node must have access to your main Postgres database as well as to Gitaly Cluster.
The backup node will copy all of the environment's Git data, so ensure that it has enough attached storage. For example, you need at least as much storage as one node in a Gitaly Cluster. Without Gitaly Cluster, you need at least as much storage as all Gitaly nodes. Keep in mind that Git repository backups can be significantly larger than Gitaly storage usage because forks are deduplicated in Gitaly but not in backups.
@@ -91,8 +90,9 @@ To back up the Git repositories:
### Configure backup of configuration files
-We strongly recommend using rigorous automation tools such as [Terraform](https://www.terraform.io/) and [Ansible](https://www.ansible.com/) to administer large GitLab environments. [GitLab Environment Toolkit](https://gitlab.com/gitlab-org/gitlab-environment-toolkit) is a good example. You may choose to build up your own deployment tool and use it as a reference.
+If your configuration and secrets are defined outside of your deployment and then deployed into it, then the implementation of the backup strategy depends on your specific setup and requirements. As an example, you can store secrets in [AWS Secret Manager](https://aws.amazon.com/secrets-manager/) with [replication to multiple regions](https://docs.aws.amazon.com/secretsmanager/latest/userguide/create-manage-multi-region-secrets.html) and configure a script to back up secrets automatically.
-Following this approach, your configuration files and secrets should already exist in secure, canonical locations outside of the production VMs or pods. This document does not cover backing up that data.
+If your configuration and secrets are only defined inside your deployment:
-As an example, you can store secrets in [AWS Secret Manager](https://aws.amazon.com/secrets-manager/) and pull them into your [Terraform configuration files](https://gitlab.com/gitlab-org/gitlab-environment-toolkit/-/blob/main/docs/environment_provision.md#terraform-data-sources). [AWS Secret Manager can be configured to replicate to multiple regions](https://docs.aws.amazon.com/secretsmanager/latest/userguide/create-manage-multi-region-secrets.html).
+1. [Storing configuration files](backup_gitlab.md#storing-configuration-files) describes how to extract configuration and secrets files.
+1. These files should be uploaded to a separate, more restrictive, object storage account.
diff --git a/doc/ci/git_submodules.md b/doc/ci/git_submodules.md
index 31cb6bc9946..78de31c70fc 100644
--- a/doc/ci/git_submodules.md
+++ b/doc/ci/git_submodules.md
@@ -116,7 +116,7 @@ To make submodules work correctly in CI/CD jobs:
If you use the [`CI_JOB_TOKEN`](jobs/ci_job_token.md) to clone a submodule in a
pipeline job, the user executing the job must be assigned to a role that has
[permission](../user/permissions.md#gitlab-cicd-permissions) to trigger a pipeline
-in the upstream submodule project.
+in the upstream submodule project. Additionally, [CI/CD job token access](jobs/ci_job_token.md#configure-cicd-job-token-access) must be properly configured in the upstream submodule project.
## Troubleshooting
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index a483920c794..a7225098388 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -41248,10 +41248,10 @@ msgstr ""
msgid "ScanExecutionPolicy|%{period} %{days} at %{time} %{timezoneLabel} %{timezone}"
msgstr ""
-msgid "ScanExecutionPolicy|%{rules} actions for %{scopes} %{branches} %{agents} %{namespaces} %{period}"
+msgid "ScanExecutionPolicy|%{rules} actions for %{scopes} %{branches} %{branchExceptions} %{agents} %{namespaces} %{period}"
msgstr ""
-msgid "ScanExecutionPolicy|%{rules} every time a pipeline runs for %{scopes} %{branches} %{agents} %{namespaces}"
+msgid "ScanExecutionPolicy|%{rules} every time a pipeline runs for %{scopes} %{branches} %{branchExceptions} %{agents} %{namespaces}"
msgstr ""
msgid "ScanExecutionPolicy|A runner will be selected automatically from those available."
diff --git a/qa/lib/gitlab/page/group/settings/usage_quotas.rb b/qa/lib/gitlab/page/group/settings/usage_quotas.rb
index 11d1dd78dbe..cdb0760ad9c 100644
--- a/qa/lib/gitlab/page/group/settings/usage_quotas.rb
+++ b/qa/lib/gitlab/page/group/settings/usage_quotas.rb
@@ -35,6 +35,13 @@ module Gitlab
span :container_registry_size
div :storage_purchased, 'data-testid': 'storage-purchased'
div :storage_purchase_successful_alert, text: /You have successfully purchased a storage/
+ span :project_repository_size
+ span :project_lfs_object_size
+ span :project_build_artifact_size
+ span :project_packages_size
+ span :project_wiki_size
+ span :project_snippets_size
+ span :project_containers_registry_size
# Pending members
div :pending_members
diff --git a/spec/frontend/lib/utils/error_util_spec.js b/spec/frontend/lib/utils/error_util_spec.js
new file mode 100644
index 00000000000..72dcf550428
--- /dev/null
+++ b/spec/frontend/lib/utils/error_util_spec.js
@@ -0,0 +1,194 @@
+import {
+ ActiveModelError,
+ generateHelpTextWithLinks,
+ mapSystemToFriendlyError,
+} from '~/lib/utils/error_utils';
+import { convertObjectPropsToLowerCase } from '~/lib/utils/common_utils';
+
+describe('Error Alert Utils', () => {
+ const unfriendlyErrorOneKey = 'Unfriendly error 1';
+ const emailTakenAttributeMap = 'email:taken';
+ const emailTakenError = 'Email has already been taken';
+ const emailTakenFriendlyError = {
+ message: 'This is a friendly error message for the given attribute map',
+ links: {},
+ };
+
+ const mockErrorDictionary = convertObjectPropsToLowerCase({
+ [unfriendlyErrorOneKey]: {
+ message:
+ 'This is a friendly error with %{linkOneStart}link 1%{linkOneEnd} and %{linkTwoStart}link 2%{linkTwoEnd}',
+ links: {
+ linkOne: '/sample/link/1',
+ linkTwo: '/sample/link/2',
+ },
+ },
+ 'Unfriendly error 2': {
+ message: 'This is a friendly error with only %{linkStart} one link %{linkEnd}',
+ links: {
+ link: '/sample/link/1',
+ },
+ },
+ 'Unfriendly error 3': {
+ message: 'This is a friendly error with no links',
+ links: {},
+ },
+ [emailTakenAttributeMap]: emailTakenFriendlyError,
+ [emailTakenError]: emailTakenFriendlyError,
+ });
+
+ const mockGeneralError = {
+ message: 'Something went wrong',
+ link: {},
+ };
+
+ describe('mapSystemToFriendlyError', () => {
+ describe.each(Object.keys(mockErrorDictionary))('when system error is %s', (systemError) => {
+ const friendlyError = mockErrorDictionary[systemError];
+
+ it('maps the system error to the friendly one', () => {
+ expect(mapSystemToFriendlyError(new Error(systemError), mockErrorDictionary)).toEqual(
+ friendlyError,
+ );
+ });
+
+ it('maps the system error to the friendly one from uppercase', () => {
+ expect(
+ mapSystemToFriendlyError(new Error(systemError.toUpperCase()), mockErrorDictionary),
+ ).toEqual(friendlyError);
+ });
+ });
+
+ describe.each(['', {}, [], undefined, null, new Error()])(
+ 'when system error is %s',
+ (systemError) => {
+ it('defaults to the given general error message when provided', () => {
+ expect(
+ mapSystemToFriendlyError(systemError, mockErrorDictionary, mockGeneralError),
+ ).toEqual(mockGeneralError);
+ });
+
+ it('defaults to the default error message when general error message is not provided', () => {
+ expect(mapSystemToFriendlyError(systemError, mockErrorDictionary)).toEqual({
+ message: 'Something went wrong. Please try again.',
+ links: {},
+ });
+ });
+ },
+ );
+
+ describe('when system error is a non-existent key', () => {
+ const message = 'a non-existent key';
+ const nonExistentKeyError = { message, links: {} };
+
+ it('maps the system error to the friendly one', () => {
+ expect(mapSystemToFriendlyError(new Error(message), mockErrorDictionary)).toEqual(
+ nonExistentKeyError,
+ );
+ });
+ });
+
+ describe('when system error consists of multiple non-existent keys', () => {
+ const message = 'a non-existent key, another non-existent key';
+ const nonExistentKeyError = { message, links: {} };
+
+ it('maps the system error to the friendly one', () => {
+ expect(mapSystemToFriendlyError(new Error(message), mockErrorDictionary)).toEqual(
+ nonExistentKeyError,
+ );
+ });
+ });
+
+ describe('when system error consists of multiple messages with one matching key', () => {
+ const message = `a non-existent key, ${unfriendlyErrorOneKey}`;
+
+ it('maps the system error to the friendly one', () => {
+ expect(mapSystemToFriendlyError(new Error(message), mockErrorDictionary)).toEqual(
+ mockErrorDictionary[unfriendlyErrorOneKey.toLowerCase()],
+ );
+ });
+ });
+
+ describe('when error is email:taken error_attribute_map', () => {
+ const errorAttributeMap = { email: ['taken'] };
+
+ it('maps the email friendly error', () => {
+ expect(
+ mapSystemToFriendlyError(
+ new ActiveModelError(errorAttributeMap, emailTakenError),
+ mockErrorDictionary,
+ ),
+ ).toEqual(mockErrorDictionary[emailTakenAttributeMap.toLowerCase()]);
+ });
+ });
+
+ describe('when there are multiple errors in the error_attribute_map', () => {
+ const errorAttributeMap = { email: ['taken', 'invalid'] };
+
+ it('maps the email friendly error', () => {
+ expect(
+ mapSystemToFriendlyError(
+ new ActiveModelError(errorAttributeMap, `${emailTakenError}, Email is invalid`),
+ mockErrorDictionary,
+ ),
+ ).toEqual(mockErrorDictionary[emailTakenAttributeMap.toLowerCase()]);
+ });
+ });
+ });
+
+ describe('generateHelpTextWithLinks', () => {
+ describe('when the error is present in the dictionary', () => {
+ describe.each(Object.values(mockErrorDictionary))(
+ 'when system error is %s',
+ (friendlyError) => {
+ it('generates the proper link', () => {
+ const errorHtmlString = generateHelpTextWithLinks(friendlyError);
+ const expected = Array.from(friendlyError.message.matchAll(/%{/g)).length / 2;
+ const newNode = document.createElement('div');
+ newNode.innerHTML = errorHtmlString;
+ const links = Array.from(newNode.querySelectorAll('a'));
+
+ expect(links).toHaveLength(expected);
+ });
+ },
+ );
+ });
+
+ describe('when the error contains no links', () => {
+ it('generates the proper link/s', () => {
+ const anError = { message: 'An error', links: {} };
+ const errorHtmlString = generateHelpTextWithLinks(anError);
+ const expected = Object.keys(anError.links).length;
+ const newNode = document.createElement('div');
+ newNode.innerHTML = errorHtmlString;
+ const links = Array.from(newNode.querySelectorAll('a'));
+
+ expect(links).toHaveLength(expected);
+ });
+ });
+
+ describe('when the error is invalid', () => {
+ it('returns the error', () => {
+ expect(() => generateHelpTextWithLinks([])).toThrow(
+ new Error('The error cannot be empty.'),
+ );
+ });
+ });
+
+ describe('when the error is not an object', () => {
+ it('returns the error', () => {
+ const errorHtmlString = generateHelpTextWithLinks('An error');
+
+ expect(errorHtmlString).toBe('An error');
+ });
+ });
+
+ describe('when the error is falsy', () => {
+ it('throws an error', () => {
+ expect(() => generateHelpTextWithLinks(null)).toThrow(
+ new Error('The error cannot be empty.'),
+ );
+ });
+ });
+ });
+});
diff --git a/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb b/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb
index 983358453f5..a11601b6ae0 100644
--- a/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb
+++ b/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb
@@ -14,7 +14,8 @@ RSpec.describe 'cross-database foreign keys' do
'routes.namespace_id', # https://gitlab.com/gitlab-org/gitlab/-/issues/420869
'user_details.enterprise_group_id', # https://gitlab.com/gitlab-org/gitlab/-/issues/420868
'user_details.provisioned_by_group_id', # https://gitlab.com/gitlab-org/gitlab/-/issues/420868
- 'group_import_states.user_id' # https://gitlab.com/gitlab-org/gitlab/-/issues/421210
+ 'group_import_states.user_id', # https://gitlab.com/gitlab-org/gitlab/-/issues/421210
+ 'user_group_callouts.user_id' # https://gitlab.com/gitlab-org/gitlab/-/issues/421287
]
end