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--app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js47
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js36
-rw-r--r--app/assets/javascripts/pages/projects/init_blob.js1
-rw-r--r--app/assets/javascripts/repository/utils/title.js14
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/carousel.scss202
-rw-r--r--app/models/ci/pipeline.rb2
-rw-r--r--app/services/groups/import_export/import_service.rb61
-rw-r--r--app/workers/all_queues.yml1
-rw-r--r--app/workers/group_export_worker.rb4
-rw-r--r--app/workers/group_import_worker.rb15
-rw-r--r--changelogs/unreleased/es-guest-results.yml5
-rw-r--r--changelogs/unreleased/feature-no-reload-permalink.yml6
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--doc/administration/gitaly/praefect.md25
-rw-r--r--doc/administration/index.md1
-rw-r--r--doc/administration/troubleshooting/ssl.md112
-rw-r--r--doc/development/i18n/externalization.md68
-rw-r--r--lib/gitlab/import_export/base_relation_factory.rb3
-rw-r--r--lib/gitlab/import_export/group_object_builder.rb46
-rw-r--r--lib/gitlab/import_export/group_relation_factory.rb40
-rw-r--r--lib/gitlab/import_export/group_tree_restorer.rb105
-rw-r--r--lib/gitlab/import_export/members_mapper.rb10
-rw-r--r--locale/gitlab.pot54
-rw-r--r--package.json1
-rw-r--r--spec/fixtures/group_export.tar.gzbin4551 -> 2795 bytes
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js107
-rw-r--r--spec/frontend/repository/utils/title_spec.js25
-rw-r--r--spec/lib/gitlab/import_export/group_object_builder_spec.rb44
-rw-r--r--spec/lib/gitlab/import_export/group_relation_factory_spec.rb120
-rw-r--r--spec/lib/gitlab/import_export/group_tree_restorer_spec.rb80
-rw-r--r--spec/services/groups/import_export/import_service_spec.rb38
-rw-r--r--spec/workers/group_import_worker_spec.rb29
33 files changed, 1284 insertions, 21 deletions
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
index 052e33b4a2b..d5d8edd5ac0 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
@@ -1,26 +1,67 @@
import Mousetrap from 'mousetrap';
-import { getLocationHash, visitUrl } from '../../lib/utils/url_utility';
+import {
+ getLocationHash,
+ updateHistory,
+ urlIsDifferent,
+ urlContainsSha,
+ getShaFromUrl,
+} from '~/lib/utils/url_utility';
+import { updateRefPortionOfTitle } from '~/repository/utils/title';
import Shortcuts from './shortcuts';
const defaults = {
skipResetBindings: false,
fileBlobPermalinkUrl: null,
+ fileBlobPermalinkUrlElement: null,
};
+function eventHasModifierKeys(event) {
+ // We ignore alt because I don't think alt clicks normally do anything special?
+ return event.ctrlKey || event.metaKey || event.shiftKey;
+}
+
export default class ShortcutsBlob extends Shortcuts {
constructor(opts) {
const options = Object.assign({}, defaults, opts);
super(options.skipResetBindings);
this.options = options;
+ this.shortcircuitPermalinkButton();
+
Mousetrap.bind('y', this.moveToFilePermalink.bind(this));
}
moveToFilePermalink() {
- if (this.options.fileBlobPermalinkUrl) {
+ const permalink = this.options.fileBlobPermalinkUrl;
+
+ if (permalink) {
const hash = getLocationHash();
const hashUrlString = hash ? `#${hash}` : '';
- visitUrl(`${this.options.fileBlobPermalinkUrl}${hashUrlString}`);
+
+ if (urlIsDifferent(permalink)) {
+ updateHistory({
+ url: `${permalink}${hashUrlString}`,
+ title: document.title,
+ });
+ }
+
+ if (urlContainsSha({ url: permalink })) {
+ updateRefPortionOfTitle(getShaFromUrl({ url: permalink }));
+ }
+ }
+ }
+
+ shortcircuitPermalinkButton() {
+ const button = this.options.fileBlobPermalinkUrlElement;
+ const handleButton = e => {
+ if (!eventHasModifierKeys(e)) {
+ e.preventDefault();
+ this.moveToFilePermalink();
+ }
+ };
+
+ if (button) {
+ button.addEventListener('click', handleButton);
}
}
}
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index d48678c21f6..202363a1dda 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -1,6 +1,14 @@
const PATH_SEPARATOR = '/';
const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`);
const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`);
+const SHA_REGEX = /[\da-f]{40}/gi;
+
+// Reset the cursor in a Regex so that multiple uses before a recompile don't fail
+function resetRegExp(regex) {
+ regex.lastIndex = 0; /* eslint-disable-line no-param-reassign */
+
+ return regex;
+}
// Returns a decoded url parameter value
// - Treats '+' as '%20'
@@ -128,6 +136,20 @@ export function doesHashExistInUrl(hashName) {
return hash && hash.includes(hashName);
}
+export function urlContainsSha({ url = String(window.location) } = {}) {
+ return resetRegExp(SHA_REGEX).test(url);
+}
+
+export function getShaFromUrl({ url = String(window.location) } = {}) {
+ let sha = null;
+
+ if (urlContainsSha({ url })) {
+ [sha] = url.match(resetRegExp(SHA_REGEX));
+ }
+
+ return sha;
+}
+
/**
* Apply the fragment to the given url by returning a new url string that includes
* the fragment. If the given url already contains a fragment, the original fragment
@@ -154,6 +176,16 @@ export function visitUrl(url, external = false) {
}
}
+export function updateHistory({ state = {}, title = '', url, replace = false, win = window } = {}) {
+ if (win.history) {
+ if (replace) {
+ win.history.replaceState(state, title, url);
+ } else {
+ win.history.pushState(state, title, url);
+ }
+ }
+}
+
export function refreshCurrentPage() {
visitUrl(window.location.href);
}
@@ -282,3 +314,7 @@ export const setUrlParams = (params, url = window.location.href, clearParams = f
};
export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
+
+export function urlIsDifferent(url, compare = String(window.location)) {
+ return url !== compare;
+}
diff --git a/app/assets/javascripts/pages/projects/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js
index bd8afa2d5ba..e862456f429 100644
--- a/app/assets/javascripts/pages/projects/init_blob.js
+++ b/app/assets/javascripts/pages/projects/init_blob.js
@@ -25,6 +25,7 @@ export default () => {
new ShortcutsBlob({
skipResetBindings: true,
fileBlobPermalinkUrl,
+ fileBlobPermalinkUrlElement,
});
new BlobForkSuggestion({
diff --git a/app/assets/javascripts/repository/utils/title.js b/app/assets/javascripts/repository/utils/title.js
index ff16fbdd420..9c4b334a1ce 100644
--- a/app/assets/javascripts/repository/utils/title.js
+++ b/app/assets/javascripts/repository/utils/title.js
@@ -1,5 +1,5 @@
const DEFAULT_TITLE = '· GitLab';
-// eslint-disable-next-line import/prefer-default-export
+
export const setTitle = (pathMatch, ref, project) => {
if (!pathMatch) {
document.title = `${project} ${DEFAULT_TITLE}`;
@@ -12,3 +12,15 @@ export const setTitle = (pathMatch, ref, project) => {
/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project} ${DEFAULT_TITLE}`;
};
+
+export function updateRefPortionOfTitle(sha, doc = document) {
+ const { title = '' } = doc;
+ const titleParts = title.split(' · ');
+
+ if (titleParts.length > 1) {
+ titleParts[1] = sha;
+
+ /* eslint-disable-next-line no-param-reassign */
+ doc.title = titleParts.join(' · ');
+ }
+}
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 249e9a24b17..9032dd28b80 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -15,6 +15,7 @@
@import 'framework/badges';
@import 'framework/calendar';
@import 'framework/callout';
+@import 'framework/carousel';
@import 'framework/common';
@import 'framework/dropdowns';
@import 'framework/files';
diff --git a/app/assets/stylesheets/framework/carousel.scss b/app/assets/stylesheets/framework/carousel.scss
new file mode 100644
index 00000000000..d51a9f9c173
--- /dev/null
+++ b/app/assets/stylesheets/framework/carousel.scss
@@ -0,0 +1,202 @@
+// Notes on the classes:
+//
+// 1. .carousel.pointer-event should ideally be pan-y (to allow for users to scroll vertically)
+// even when their scroll action started on a carousel, but for compatibility (with Firefox)
+// we're preventing all actions instead
+// 2. The .carousel-item-left and .carousel-item-right is used to indicate where
+// the active slide is heading.
+// 3. .active.carousel-item is the current slide.
+// 4. .active.carousel-item-left and .active.carousel-item-right is the current
+// slide in its in-transition state. Only one of these occurs at a time.
+// 5. .carousel-item-next.carousel-item-left and .carousel-item-prev.carousel-item-right
+// is the upcoming slide in transition.
+
+.carousel {
+ position: relative;
+
+ &.pointer-event {
+ touch-action: pan-y;
+ }
+}
+
+
+.carousel-inner {
+ position: relative;
+ width: 100%;
+ overflow: hidden;
+ @include clearfix();
+}
+
+.carousel-item {
+ position: relative;
+ display: none;
+ float: left;
+ width: 100%;
+ margin-right: -100%;
+ backface-visibility: hidden;
+ @include transition($carousel-transition);
+}
+
+.carousel-item.active,
+.carousel-item-next,
+.carousel-item-prev {
+ display: block;
+}
+
+.carousel-item-next:not(.carousel-item-left),
+.active.carousel-item-right {
+ transform: translateX(100%);
+}
+
+.carousel-item-prev:not(.carousel-item-right),
+.active.carousel-item-left {
+ transform: translateX(-100%);
+}
+
+
+//
+// Alternate transitions
+//
+
+.carousel-fade {
+ .carousel-item {
+ opacity: 0;
+ transition-property: opacity;
+ transform: none;
+ }
+
+ .carousel-item.active,
+ .carousel-item-next.carousel-item-left,
+ .carousel-item-prev.carousel-item-right {
+ z-index: 1;
+ opacity: 1;
+ }
+
+ .active.carousel-item-left,
+ .active.carousel-item-right {
+ z-index: 0;
+ opacity: 0;
+ @include transition(0s $carousel-transition-duration opacity);
+ }
+}
+
+
+//
+// Left/right controls for nav
+//
+
+.carousel-control-prev,
+.carousel-control-next {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ z-index: 1;
+ // Use flex for alignment (1-3)
+ display: flex; // 1. allow flex styles
+ align-items: center; // 2. vertically center contents
+ justify-content: center; // 3. horizontally center contents
+ width: $carousel-control-width;
+ color: $carousel-control-color;
+ text-align: center;
+ opacity: $carousel-control-opacity;
+ @include transition($carousel-control-transition);
+
+ // Hover/focus state
+ @include hover-focus {
+ color: $carousel-control-color;
+ text-decoration: none;
+ outline: 0;
+ opacity: $carousel-control-hover-opacity;
+ }
+}
+
+.carousel-control-prev {
+ left: 0;
+ @if $enable-gradients {
+ background: linear-gradient(90deg, rgba($black, 0.25), rgba($black, 0.001));
+ }
+}
+
+.carousel-control-next {
+ right: 0;
+ @if $enable-gradients {
+ background: linear-gradient(270deg, rgba($black, 0.25), rgba($black, 0.001));
+ }
+}
+
+// Icons for within
+.carousel-control-prev-icon,
+.carousel-control-next-icon {
+ display: inline-block;
+ width: $carousel-control-icon-width;
+ height: $carousel-control-icon-width;
+ background: no-repeat 50% / 100% 100%;
+}
+
+.carousel-control-prev-icon {
+ background-image: $carousel-control-prev-icon-bg;
+}
+
+.carousel-control-next-icon {
+ background-image: $carousel-control-next-icon-bg;
+}
+
+
+// Optional indicator pips
+//
+// Add an ordered list with the following class and add a list item for each
+// slide your carousel holds.
+
+.carousel-indicators {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 15;
+ display: flex;
+ justify-content: center;
+ padding-left: 0; // override <ol> default
+ // Use the .carousel-control's width as margin so we don't overlay those
+ margin-right: $carousel-control-width;
+ margin-left: $carousel-control-width;
+ list-style: none;
+
+ li {
+ box-sizing: content-box;
+ flex: 0 1 auto;
+ width: $carousel-indicator-width;
+ height: $carousel-indicator-height;
+ margin-right: $carousel-indicator-spacer;
+ margin-left: $carousel-indicator-spacer;
+ text-indent: -999px;
+ cursor: pointer;
+ background-color: $carousel-indicator-active-bg;
+ background-clip: padding-box;
+ // Use transparent borders to increase the hit area by 10px on top and bottom.
+ border-top: $carousel-indicator-hit-area-height solid transparent;
+ border-bottom: $carousel-indicator-hit-area-height solid transparent;
+ opacity: 0.5;
+ @include transition($carousel-indicator-transition);
+ }
+
+ .active {
+ opacity: 1;
+ }
+}
+
+
+// Optional captions
+//
+//
+
+.carousel-caption {
+ position: absolute;
+ right: (100% - $carousel-caption-width) / 2;
+ bottom: 20px;
+ left: (100% - $carousel-caption-width) / 2;
+ z-index: 10;
+ padding-top: 20px;
+ padding-bottom: 20px;
+ color: $carousel-caption-color;
+ text-align: center;
+}
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 7e3ba98d86c..2bf0ee57d39 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -578,7 +578,7 @@ module Ci
# Manually set the notes for a Ci::Pipeline
# There is no ActiveRecord relation between Ci::Pipeline and notes
# as they are related to a commit sha. This method helps importing
- # them using the +Gitlab::ImportExport::RelationFactory+ class.
+ # them using the +Gitlab::ImportExport::ProjectRelationFactory+ class.
def notes=(notes)
notes.each do |note|
note[:id] = nil
diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb
new file mode 100644
index 00000000000..628c8f5bac0
--- /dev/null
+++ b/app/services/groups/import_export/import_service.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Groups
+ module ImportExport
+ class ImportService
+ attr_reader :current_user, :group, :params
+
+ def initialize(group:, user:)
+ @group = group
+ @current_user = user
+ @shared = Gitlab::ImportExport::Shared.new(@group)
+ end
+
+ def execute
+ validate_user_permissions
+
+ if import_file && restorer.restore
+ @group
+ else
+ raise StandardError.new(@shared.errors.to_sentence)
+ end
+ rescue => e
+ raise StandardError.new(e.message)
+ ensure
+ remove_import_file
+ end
+
+ private
+
+ def import_file
+ @import_file ||= Gitlab::ImportExport::FileImporter.import(importable: @group,
+ archive_file: nil,
+ shared: @shared)
+ end
+
+ def restorer
+ @restorer ||= Gitlab::ImportExport::GroupTreeRestorer.new(user: @current_user,
+ shared: @shared,
+ group: @group,
+ group_hash: nil)
+ end
+
+ def remove_import_file
+ upload = @group.import_export_upload
+
+ return unless upload&.import_file&.file
+
+ upload.remove_import_file!
+ upload.save!
+ end
+
+ def validate_user_permissions
+ unless current_user.can?(:admin_group, group)
+ raise ::Gitlab::ImportExport::Error.new(
+ "User with ID: %s does not have permission to Group %s with ID: %s." %
+ [current_user.id, group.name, group.id])
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 87feecf4bbb..1e7b5a1cf65 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -137,6 +137,7 @@
- gitlab_shell
- group_destroy
- group_export
+- group_import
- import_issues_csv
- invalid_gpg_signature_update
- irker
diff --git a/app/workers/group_export_worker.rb b/app/workers/group_export_worker.rb
index 51dbdc95661..a2d34e8c8bf 100644
--- a/app/workers/group_export_worker.rb
+++ b/app/workers/group_export_worker.rb
@@ -4,11 +4,11 @@ class GroupExportWorker
include ApplicationWorker
include ExceptionBacktrace
- feature_category :source_code_management
+ feature_category :importers
def perform(current_user_id, group_id, params = {})
current_user = User.find(current_user_id)
- group = Group.find(group_id)
+ group = Group.find(group_id)
::Groups::ImportExport::ExportService.new(group: group, user: current_user, params: params).execute
end
diff --git a/app/workers/group_import_worker.rb b/app/workers/group_import_worker.rb
new file mode 100644
index 00000000000..f283eab5814
--- /dev/null
+++ b/app/workers/group_import_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class GroupImportWorker
+ include ApplicationWorker
+ include ExceptionBacktrace
+
+ feature_category :importers
+
+ def perform(user_id, group_id)
+ current_user = User.find(user_id)
+ group = Group.find(group_id)
+
+ ::Groups::ImportExport::ImportService.new(group: group, user: current_user).execute
+ end
+end
diff --git a/changelogs/unreleased/es-guest-results.yml b/changelogs/unreleased/es-guest-results.yml
new file mode 100644
index 00000000000..6a3d955d986
--- /dev/null
+++ b/changelogs/unreleased/es-guest-results.yml
@@ -0,0 +1,5 @@
+---
+title: Fix advanced global search permissions for guest users
+merge_request: 23177
+author:
+type: fixed
diff --git a/changelogs/unreleased/feature-no-reload-permalink.yml b/changelogs/unreleased/feature-no-reload-permalink.yml
new file mode 100644
index 00000000000..67d81ea521b
--- /dev/null
+++ b/changelogs/unreleased/feature-no-reload-permalink.yml
@@ -0,0 +1,6 @@
+---
+title: When switching to a file permalink, just change the URL instead of triggering
+ a useless page reload
+merge_request: 22340
+author:
+type: added
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 4f8d93e66bb..c184551f510 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -110,6 +110,8 @@
- 1
- - group_export
- 1
+- - group_import
+ - 1
- - hashed_storage
- 1
- - import_issues_csv
diff --git a/doc/administration/gitaly/praefect.md b/doc/administration/gitaly/praefect.md
index 6a1fb0cde2b..73a020b87d0 100644
--- a/doc/administration/gitaly/praefect.md
+++ b/doc/administration/gitaly/praefect.md
@@ -72,9 +72,11 @@ We need to manage the following secrets and make them match across hosts:
1. `PRAEFECT_SQL_PASSWORD`: this password is used by Praefect to connect to
PostgreSQL.
+We will note in the instructions below where these secrets are required.
+
#### Network addresses
-1. `POSTGRESQL_SERVER`: the host name or IP address of your PostgreSQL server
+1. `POSTGRESQL_SERVER_ADDRESS`: the host name or IP address of your PostgreSQL server
#### PostgreSQL
@@ -91,7 +93,7 @@ Below we assume that you have administrative access as the `postgres`
user. First open a `psql` session as the `postgres` user:
```shell
-/opt/gitlab/embedded/bin/psql -h POSTGRESQL_SERVER -U postgres -d template1
+/opt/gitlab/embedded/bin/psql -h POSTGRESQL_SERVER_ADDRESS -U postgres -d template1
```
Once you are connected, run the following command. Replace
@@ -107,7 +109,7 @@ Now connect as the `praefect` user to create the database. This has
the side effect of verifying that you have access:
```shell
-/opt/gitlab/embedded/bin/psql -h POSTGRESQL_SERVER -U praefect -d template1
+/opt/gitlab/embedded/bin/psql -h POSTGRESQL_SERVER_ADDRESS -U praefect -d template1
```
Once you have connected as the `praefect` user, run:
@@ -125,6 +127,12 @@ Gitaly node that will be connected to Praefect as members of the `praefect` hash
In the example below, the Gitaly nodes are named `gitaly-N`. Note that one
node is designated as primary by setting the primary to `true`.
+If you are using an uncrypted connection to Postgres, set `praefect['database_sslmode']` to false.
+
+If you are using an encrypted connection with a client certificate,
+`praefect['database_sslcert']` and `praefect['database_sslkey']` will need to be set.
+If you are using a custom CA, also set `praefect['database_sslrootcert']`:
+
```ruby
# /etc/gitlab/gitlab.rb on praefect server
@@ -174,7 +182,7 @@ praefect['virtual_storages'] = {
}
# Replace POSTGRESQL_SERVER below with a real IP/host address of the database.
-praefect['database_host'] = 'POSTGRESQL_SERVER'
+praefect['database_host'] = 'POSTGRESQL_SERVER_ADDRESS'
praefect['database_port'] = 5432
praefect['database_user'] = 'praefect'
# Replace PRAEFECT_SQL_PASSWORD below with a real password of the database.
@@ -195,6 +203,9 @@ praefect['database_dbname'] = 'praefect_production'
# praefect['database_sslrootcert'] = '/path/to/rootcert'
```
+Replace `POSTGRESQL_SERVER_ADDRESS`, `PRAEFECT_EXTERNAL_TOKEN`, `PRAEFECT_INTERNAL_TOKEN`,
+and `PRAEFECT_SQL_PASSWORD` with their respective values.
+
Save the file and [reconfigure Praefect](../restart_gitlab.md#omnibus-gitlab-reconfigure).
After you reconfigure, verify that Praefect can reach PostgreSQL:
@@ -260,6 +271,9 @@ git_data_dirs({
})
```
+Replace `GITLAB_SHELL_SECRET_TOKEN` and `PRAEFECT_INTERNAL_TOKEN`
+with their respective values.
+
For more information on Gitaly server configuration, see our [Gitaly documentation](index.md#3-gitaly-server-configuration).
When finished editing the configuration file for each Gitaly server, run the
@@ -302,6 +316,9 @@ git_data_dirs({
gitlab_shell['secret_token'] = 'GITLAB_SHELL_SECRET_TOKEN'
```
+Replace `GITLAB_SHELL_SECRET_TOKEN` and `PRAEFECT_EXTERNAL_TOKEN`
+with their respective values.
+
Note that the storage name used is the same as the `praefect['virtual_storage_name']` set
on the Praefect node.
diff --git a/doc/administration/index.md b/doc/administration/index.md
index 35fe066d866..dbe4a351dce 100644
--- a/doc/administration/index.md
+++ b/doc/administration/index.md
@@ -222,6 +222,7 @@ who are aware of the risks.
- [Troubleshooting PostgreSQL](troubleshooting/postgresql.md)
- [Guide to test environments](troubleshooting/test_environments.md) (for Support Engineers)
- [GitLab Rails console commands](troubleshooting/gitlab_rails_cheat_sheet.md) (for Support Engineers)
+- [Troubleshooting SSL](troubleshooting/ssl.md)
- Useful links:
- [GitLab Developer Docs](../development/README.md)
- [Repairing and recovering broken Git repositories](https://git.seveas.net/repairing-and-recovering-broken-git-repositories.html)
diff --git a/doc/administration/troubleshooting/ssl.md b/doc/administration/troubleshooting/ssl.md
new file mode 100644
index 00000000000..dcda4fbb7a9
--- /dev/null
+++ b/doc/administration/troubleshooting/ssl.md
@@ -0,0 +1,112 @@
+---
+type: reference
+---
+
+# Troubleshooting SSL
+
+This page contains a list of common SSL-related errors and scenarios that you may face while working with GitLab.
+It should serve as an addition to the main SSL docs available here:
+
+- [Omniibus SSL Configuration](https://docs.gitlab.com/omnibus/settings/ssl.html)
+- [Self-signed certificates or custom Certification Authorities for GitLab Runner](https://docs.gitlab.com/runner/configuration/tls-self-signed.html)
+- [Manually configuring HTTPS](https://docs.gitlab.com/omnibus/settings/nginx.html#manually-configuring-https)
+
+## Using an internal CA certificate with GitLab
+
+After configuring a GitLab instance with an internal CA certificate, you might not be able to access it via various CLI tools. You may see the following symptoms:
+
+- `curl` fails:
+
+ ```shell
+ curl https://gitlab.domain.tld
+ curl: (60) SSL certificate problem: unable to get local issuer certificate
+ More details here: https://curl.haxx.se/docs/sslcerts.html
+ ```
+
+- Testing via the [rails console](https://docs.gitlab.com/omnibus/maintenance/#starting-a-rails-console-session) also fails:
+
+ ```ruby
+ uri = URI.parse("https://gitlab.domain.tld")
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = true
+ http.verify_mode = 1
+ response = http.request(Net::HTTP::Get.new(uri.request_uri))
+ ...
+ Traceback (most recent call last):
+ 1: from (irb):5
+ OpenSSL::SSL::SSLError (SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get local issuer certificate))
+ ```
+
+- The error `SSL certificate problem: unable to get local issuer certificate` is shown when setting up a [mirror](../../user/project/repository/repository_mirroring.md#repository-mirroring) from this GitLab instance.
+- `openssl` works when specifying the path to the certificate:
+
+ ```shell
+ /opt/gitlab/embedded/bin/openssl s_client -CAfile /root/my-cert.crt -connect gitlab.domain.tld:443
+ ```
+
+If you have the problems listed above, add your certificate to `/etc/gitlab/trusted-certs` and run `sudo gitlab-ctl reconfigure`.
+
+## Mirroring a remote GitLab repository that uses a self-signed SSL certificate
+
+**Scenario:** When configuring a local GitLab instance to [mirror a repository](../../user/project/repository/repository_mirroring.md) from a remote GitLab instance that uses a self-signed certificate, you may see the `SSL certificate problem: self signed certificate` error in the UI.
+
+The cause of the issue can be confirmed by checking if:
+
+- `curl` fails:
+
+ ```shell
+ $ curl https://gitlab.domain.tld
+ curl: (60) SSL certificate problem: self signed certificate
+ More details here: https://curl.haxx.se/docs/sslcerts.html
+ ```
+
+- Testing via the Rails console also fails:
+
+ ```ruby
+ uri = URI.parse("https://gitlab.domain.tld")
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = true
+ http.verify_mode = 1
+ response = http.request(Net::HTTP::Get.new(uri.request_uri))
+ ...
+ Traceback (most recent call last):
+ 1: from (irb):5
+ OpenSSL::SSL::SSLError (SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get local issuer certificate))
+ ```
+
+To fix this problem:
+
+- Add the self-signed certificate from the remote GitLab instance to the `/etc/gitlab/trusted-certs` directory on the local GitLab instance and run `sudo gitlab-ctl reconfigure` as per the instructions for [installing custom public certificates](https://docs.gitlab.com/omnibus/settings/ssl.html#install-custom-public-certificates).
+- If your local GitLab instance was installed using the Helm Charts, you can [add your self-signed certificate to your GitLab instance](https://docs.gitlab.com/runner/install/kubernetes.html#providing-a-custom-certificate-for-accessing-gitlab).
+
+## Unable to perform Git operations due to an internal or self-signed certificate
+
+If your GitLab instance is using a self-signed certificate, or the certificate is signed by an internal certificate authority (CA), you might run into the following errors when attempting to perform Git operations:
+
+```bash
+$ git clone https://gitlab.domain.tld/group/project.git
+Cloning into 'project'...
+fatal: unable to access 'https://gitlab.domain.tld/group/project.git/': SSL certificate problem: self signed certificate
+```
+
+```bash
+$ git clone https://gitlab.domain.tld/group/project.git
+Cloning into 'project'...
+fatal: unable to access 'https://gitlab.domain.tld/group/project.git/': server certificate verification failed. CAfile: /etc/ssl/certs/ca-certificates.crt CRLfile: none
+```
+
+To fix this problem:
+
+- If possible, use SSH remotes for all Git operations. This is considered more secure and convenient to use.
+- If you must use HTTPS remotes, you can try the following:
+ - Copy the self signed certificate or the internal root CA certificate to a local directory (for example, `~/.ssl`) and configure Git to trust your certificate:
+
+ ```shell
+ git config --global http.sslCAInfo ~/.ssl/gitlab.domain.tld.crt
+ ```
+
+ - Disable SSL verification in your Git client. Note that this intended as a temporary measure as it could be considered a **security risk**.
+
+ ```bash
+ git config --global http.sslVerify false
+ ```
diff --git a/doc/development/i18n/externalization.md b/doc/development/i18n/externalization.md
index 86ac70ecef6..b9ab5f4e8ff 100644
--- a/doc/development/i18n/externalization.md
+++ b/doc/development/i18n/externalization.md
@@ -161,7 +161,11 @@ For example use `%{created_at}` in Ruby but `%{createdAt}` in JavaScript. Make s
_("Hello %{name}") % { name: 'Joe' } => 'Hello Joe'
```
-- In JavaScript:
+- In Vue:
+
+ See the section on [Vue component interpolation](#vue-components-interpolation).
+
+- In JavaScript (when Vue cannot be used):
```js
import { __, sprintf } from '~/locale';
@@ -169,14 +173,30 @@ For example use `%{created_at}` in Ruby but `%{createdAt}` in JavaScript. Make s
sprintf(__('Hello %{username}'), { username: 'Joe' }); // => 'Hello Joe'
```
- By default, `sprintf` escapes the placeholder values.
- If you want to take care of that yourself, you can pass `false` as third argument.
+ If you want to use markup within the translation and are using Vue, you
+ **must** use the [`gl-sprintf`](#vue-components-interpolation) component. If
+ for some reason you cannot use Vue, use `sprintf` and stop it from escaping
+ placeholder values by passing `false` as its third argument. You **must**
+ escape any interpolated dynamic values yourself, for instance using
+ `escape` from `lodash`.
```js
+ import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
- sprintf(__('This is %{value}'), { value: '<strong>bold</strong>' }); // => 'This is &lt;strong&gt;bold&lt;/strong&gt;'
- sprintf(__('This is %{value}'), { value: '<strong>bold</strong>' }, false); // => 'This is <strong>bold</strong>'
+ let someDynamicValue = '<script>alert("evil")</script>';
+
+ // Dangerous:
+ sprintf(__('This is %{value}'), { value: `<strong>${someDynamicValue}</strong>`, false);
+ // => 'This is <strong><script>alert('evil')</script></strong>'
+
+ // Incorrect:
+ sprintf(__('This is %{value}'), { value: `<strong>${someDynamicValue}</strong>` });
+ // => 'This is &lt;strong&gt;&lt;script&gt;alert(&#x27;evil&#x27;)&lt;/script&gt;&lt;/strong&gt;'
+
+ // OK:
+ sprintf(__('This is %{value}'), { value: `<strong>${escape(someDynamicValue)}</strong>`, false);
+ // => 'This is <strong>&lt;script&gt;alert(&#x27;evil&#x27;)&lt;/script&gt;</strong>'
```
### Plurals
@@ -326,7 +346,41 @@ This also applies when using links in between translated sentences, otherwise th
= s_('ClusterIntegration|Learn more about %{zones_link_start}zones%{zones_link_end}').html_safe % { zones_link_start: zones_link_start, zones_link_end: '</a>'.html_safe }
```
-- In JavaScript, instead of:
+- In Vue, instead of:
+
+ ```html
+ <template>
+ <div>
+ <gl-sprintf :message="s__('ClusterIntegration|Learn more about %{link}')">
+ <template #link>
+ <gl-link
+ href="https://cloud.google.com/compute/docs/regions-zones/regions-zones"
+ target="_blank"
+ >zones</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ </template>
+ ```
+
+ Set the link starting and ending HTML fragments as placeholders like so:
+
+ ```html
+ <template>
+ <div>
+ <gl-sprintf :message="s__('ClusterIntegration|Learn more about %{linkStart}zones%{linkEnd}')">
+ <template #link="{ content }">
+ <gl-link
+ href="https://cloud.google.com/compute/docs/regions-zones/regions-zones"
+ target="_blank"
+ >{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ </template>
+ ```
+
+- In JavaScript (when Vue cannot be used), instead of:
```js
{{
@@ -336,7 +390,7 @@ This also applies when using links in between translated sentences, otherwise th
}}
```
- Set the link starting and ending HTML fragments as variables like so:
+ Set the link starting and ending HTML fragments as placeholders like so:
```js
{{
diff --git a/lib/gitlab/import_export/base_relation_factory.rb b/lib/gitlab/import_export/base_relation_factory.rb
index 562b549f6a1..d3c8802bcce 100644
--- a/lib/gitlab/import_export/base_relation_factory.rb
+++ b/lib/gitlab/import_export/base_relation_factory.rb
@@ -24,7 +24,8 @@ module Gitlab
last_edited_by_id
merge_user_id
resolved_by_id
- closed_by_id owner_id
+ closed_by_id
+ owner_id
].freeze
TOKEN_RESET_MODELS = %i[Project Namespace Group Ci::Trigger Ci::Build Ci::Runner ProjectHook].freeze
diff --git a/lib/gitlab/import_export/group_object_builder.rb b/lib/gitlab/import_export/group_object_builder.rb
new file mode 100644
index 00000000000..fa426e32b60
--- /dev/null
+++ b/lib/gitlab/import_export/group_object_builder.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module ImportExport
+ # Given a class, it finds or creates a new object at group level.
+ #
+ # Example:
+ # `GroupObjectBuilder.build(Label, label_attributes)`
+ # finds or initializes a label with the given attributes.
+ class GroupObjectBuilder < BaseObjectBuilder
+ def self.build(*args)
+ Group.transaction do
+ super
+ end
+ end
+
+ def initialize(klass, attributes)
+ super
+
+ @group = @attributes['group']
+ end
+
+ private
+
+ attr_reader :group
+
+ def where_clauses
+ [
+ where_clause_base,
+ where_clause_for_title,
+ where_clause_for_description,
+ where_clause_for_created_at
+ ].compact
+ end
+
+ # Returns Arel clause `"{table_name}"."group_id" = {group.id}`
+ def where_clause_base
+ table[:group_id].in(group_and_ancestor_ids)
+ end
+
+ def group_and_ancestor_ids
+ group.ancestors.map(&:id) << group.id
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/import_export/group_relation_factory.rb b/lib/gitlab/import_export/group_relation_factory.rb
new file mode 100644
index 00000000000..e3597af44d2
--- /dev/null
+++ b/lib/gitlab/import_export/group_relation_factory.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module ImportExport
+ class GroupRelationFactory < BaseRelationFactory
+ OVERRIDES = {
+ labels: :group_labels,
+ priorities: :label_priorities,
+ label: :group_label,
+ parent: :epic
+ }.freeze
+
+ EXISTING_OBJECT_RELATIONS = %i[
+ epic
+ epics
+ milestone
+ milestones
+ label
+ labels
+ group_label
+ group_labels
+ ].freeze
+
+ private
+
+ def setup_models
+ setup_note if @relation_name == :notes
+
+ update_group_references
+ end
+
+ def update_group_references
+ return unless self.class.existing_object_relations.include?(@relation_name)
+ return unless @relation_hash['group_id']
+
+ @relation_hash['group_id'] = @importable.id
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/import_export/group_tree_restorer.rb b/lib/gitlab/import_export/group_tree_restorer.rb
new file mode 100644
index 00000000000..8230e4ff128
--- /dev/null
+++ b/lib/gitlab/import_export/group_tree_restorer.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module ImportExport
+ class GroupTreeRestorer
+ attr_reader :user
+ attr_reader :shared
+ attr_reader :group
+
+ def initialize(user:, shared:, group:, group_hash:)
+ @path = File.join(shared.export_path, 'group.json')
+ @user = user
+ @shared = shared
+ @group = group
+ @group_hash = group_hash
+ end
+
+ def restore
+ @tree_hash = @group_hash || read_tree_hash
+ @group_members = @tree_hash.delete('members')
+ @children = @tree_hash.delete('children')
+
+ if members_mapper.map && restorer.restore
+ @children&.each do |group_hash|
+ group = create_group(group_hash: group_hash, parent_group: @group)
+ shared = Gitlab::ImportExport::Shared.new(group)
+
+ self.class.new(
+ user: @user,
+ shared: shared,
+ group: group,
+ group_hash: group_hash
+ ).restore
+ end
+ end
+
+ return false if @shared.errors.any?
+
+ true
+ rescue => e
+ @shared.error(e)
+ false
+ end
+
+ private
+
+ def read_tree_hash
+ json = IO.read(@path)
+ ActiveSupport::JSON.decode(json)
+ rescue => e
+ @shared.logger.error(
+ group_id: @group.id,
+ group_name: @group.name,
+ message: "Import/Export error: #{e.message}"
+ )
+
+ raise Gitlab::ImportExport::Error.new('Incorrect JSON format')
+ end
+
+ def restorer
+ @relation_tree_restorer ||= RelationTreeRestorer.new(
+ user: @user,
+ shared: @shared,
+ importable: @group,
+ tree_hash: @tree_hash.except('name', 'path'),
+ members_mapper: members_mapper,
+ object_builder: object_builder,
+ relation_factory: relation_factory,
+ reader: reader
+ )
+ end
+
+ def create_group(group_hash:, parent_group:)
+ group_params = {
+ name: group_hash['name'],
+ path: group_hash['path'],
+ parent_id: parent_group&.id
+ }
+
+ ::Groups::CreateService.new(@user, group_params).execute
+ end
+
+ def members_mapper
+ @members_mapper ||= Gitlab::ImportExport::MembersMapper.new(exported_members: @group_members, user: @user, importable: @group)
+ end
+
+ def relation_factory
+ Gitlab::ImportExport::GroupRelationFactory
+ end
+
+ def object_builder
+ Gitlab::ImportExport::GroupObjectBuilder
+ end
+
+ def reader
+ @reader ||= Gitlab::ImportExport::Reader.new(
+ shared: @shared,
+ config: Gitlab::ImportExport::Config.new(
+ config: Gitlab::ImportExport.group_config_file
+ ).to_h
+ )
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/import_export/members_mapper.rb b/lib/gitlab/import_export/members_mapper.rb
index d2e27388b51..68d484d5087 100644
--- a/lib/gitlab/import_export/members_mapper.rb
+++ b/lib/gitlab/import_export/members_mapper.rb
@@ -9,7 +9,7 @@ module Gitlab
@importable = importable
# This needs to run first, as second call would be from #map
- # which means project members already exist.
+ # which means Project/Group members already exist.
ensure_default_member!
end
@@ -47,6 +47,8 @@ module Gitlab
end
def ensure_default_member!
+ return if user_already_member?
+
@importable.members.destroy_all # rubocop: disable DestroyAll
relation_class.create!(user: @user, access_level: relation_class::MAINTAINER, source_id: @importable.id, importing: true)
@@ -54,6 +56,12 @@ module Gitlab
raise e, "Error adding importer user to #{@importable.class} members. #{e.message}"
end
+ def user_already_member?
+ member = @importable.members&.first
+
+ member&.user == @user && member.access_level >= relation_class::MAINTAINER
+ end
+
def add_team_member(member, existing_user = nil)
member['user'] = existing_user
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 77765ab38dc..8570a0ef677 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -16,6 +16,9 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
+msgid " %{start} to %{end}"
+msgstr ""
+
msgid " (from %{timeoutSource})"
msgstr ""
@@ -994,6 +997,9 @@ msgstr ""
msgid "Active Sessions"
msgstr ""
+msgid "Active Users:"
+msgstr ""
+
msgid "Activity"
msgstr ""
@@ -6594,6 +6600,30 @@ msgstr ""
msgid "Discover projects, groups and snippets. Share your projects with others"
msgstr ""
+msgid "Discover|Check your application for security vulnerabilities that may lead to unauthorized access, data leaks, and denial of services."
+msgstr ""
+
+msgid "Discover|For code that's already live in production, our dashboards give you an easy way to prioritize any issues that are found, empowering your team to ship quickly and securely."
+msgstr ""
+
+msgid "Discover|GitLab will perform static and dynamic tests on the code of your application, looking for known flaws and report them in the merge request so you can fix them before merging."
+msgstr ""
+
+msgid "Discover|Give feedback for this page"
+msgstr ""
+
+msgid "Discover|Security capabilities, integrated into your development lifecycle"
+msgstr ""
+
+msgid "Discover|See the other features of the %{linkStart}gold plan%{linkEnd}"
+msgstr ""
+
+msgid "Discover|Start a free trial"
+msgstr ""
+
+msgid "Discover|Upgrade now"
+msgstr ""
+
msgid "Discuss a specific suggestion or question"
msgstr ""
@@ -11550,6 +11580,9 @@ msgstr ""
msgid "Max seats used"
msgstr ""
+msgid "Maximum Users:"
+msgstr ""
+
msgid "Maximum allowable lifetime for personal access token (days)"
msgstr ""
@@ -18508,6 +18541,12 @@ msgstr ""
msgid "The \"Require approval from CODEOWNERS\" setting was moved to %{banner_link_start}Protected Branches%{banner_link_end}"
msgstr ""
+msgid "The %{link_start}true-up model%{link_end} allows having more users, and additional users will incur a retroactive charge on renewal."
+msgstr ""
+
+msgid "The %{true_up_link_start}true-up model%{link_end} has a retroactive charge for these users at the next renewal. If you want to update your license sooner to prevent this, %{support_link_start}please contact our Support team%{link_end}."
+msgstr ""
+
msgid "The %{type} contains the following error:"
msgid_plural "The %{type} contains the following errors:"
msgstr[0] ""
@@ -19163,9 +19202,15 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
+msgid "This is the highest peak of users on your installation since the license started."
+msgstr ""
+
msgid "This is the maximum number of users that have existed at the same time since the license started. This is the minimum number of seats you will need to buy when you renew your license."
msgstr ""
+msgid "This is the number of currently active users on your installation, and this is the minimum number you need to purchase when you renew your license."
+msgstr ""
+
msgid "This is your current session"
msgstr ""
@@ -20754,12 +20799,18 @@ msgstr ""
msgid "Users"
msgstr ""
+msgid "Users in License:"
+msgstr ""
+
msgid "Users or groups set as approvers in the project's or merge request's settings."
msgstr ""
msgid "Users outside of license"
msgstr ""
+msgid "Users over License:"
+msgstr ""
+
msgid "Users requesting access to"
msgstr ""
@@ -21883,6 +21934,9 @@ msgstr ""
msgid "Your issues will be imported in the background. Once finished, you'll get a confirmation email."
msgstr ""
+msgid "Your license is valid from"
+msgstr ""
+
msgid "Your message here"
msgstr ""
diff --git a/package.json b/package.json
index 6b2dd93773b..c8d35ad585e 100644
--- a/package.json
+++ b/package.json
@@ -56,6 +56,7 @@
"babel-loader": "^8.0.6",
"babel-plugin-lodash": "^3.3.4",
"bootstrap": "4.3.1",
+ "bootstrap-vue": "2.1.0",
"brace-expansion": "^1.1.8",
"cache-loader": "^4.1.0",
"chart.js": "2.7.2",
diff --git a/spec/fixtures/group_export.tar.gz b/spec/fixtures/group_export.tar.gz
index 83e360d7cc2..d76c6ddba25 100644
--- a/spec/fixtures/group_export.tar.gz
+++ b/spec/fixtures/group_export.tar.gz
Binary files differ
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index 048736d75f6..989de1a8337 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -1,5 +1,20 @@
import * as urlUtils from '~/lib/utils/url_utility';
+const shas = {
+ valid: [
+ 'ad9be38573f9ee4c4daec22673478c2dd1d81cd8',
+ '76e07a692f65a2f4fd72f107a3e83908bea9b7eb',
+ '9dd8f215b1e8605b1d59eaf9df1178081cda0aaf',
+ 'f2e0be58c4091b033203bae1cc0302febd54117d',
+ ],
+ invalid: [
+ 'zd9be38573f9ee4c4daec22673478c2dd1d81cd8',
+ ':6e07a692f65a2f4fd72f107a3e83908bea9b7eb',
+ '-dd8f215b1e8605b1d59eaf9df1178081cda0aaf',
+ ' 2e0be58c4091b033203bae1cc0302febd54117d',
+ ],
+};
+
const setWindowLocation = value => {
Object.defineProperty(window, 'location', {
writable: true,
@@ -154,6 +169,44 @@ describe('URL utility', () => {
});
});
+ describe('urlContainsSha', () => {
+ it('returns true when there is a valid 40-character SHA1 hash in the URL', () => {
+ shas.valid.forEach(sha => {
+ expect(
+ urlUtils.urlContainsSha({ url: `http://urlstuff/${sha}/moreurlstuff` }),
+ ).toBeTruthy();
+ });
+ });
+
+ it('returns false when there is not a valid 40-character SHA1 hash in the URL', () => {
+ shas.invalid.forEach(str => {
+ expect(urlUtils.urlContainsSha({ url: `http://urlstuff/${str}/moreurlstuff` })).toBeFalsy();
+ });
+ });
+ });
+
+ describe('getShaFromUrl', () => {
+ let validUrls = [];
+ let invalidUrls = [];
+
+ beforeAll(() => {
+ validUrls = shas.valid.map(sha => `http://urlstuff/${sha}/moreurlstuff`);
+ invalidUrls = shas.invalid.map(str => `http://urlstuff/${str}/moreurlstuff`);
+ });
+
+ it('returns the valid 40-character SHA1 hash from the URL', () => {
+ validUrls.forEach((url, idx) => {
+ expect(urlUtils.getShaFromUrl({ url })).toBe(shas.valid[idx]);
+ });
+ });
+
+ it('returns null from a URL with no valid 40-character SHA1 hash', () => {
+ invalidUrls.forEach(url => {
+ expect(urlUtils.getShaFromUrl({ url })).toBeNull();
+ });
+ });
+ });
+
describe('setUrlFragment', () => {
it('should set fragment when url has no fragment', () => {
const url = urlUtils.setUrlFragment('/home/feature', 'usage');
@@ -174,6 +227,44 @@ describe('URL utility', () => {
});
});
+ describe('updateHistory', () => {
+ const state = { key: 'prop' };
+ const title = 'TITLE';
+ const url = 'URL';
+ const win = {
+ history: {
+ pushState: jest.fn(),
+ replaceState: jest.fn(),
+ },
+ };
+
+ beforeEach(() => {
+ win.history.pushState.mockReset();
+ win.history.replaceState.mockReset();
+ });
+
+ it('should call replaceState if the replace option is true', () => {
+ urlUtils.updateHistory({ state, title, url, replace: true, win });
+
+ expect(win.history.replaceState).toHaveBeenCalledWith(state, title, url);
+ expect(win.history.pushState).not.toHaveBeenCalled();
+ });
+
+ it('should call pushState if the replace option is missing', () => {
+ urlUtils.updateHistory({ state, title, url, win });
+
+ expect(win.history.replaceState).not.toHaveBeenCalled();
+ expect(win.history.pushState).toHaveBeenCalledWith(state, title, url);
+ });
+
+ it('should call pushState if the replace option is false', () => {
+ urlUtils.updateHistory({ state, title, url, replace: false, win });
+
+ expect(win.history.replaceState).not.toHaveBeenCalled();
+ expect(win.history.pushState).toHaveBeenCalledWith(state, title, url);
+ });
+ });
+
describe('getBaseURL', () => {
beforeEach(() => {
setWindowLocation({
@@ -331,6 +422,22 @@ describe('URL utility', () => {
});
});
+ describe('urlIsDifferent', () => {
+ beforeEach(() => {
+ setWindowLocation('current');
+ });
+
+ it('should compare against the window location if no compare value is provided', () => {
+ expect(urlUtils.urlIsDifferent('different')).toBeTruthy();
+ expect(urlUtils.urlIsDifferent('current')).toBeFalsy();
+ });
+
+ it('should use the provided compare value', () => {
+ expect(urlUtils.urlIsDifferent('different', 'current')).toBeTruthy();
+ expect(urlUtils.urlIsDifferent('current', 'current')).toBeFalsy();
+ });
+ });
+
describe('setUrlParams', () => {
it('adds new params as query string', () => {
const url = 'https://gitlab.com/test';
diff --git a/spec/frontend/repository/utils/title_spec.js b/spec/frontend/repository/utils/title_spec.js
index 63035933424..a1213c13be8 100644
--- a/spec/frontend/repository/utils/title_spec.js
+++ b/spec/frontend/repository/utils/title_spec.js
@@ -1,4 +1,4 @@
-import { setTitle } from '~/repository/utils/title';
+import { setTitle, updateRefPortionOfTitle } from '~/repository/utils/title';
describe('setTitle', () => {
it.each`
@@ -13,3 +13,26 @@ describe('setTitle', () => {
expect(document.title).toEqual(`${title} · master · GitLab Org / GitLab · GitLab`);
});
});
+
+describe('updateRefPortionOfTitle', () => {
+ const sha = 'abc';
+ const testCases = [
+ [
+ 'updates the title with the SHA',
+ { title: 'part 1 · part 2 · part 3' },
+ 'part 1 · abc · part 3',
+ ],
+ ["makes no change if there's no title", { foo: null }, undefined],
+ [
+ "makes no change if the title doesn't split predictably",
+ { title: 'part 1 - part 2 - part 3' },
+ 'part 1 - part 2 - part 3',
+ ],
+ ];
+
+ it.each(testCases)('%s', (desc, doc, title) => {
+ updateRefPortionOfTitle(sha, doc);
+
+ expect(doc.title).toEqual(title);
+ });
+});
diff --git a/spec/lib/gitlab/import_export/group_object_builder_spec.rb b/spec/lib/gitlab/import_export/group_object_builder_spec.rb
new file mode 100644
index 00000000000..7950a937608
--- /dev/null
+++ b/spec/lib/gitlab/import_export/group_object_builder_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::ImportExport::GroupObjectBuilder do
+ let(:group) { create(:group) }
+ let(:base_attributes) do
+ {
+ 'title' => 'title',
+ 'description' => 'description',
+ 'group' => group
+ }
+ end
+
+ context 'labels' do
+ let(:label_attributes) { base_attributes.merge('type' => 'GroupLabel') }
+
+ it 'finds the existing group label' do
+ group_label = create(:group_label, base_attributes)
+
+ expect(described_class.build(Label, label_attributes)).to eq(group_label)
+ end
+
+ it 'creates a new label' do
+ label = described_class.build(Label, label_attributes)
+
+ expect(label.persisted?).to be true
+ end
+ end
+
+ context 'milestones' do
+ it 'finds the existing group milestone' do
+ milestone = create(:milestone, base_attributes)
+
+ expect(described_class.build(Milestone, base_attributes)).to eq(milestone)
+ end
+
+ it 'creates a new milestone' do
+ milestone = described_class.build(Milestone, base_attributes)
+
+ expect(milestone.persisted?).to be true
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_export/group_relation_factory_spec.rb b/spec/lib/gitlab/import_export/group_relation_factory_spec.rb
new file mode 100644
index 00000000000..9208b2ad203
--- /dev/null
+++ b/spec/lib/gitlab/import_export/group_relation_factory_spec.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::ImportExport::GroupRelationFactory do
+ let(:group) { create(:group) }
+ let(:members_mapper) { double('members_mapper').as_null_object }
+ let(:user) { create(:admin) }
+ let(:excluded_keys) { [] }
+ let(:created_object) do
+ described_class.create(relation_sym: relation_sym,
+ relation_hash: relation_hash,
+ members_mapper: members_mapper,
+ object_builder: Gitlab::ImportExport::GroupObjectBuilder,
+ user: user,
+ importable: group,
+ excluded_keys: excluded_keys)
+ end
+
+ context 'label object' do
+ let(:relation_sym) { :group_label }
+ let(:id) { random_id }
+ let(:original_group_id) { random_id }
+
+ let(:relation_hash) do
+ {
+ 'id' => 123456,
+ 'title' => 'Bruffefunc',
+ 'color' => '#1d2da4',
+ 'project_id' => nil,
+ 'created_at' => '2019-11-20T17:02:20.546Z',
+ 'updated_at' => '2019-11-20T17:02:20.546Z',
+ 'template' => false,
+ 'description' => 'Description',
+ 'group_id' => original_group_id,
+ 'type' => 'GroupLabel',
+ 'priorities' => [],
+ 'textColor' => '#FFFFFF'
+ }
+ end
+
+ it 'does not have the original ID' do
+ expect(created_object.id).not_to eq(id)
+ end
+
+ it 'does not have the original group_id' do
+ expect(created_object.group_id).not_to eq(original_group_id)
+ end
+
+ it 'has the new group_id' do
+ expect(created_object.group_id).to eq(group.id)
+ end
+
+ context 'excluded attributes' do
+ let(:excluded_keys) { %w[description] }
+
+ it 'are removed from the imported object' do
+ expect(created_object.description).to be_nil
+ end
+ end
+ end
+
+ context 'Notes user references' do
+ let(:relation_sym) { :notes }
+ let(:new_user) { create(:user) }
+ let(:exported_member) do
+ {
+ 'id' => 111,
+ 'access_level' => 30,
+ 'source_id' => 1,
+ 'source_type' => 'Namespace',
+ 'user_id' => 3,
+ 'notification_level' => 3,
+ 'created_at' => '2016-11-18T09:29:42.634Z',
+ 'updated_at' => '2016-11-18T09:29:42.634Z',
+ 'user' => {
+ 'id' => 999,
+ 'email' => new_user.email,
+ 'username' => new_user.username
+ }
+ }
+ end
+
+ let(:relation_hash) do
+ {
+ 'id' => 4947,
+ 'note' => 'note',
+ 'noteable_type' => 'Epic',
+ 'author_id' => 999,
+ 'created_at' => '2016-11-18T09:29:42.634Z',
+ 'updated_at' => '2016-11-18T09:29:42.634Z',
+ 'project_id' => 1,
+ 'attachment' => {
+ 'url' => nil
+ },
+ 'noteable_id' => 377,
+ 'system' => true,
+ 'author' => {
+ 'name' => 'Administrator'
+ },
+ 'events' => []
+ }
+ end
+
+ let(:members_mapper) do
+ Gitlab::ImportExport::MembersMapper.new(
+ exported_members: [exported_member],
+ user: user,
+ importable: group)
+ end
+
+ it 'maps the right author to the imported note' do
+ expect(created_object.author).to eq(new_user)
+ end
+ end
+
+ def random_id
+ rand(1000..10000)
+ end
+end
diff --git a/spec/lib/gitlab/import_export/group_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/group_tree_restorer_spec.rb
new file mode 100644
index 00000000000..0c55ed715cc
--- /dev/null
+++ b/spec/lib/gitlab/import_export/group_tree_restorer_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::ImportExport::GroupTreeRestorer do
+ include ImportExport::CommonUtil
+
+ let(:shared) { Gitlab::ImportExport::Shared.new(group) }
+
+ describe 'restore group tree' do
+ before(:context) do
+ # Using an admin for import, so we can check assignment of existing members
+ user = create(:admin, username: 'root')
+ create(:user, username: 'adriene.mcclure')
+ create(:user, username: 'gwendolyn_robel')
+
+ RSpec::Mocks.with_temporary_scope do
+ @group = create(:group, name: 'group', path: 'group')
+ @shared = Gitlab::ImportExport::Shared.new(@group)
+
+ setup_import_export_config('group_exports/complex')
+
+ group_tree_restorer = described_class.new(user: user, shared: @shared, group: @group, group_hash: nil)
+
+ @restored_group_json = group_tree_restorer.restore
+ end
+ end
+
+ context 'JSON' do
+ it 'restores models based on JSON' do
+ expect(@restored_group_json).to be_truthy
+ end
+
+ it 'has the group description' do
+ expect(Group.find_by_path('group').description).to eq('Group Description')
+ end
+
+ it 'has group labels' do
+ expect(@group.labels.count).to eq(10)
+ end
+
+ it 'has issue boards' do
+ expect(@group.boards.count).to eq(2)
+ end
+
+ it 'has badges' do
+ expect(@group.badges.count).to eq(1)
+ end
+
+ it 'has milestones' do
+ expect(@group.milestones.count).to eq(5)
+ end
+
+ it 'has group children' do
+ expect(@group.children.count).to eq(2)
+ end
+
+ it 'has group members' do
+ expect(@group.members.map(&:user).map(&:username)).to contain_exactly('root', 'adriene.mcclure', 'gwendolyn_robel')
+ end
+ end
+ end
+
+ context 'group.json file access check' do
+ let(:user) { create(:user) }
+ let!(:group) { create(:group, name: 'group2', path: 'group2') }
+ let(:group_tree_restorer) { described_class.new(user: user, shared: shared, group: group, group_hash: nil) }
+ let(:restored_group_json) { group_tree_restorer.restore }
+
+ it 'does not read a symlink' do
+ Dir.mktmpdir do |tmpdir|
+ setup_symlink(tmpdir, 'group.json')
+ allow(shared).to receive(:export_path).and_call_original
+
+ expect(group_tree_restorer.restore).to eq(false)
+ expect(shared.errors).to include('Incorrect JSON format')
+ end
+ end
+ end
+end
diff --git a/spec/services/groups/import_export/import_service_spec.rb b/spec/services/groups/import_export/import_service_spec.rb
new file mode 100644
index 00000000000..bac266d08da
--- /dev/null
+++ b/spec/services/groups/import_export/import_service_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Groups::ImportExport::ImportService do
+ describe '#execute' do
+ let(:user) { create(:admin) }
+ let(:group) { create(:group) }
+ let(:service) { described_class.new(group: group, user: user) }
+ let(:import_file) { fixture_file_upload('spec/fixtures/group_export.tar.gz') }
+
+ subject { service.execute }
+
+ before do
+ ImportExportUpload.create(group: group, import_file: import_file)
+ end
+
+ context 'when user has correct permissions' do
+ it 'imports group structure successfully' do
+ expect(subject).to be_truthy
+ end
+
+ it 'removes import file' do
+ subject
+
+ expect(group.import_export_upload.import_file.file).to be_nil
+ end
+ end
+
+ context 'when user does not have correct permissions' do
+ let(:user) { create(:user) }
+
+ it 'raises exception' do
+ expect { subject }.to raise_error(StandardError)
+ end
+ end
+ end
+end
diff --git a/spec/workers/group_import_worker_spec.rb b/spec/workers/group_import_worker_spec.rb
new file mode 100644
index 00000000000..0783ac4df4e
--- /dev/null
+++ b/spec/workers/group_import_worker_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe GroupImportWorker do
+ let!(:user) { create(:user) }
+ let!(:group) { create(:group) }
+
+ subject { described_class.new }
+
+ describe '#perform' do
+ context 'when it succeeds' do
+ it 'calls the ImportService' do
+ expect_any_instance_of(::Groups::ImportExport::ImportService).to receive(:execute)
+
+ subject.perform(user.id, group.id)
+ end
+ end
+
+ context 'when it fails' do
+ it 'raises an exception when params are invalid' do
+ expect_any_instance_of(::Groups::ImportExport::ImportService).not_to receive(:execute)
+
+ expect { subject.perform(1234, group.id) }.to raise_exception(ActiveRecord::RecordNotFound)
+ expect { subject.perform(user.id, 1234) }.to raise_exception(ActiveRecord::RecordNotFound)
+ end
+ end
+ end
+end