diff options
author | Grzegorz Bizon <grzesiek.bizon@gmail.com> | 2017-08-26 11:55:20 +0300 |
---|---|---|
committer | Grzegorz Bizon <grzesiek.bizon@gmail.com> | 2017-08-26 11:55:20 +0300 |
commit | 15ace6a910ecb889c58fd4838a4f9a9b710178c7 (patch) | |
tree | 647da348e903d9acb86f6063e49d958f207ba874 | |
parent | e984a8a3543fa9a26308cea35a42046392f4a488 (diff) | |
parent | 2be34630623711fc20ef8c101b5cef688f207cc1 (diff) |
Merge commit '2be34630623711fc20ef8c101b5cef688f207cc1' into backstage/gb/rename-ci-cd-processing-sidekiq-queues
* commit '2be34630623711fc20ef8c101b5cef688f207cc1':
Common Docker Documentation Location in `gitlab-ce`
fix deprecation warning present during webpack compiles
Enable 5 lines of Sidekiq backtrace lines to aid in debugging
Add support for copying permalink to notes via more actions dropdown
Handle creating a nested group on MySQL correctly
Decrease statuses batch size even more in a migration
Fix repo editor scrollbar
Replace 'source/search_code.feature' spinach test with an rspec analog
Authorizations regarding OAuth - style confirmation
Update README.md
Refactor complicated API group finding rules into GroupsFinder
Fix group and project search for anonymous users
Document version Group Milestones API introduced
Allow v4 API GET requests for groups to be unauthenticated
Adjust a range and a size in stages statuses migration
Update README.md
Point to /developers on docs/administration/authentiq.md
Indexes GFM markdown guide
use inline links instead of referenced
Add index on ci_runners.contacted_at
47 files changed, 543 insertions, 227 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 4319bfcc57f..78cb3def879 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -55,13 +55,18 @@ const Api = { // Return projects list. Filtered by query projects(query, options, callback) { const url = Api.buildUrl(Api.projectsPath); + const defaults = { + search: query, + per_page: 20, + }; + + if (gon.current_user_id) { + defaults.membership = true; + } + return $.ajax({ url, - data: Object.assign({ - search: query, - per_page: 20, - membership: true, - }, options), + data: Object.assign(defaults, options), dataType: 'json', }) .done(projects => callback(projects)); diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js index ab9a8e43dd1..1f3c7e1772d 100644 --- a/app/assets/javascripts/copy_to_clipboard.js +++ b/app/assets/javascripts/copy_to_clipboard.js @@ -29,12 +29,14 @@ showTooltip = function(target, title) { var $target = $(target); var originalTitle = $target.data('original-title'); - $target - .attr('title', 'Copied') - .tooltip('fixTitle') - .tooltip('show') - .attr('title', originalTitle) - .tooltip('fixTitle'); + if (!$target.data('hideTooltip')) { + $target + .attr('title', 'Copied') + .tooltip('fixTitle') + .tooltip('show') + .attr('title', originalTitle) + .tooltip('fixTitle'); + } }; $(function() { diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index d1f00d3ee2c..5b581780447 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -16,6 +16,14 @@ body.modal-open { overflow: hidden; } +.modal-no-backdrop { + @extend .modal-dialog; + + .modal-content { + box-shadow: none; + } +} + @media (min-width: $screen-md-min) { .modal-dialog { width: 860px; diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 37971d6bd3a..1088eca5322 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -182,7 +182,6 @@ padding: 5px 10px; position: relative; border-top: 1px solid $white-normal; - margin-top: -5px; } #binary-viewer { diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index e6fb112e7f2..88d71b0a87b 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -1,3 +1,19 @@ +# GroupsFinder +# +# Used to filter Groups by a set of params +# +# Arguments: +# current_user - which user is requesting groups +# params: +# owned: boolean +# parent: Group +# all_available: boolean (defaults to true) +# +# Users with full private access can see all groups. The `owned` and `parent` +# params can be used to restrict the groups that are returned. +# +# Anonymous users will never return any `owned` groups. They will return all +# public groups instead, even if `all_available` is set to false. class GroupsFinder < UnionFinder def initialize(current_user = nil, params = {}) @current_user = current_user @@ -16,13 +32,13 @@ class GroupsFinder < UnionFinder attr_reader :current_user, :params def all_groups - groups = [] - - if current_user - groups << Gitlab::GroupHierarchy.new(groups_for_ancestors, groups_for_descendants).all_groups - end - groups << Group.unscoped.public_to_user(current_user) + return [owned_groups] if params[:owned] + return [Group.all] if current_user&.full_private_access? + groups = [] + groups << Gitlab::GroupHierarchy.new(groups_for_ancestors, groups_for_descendants).all_groups if current_user + groups << Group.unscoped.public_to_user(current_user) if include_public_groups? + groups << Group.none if groups.empty? groups end @@ -39,4 +55,12 @@ class GroupsFinder < UnionFinder groups.where(parent: params[:parent]) end + + def owned_groups + current_user&.groups || Group.none + end + + def include_public_groups? + current_user.nil? || params.fetch(:all_available, true) + end end diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index bf9ad95b7c2..48cf30a48ab 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -20,6 +20,9 @@ module ButtonHelper def clipboard_button(data = {}) css_class = data[:class] || 'btn-clipboard btn-transparent' title = data[:title] || 'Copy to clipboard' + button_text = data[:button_text] || '' + hide_tooltip = data[:hide_tooltip] || false + hide_button_icon = data[:hide_button_icon] || false # This supports code in app/assets/javascripts/copy_to_clipboard.js that # works around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM. @@ -35,17 +38,22 @@ module ButtonHelper target = data.delete(:target) data[:clipboard_target] = target if target - data = { toggle: 'tooltip', placement: 'bottom', container: 'body' }.merge(data) + unless hide_tooltip + data = { toggle: 'tooltip', placement: 'bottom', container: 'body' }.merge(data) + end - content_tag :button, - icon('clipboard', 'aria-hidden': 'true'), + button_attributes = { class: "btn #{css_class}", data: data, type: :button, title: title, - aria: { - label: title - } + aria: { label: title } + } + + content_tag :button, button_attributes do + concat(icon('clipboard', 'aria-hidden': 'true')) unless hide_button_icon + concat(button_text) + end end def http_clone_button(project, placement = 'right', append_link: true) diff --git a/app/services/groups/nested_create_service.rb b/app/services/groups/nested_create_service.rb index 8d793f5c02e..d6f08fc3cce 100644 --- a/app/services/groups/nested_create_service.rb +++ b/app/services/groups/nested_create_service.rb @@ -15,6 +15,10 @@ module Groups return group end + if group_path.include?('/') && !Group.supports_nested_groups? + raise 'Nested groups are not supported on MySQL' + end + create_group_path end diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index 82aa51f9778..8ba88906714 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -1,39 +1,43 @@ -%h3.page-title Authorization required %main{ :role => "main" } - %p.h4 - Authorize - %strong.text-info= @pre_auth.client.name - to use your account? + .modal-no-backdrop + .modal-content + .modal-header + %h3.page-title + Authorize + = link_to @pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer' + to use your account? - - if current_user.admin? - .text-warning.prepend-top-20 - %p - = icon("exclamation-triangle fw") - You are an admin, which means granting access to - %strong= @pre_auth.client.name - will allow them to interact with GitLab as an admin as well. Proceed with caution. - - - if @pre_auth.scopes - #oauth-permissions - %p This application will be able to: - %ul.text-info - - @pre_auth.scopes.each do |scope| - %li= t scope, scope: [:doorkeeper, :scopes] - %hr/ - .actions - = form_tag oauth_authorization_path, method: :post do - = hidden_field_tag :client_id, @pre_auth.client.uid - = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri - = hidden_field_tag :state, @pre_auth.state - = hidden_field_tag :response_type, @pre_auth.response_type - = hidden_field_tag :scope, @pre_auth.scope - = hidden_field_tag :nonce, @pre_auth.nonce - = submit_tag "Authorize", class: "btn btn-success wide pull-left" - = form_tag oauth_authorization_path, method: :delete do - = hidden_field_tag :client_id, @pre_auth.client.uid - = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri - = hidden_field_tag :state, @pre_auth.state - = hidden_field_tag :response_type, @pre_auth.response_type - = hidden_field_tag :scope, @pre_auth.scope - = hidden_field_tag :nonce, @pre_auth.nonce - = submit_tag "Deny", class: "btn btn-danger prepend-left-10" + .modal-body + - if current_user.admin? + .text-warning + %p + = icon("exclamation-triangle fw") + You are an admin, which means granting access to + %strong= @pre_auth.client.name + will allow them to interact with GitLab as an admin as well. Proceed with caution. + %p + You are about to authorize + = link_to @pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer' + to use your account. + - if @pre_auth.scopes + This application will be able to: + %ul + - @pre_auth.scopes.each do |scope| + %li= t scope, scope: [:doorkeeper, :scopes] + .form-actions.text-right + = form_tag oauth_authorization_path, method: :delete, class: 'inline' do + = hidden_field_tag :client_id, @pre_auth.client.uid + = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri + = hidden_field_tag :state, @pre_auth.state + = hidden_field_tag :response_type, @pre_auth.response_type + = hidden_field_tag :scope, @pre_auth.scope + = hidden_field_tag :nonce, @pre_auth.nonce + = submit_tag "Deny", class: "btn btn-danger" + = form_tag oauth_authorization_path, method: :post, class: 'inline' do + = hidden_field_tag :client_id, @pre_auth.client.uid + = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri + = hidden_field_tag :state, @pre_auth.state + = hidden_field_tag :response_type, @pre_auth.response_type + = hidden_field_tag :scope, @pre_auth.scope + = hidden_field_tag :nonce, @pre_auth.nonce + = submit_tag "Authorize", class: "btn btn-success prepend-left-10" diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml index 5930209a682..7e854186973 100644 --- a/app/views/projects/notes/_more_actions_dropdown.html.haml +++ b/app/views/projects/notes/_more_actions_dropdown.html.haml @@ -6,6 +6,8 @@ %span.icon = custom_icon('ellipsis_v') %ul.dropdown-menu.more-actions-dropdown.dropdown-open-left + %li + = clipboard_button(text: noteable_note_url(note), title: "Copy reference to clipboard", button_text: 'Copy link', hide_tooltip: true, hide_button_icon: true) - unless is_current_user %li = link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml index 3139be1cd37..a4a5cec1314 100644 --- a/app/views/search/_form.html.haml +++ b/app/views/search/_form.html.haml @@ -11,5 +11,5 @@ %span.sr-only Clear search - unless params[:snippets].eql? 'true' - = render 'filter' if current_user + = render 'filter' = button_tag "Search", class: "btn btn-success btn-search" diff --git a/app/workers/concerns/exception_backtrace.rb b/app/workers/concerns/exception_backtrace.rb new file mode 100644 index 00000000000..ea0f1f8d19b --- /dev/null +++ b/app/workers/concerns/exception_backtrace.rb @@ -0,0 +1,8 @@ +# Concern for enabling a few lines of exception backtraces in Sidekiq +module ExceptionBacktrace + extend ActiveSupport::Concern + + included do + sidekiq_options backtrace: 5 + end +end diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb index 07e82767b06..bd8e212e928 100644 --- a/app/workers/group_destroy_worker.rb +++ b/app/workers/group_destroy_worker.rb @@ -1,6 +1,7 @@ class GroupDestroyWorker include Sidekiq::Worker include DedicatedSidekiqQueue + include ExceptionBacktrace def perform(group_id, user_id) begin diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb index 1cfb0be759e..f1cd1769421 100644 --- a/app/workers/namespaceless_project_destroy_worker.rb +++ b/app/workers/namespaceless_project_destroy_worker.rb @@ -7,6 +7,7 @@ class NamespacelessProjectDestroyWorker include Sidekiq::Worker include DedicatedSidekiqQueue + include ExceptionBacktrace def self.bulk_perform_async(args_list) Sidekiq::Client.push_bulk('class' => self, 'queue' => sidekiq_options['queue'], 'args' => args_list) diff --git a/app/workers/project_destroy_worker.rb b/app/workers/project_destroy_worker.rb index a9188b78460..3be7e686609 100644 --- a/app/workers/project_destroy_worker.rb +++ b/app/workers/project_destroy_worker.rb @@ -1,6 +1,7 @@ class ProjectDestroyWorker include Sidekiq::Worker include DedicatedSidekiqQueue + include ExceptionBacktrace def perform(project_id, user_id, params) project = Project.find(project_id) diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb index 6009aa1b191..f13ac9e5db2 100644 --- a/app/workers/project_export_worker.rb +++ b/app/workers/project_export_worker.rb @@ -1,6 +1,7 @@ class ProjectExportWorker include Sidekiq::Worker include DedicatedSidekiqQueue + include ExceptionBacktrace sidekiq_options retry: 3 diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 2c2d1e8b91f..00a021abbdc 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -3,6 +3,7 @@ class RepositoryImportWorker include Sidekiq::Worker include DedicatedSidekiqQueue + include ExceptionBacktrace sidekiq_options status_expiration: StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION diff --git a/changelogs/unreleased/31409-fix-group-and-project-search-for-anonymous-users.yml b/changelogs/unreleased/31409-fix-group-and-project-search-for-anonymous-users.yml new file mode 100644 index 00000000000..06e8180db64 --- /dev/null +++ b/changelogs/unreleased/31409-fix-group-and-project-search-for-anonymous-users.yml @@ -0,0 +1,5 @@ +--- +title: Fix group and project search for anonymous users +merge_request: 13745 +author: +type: fixed diff --git a/changelogs/unreleased/35721-auth-style-confirmation.yml b/changelogs/unreleased/35721-auth-style-confirmation.yml new file mode 100644 index 00000000000..9963f76e845 --- /dev/null +++ b/changelogs/unreleased/35721-auth-style-confirmation.yml @@ -0,0 +1,5 @@ +--- +title: restyling of OAuth authorization confirmation +merge_request: +author: Jacopo Beschi @jacopo-beschi +type: changed diff --git a/changelogs/unreleased/35811-copy-link-note.yml b/changelogs/unreleased/35811-copy-link-note.yml new file mode 100644 index 00000000000..9fa74884c8a --- /dev/null +++ b/changelogs/unreleased/35811-copy-link-note.yml @@ -0,0 +1,5 @@ +--- +title: Add support for copying permalink to notes via more actions dropdown +merge_request: 13299 +author: +type: added diff --git a/changelogs/unreleased/docs-document-version-for-group-milestones-api.yml b/changelogs/unreleased/docs-document-version-for-group-milestones-api.yml new file mode 100644 index 00000000000..d75c46313f4 --- /dev/null +++ b/changelogs/unreleased/docs-document-version-for-group-milestones-api.yml @@ -0,0 +1,5 @@ +--- +title: Document version Group Milestones API introduced +merge_request: +author: +type: changed diff --git a/changelogs/unreleased/replace_spinach_search_code-feature.yml b/changelogs/unreleased/replace_spinach_search_code-feature.yml new file mode 100644 index 00000000000..28d2108c871 --- /dev/null +++ b/changelogs/unreleased/replace_spinach_search_code-feature.yml @@ -0,0 +1,5 @@ +--- +title: Replace 'source/search_code.feature' spinach test with an rspec analog +merge_request: 13697 +author: blackst0ne +type: other diff --git a/config/webpack.config.js b/config/webpack.config.js index 8aa938d538e..7d63a42d7d8 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -170,7 +170,7 @@ var config = { if (chunk.name) { return chunk.name; } - return chunk.modules.map((m) => { + return chunk.mapModules((m) => { var chunkPath = m.request.split('!').pop(); return path.relative(m.context, chunkPath); }).join('_'); diff --git a/db/post_migrate/20170711145558_migrate_stages_statuses.rb b/db/post_migrate/20170711145558_migrate_stages_statuses.rb index 5a24fb1307f..aeb900354db 100644 --- a/db/post_migrate/20170711145558_migrate_stages_statuses.rb +++ b/db/post_migrate/20170711145558_migrate_stages_statuses.rb @@ -6,7 +6,7 @@ class MigrateStagesStatuses < ActiveRecord::Migration disable_ddl_transaction! BATCH_SIZE = 10000 - RANGE_SIZE = 1000 + RANGE_SIZE = 100 MIGRATION = 'MigrateStageStatus'.freeze class Stage < ActiveRecord::Base @@ -17,10 +17,10 @@ class MigrateStagesStatuses < ActiveRecord::Migration def up Stage.where(status: nil).each_batch(of: BATCH_SIZE) do |relation, index| relation.each_batch(of: RANGE_SIZE) do |batch| - range = relation.pluck('MIN(id)', 'MAX(id)').first - schedule = index * 5.minutes + range = batch.pluck('MIN(id)', 'MAX(id)').first + delay = index * 5.minutes - BackgroundMigrationWorker.perform_in(schedule, MIGRATION, range) + BackgroundMigrationWorker.perform_in(delay, MIGRATION, range) end end end diff --git a/doc/administration/auth/authentiq.md b/doc/administration/auth/authentiq.md index 1528f1d2b17..252ff1f4b15 100644 --- a/doc/administration/auth/authentiq.md +++ b/doc/administration/auth/authentiq.md @@ -4,7 +4,7 @@ To enable the Authentiq OmniAuth provider for passwordless authentication you mu Authentiq will generate a Client ID and the accompanying Client Secret for you to use. -1. Get your Client credentials (Client ID and Client Secret) at [Authentiq](https://www.authentiq.com/register). +1. Get your Client credentials (Client ID and Client Secret) at [Authentiq](https://www.authentiq.com/developers). 2. On your GitLab server, open the configuration file: diff --git a/doc/api/group_milestones.md b/doc/api/group_milestones.md index dbfc7529125..a96fb3124fc 100644 --- a/doc/api/group_milestones.md +++ b/doc/api/group_milestones.md @@ -1,5 +1,8 @@ # Group milestones API +> **Notes:** +> [Introduced][ce-12819] in GitLab 9.5. + ## List group milestones Returns a list of group milestones. @@ -118,3 +121,5 @@ Parameters: - `id` (required) - The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user - `milestone_id` (required) - The ID of a group milestone + +[ce-12819]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/12819 diff --git a/doc/api/groups.md b/doc/api/groups.md index 2b3d8e125c8..c2daa8bc029 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -2,7 +2,8 @@ ## List groups -Get a list of groups. (As user: my groups or all available, as admin: all groups). +Get a list of visible groups for the authenticated user. When accessed without +authentication, only public groups are returned. Parameters: @@ -43,7 +44,8 @@ You can search for groups by name or path, see below. ## List a group's projects -Get a list of projects in this group. +Get a list of projects in this group. When accessed without authentication, only +public projects are returned. ``` GET /groups/:id/projects @@ -109,7 +111,8 @@ Example response: ## Details of a group -Get all details of a group. +Get all details of a group. This endpoint can be accessed without authentication +if the group is publicly accessible. ``` GET /groups/:id diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index e55a92dbb71..234dc530db0 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -86,6 +86,11 @@ To follow conventions of naming across GitLab, and to futher move away from the `build` term and toward `job` CI variables have been renamed for the 9.0 release. +>**Note:** +Starting with GitLab 9.0, we have deprecated the `$CI_BUILD_*` variables. **You are +strongly advised to use the new variables as we will remove the old ones in +future GitLab releases.** + | 8.x name | 9.0+ name | | --------------------- |------------------------ | | `CI_BUILD_ID` | `CI_JOB_ID` | diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 1869782fe6e..abf4ec7dbf8 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -1065,6 +1065,8 @@ a list of all previous jobs from which the artifacts should be downloaded. You can only define jobs from stages that are executed before the current one. An error will be shown if you define jobs from the current stage or next ones. Defining an empty array will skip downloading any artifacts for that job. +The status of the previous job is not considered when using `dependencies`, so +if it failed or it is a manual job that was not run, no error occurs. --- diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md index 90d1d9657b9..798f40eef3d 100644 --- a/doc/development/doc_styleguide.md +++ b/doc/development/doc_styleguide.md @@ -113,13 +113,12 @@ merge request. ## Links -- If a link makes the paragraph to span across multiple lines, do not use - the regular Markdown approach: `[Text](https://example.com)`. Instead use - `[Text][identifier]` and at the very bottom of the document add: - `[identifier]: https://example.com`. This is another way to create Markdown - links which keeps the document clear and concise. Bonus points if you also - add an alternative text: `[identifier]: https://example.com "Alternative text"` - that appears when hovering your mouse on a link +- Use the regular inline link markdown markup `[Text](https://example.com)`. + It's easier to read, review, and maintain. +- If there's a link that repeats several times through the same document, + you can use `[Text][identifier]` and at the bottom of the section or the + document add: `[identifier]: https://example.com`, in which case, we do + encourage you to also add an alternative text: `[identifier]: https://example.com "Alternative text"` that appears when hovering your mouse on a link. ### Linking to inline docs diff --git a/doc/install/README.md b/doc/install/README.md index 1d510cb29c3..656f8720361 100644 --- a/doc/install/README.md +++ b/doc/install/README.md @@ -17,7 +17,7 @@ the hardware requirements. - [Installation from source](installation.md) - Install GitLab from source. Useful for unsupported systems like *BSD. For an overview of the directory structure, read the [structure documentation](structure.md). -- [Docker](https://docs.gitlab.com/omnibus/docker/) - Install GitLab using Docker. +- [Docker](docker.md) - Install GitLab using Docker. ## Install GitLab on cloud providers diff --git a/doc/install/docker.md b/doc/install/docker.md new file mode 100644 index 00000000000..933756072ff --- /dev/null +++ b/doc/install/docker.md @@ -0,0 +1,18 @@ +# GitLab Docker images + +[Docker](https://www.docker.com) and container technology have been revolutionizing the software world for the past few years. They combine the performance and efficiency of native execution with the abstraction, security, and immutability of virtualization. + +GitLab provides official Docker images to allowing you to easily take advantage of the benefits of containerization while operating your GitLab instance. + +## Omnibus GitLab based images + +GitLab maintains a set of [official Docker images](https://hub.docker.com/r/gitlab) based on our [Omnibus GitLab package](https://docs.gitlab.com/omnibus/README.html). These images include: +* [GitLab Community Edition](https://hub.docker.com/r/gitlab/gitlab-ce/) +* [GitLab Enterprise Edition](https://hub.docker.com/r/gitlab/gitlab-ee/) +* [GitLab Runner](https://hub.docker.com/r/gitlab/gitlab-runner/) + +A [complete usage guide](https://docs.gitlab.com/omnibus/docker/) to these images is available, as well as the [Dockerfile used for building the images](https://gitlab.com/gitlab-org/omnibus-gitlab/tree/master/docker). + +## Cloud native images + +GitLab is also working towards a [cloud native set of containers](https://gitlab.com/charts/helm.gitlab.io#docker-container-images), with a single image for each component service. We intend for these images to eventually replace the [Omnibus GitLab based images](#omnibus-gitlab-based-images). diff --git a/doc/user/index.md b/doc/user/index.md index e9ec603f2f1..f239a15d441 100644 --- a/doc/user/index.md +++ b/doc/user/index.md @@ -119,6 +119,13 @@ When performing inline reviews to implementations to your codebase through merge requests you can gather feedback through [resolvable discussions](discussions/index.md#resolvable-discussions). +### GitLab Flavored Markdown (GFM) + +Read through the [GFM documentation](markdown.md) to learn how to apply +the best of GitLab Flavored Markdown in your discussions, comments, +issues and merge requests descriptions, and everywhere else GMF is +supported. + ## Todos Never forget to reply to your collaborators. [GitLab Todos](../workflow/todos.md) diff --git a/docker/README.md b/docker/README.md index f9e12c5733b..61b41d2f109 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,7 +1,3 @@ # GitLab Docker images -* The official GitLab Community Edition Docker image is [available on Docker Hub](https://hub.docker.com/r/gitlab/gitlab-ce/). -* The official GitLab Enterprise Edition Docker image is [available on Docker Hub](https://hub.docker.com/r/gitlab/gitlab-ee/). -* The complete usage guide can be found in [Using GitLab Docker images](https://docs.gitlab.com/omnibus/docker/) -* The Dockerfile used for building public images is in [Omnibus Repository](https://gitlab.com/gitlab-org/omnibus-gitlab/tree/master/docker) -* Check the guide for [creating Omnibus-based Docker Image](https://docs.gitlab.com/omnibus/build/README.html#build-docker-image) +This content has been moved to [our documentation site](https://docs.gitlab.com/ce/install/docker.html). diff --git a/features/project/source/search_code.feature b/features/project/source/search_code.feature deleted file mode 100644 index 4f9dcea249f..00000000000 --- a/features/project/source/search_code.feature +++ /dev/null @@ -1,15 +0,0 @@ -Feature: Project Source Search Code - Background: - Given I sign in as a user - - Scenario: Search for term "coffee" - Given I own project "Shop" - And I visit project source page - When I search for term "coffee" - Then I should see files from repository containing "coffee" - - Scenario: Search on empty project - Given I own an empty project - And I visit my project's home page - When I search for term "coffee" - Then I should see empty result diff --git a/features/steps/project/source/search_code.rb b/features/steps/project/source/search_code.rb deleted file mode 100644 index feee756d7ec..00000000000 --- a/features/steps/project/source/search_code.rb +++ /dev/null @@ -1,19 +0,0 @@ -class Spinach::Features::ProjectSourceSearchCode < Spinach::FeatureSteps - include SharedAuthentication - include SharedProject - include SharedPaths - - step 'I search for term "coffee"' do - fill_in "search", with: "coffee" - click_button "Go" - end - - step 'I should see files from repository containing "coffee"' do - expect(page).to have_content 'coffee' - expect(page).to have_content 'CONTRIBUTING.md' - end - - step 'I should see empty result' do - expect(page).to have_content "We couldn't find any" - end -end diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb index 830263fd038..be69a96c3ee 100644 --- a/features/steps/shared/paths.rb +++ b/features/steps/shared/paths.rb @@ -304,10 +304,6 @@ module SharedPaths visit project_commits_path(@project, 'stable', { limit: 5 }) end - step 'I visit project source page' do - visit project_tree_path(@project, root_ref) - end - step 'I visit blob file from repo' do visit project_blob_path(@project, File.join(sample_commit.id, sample_blob.path)) end diff --git a/lib/api/groups.rb b/lib/api/groups.rb index 49c3b2278c7..e56427304a6 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -2,7 +2,7 @@ module API class Groups < Grape::API include PaginationParams - before { authenticate! } + before { authenticate_non_get! } helpers do params :optional_params_ce do @@ -47,16 +47,8 @@ module API use :pagination end get do - groups = if params[:owned] - current_user.owned_groups - elsif current_user.admin - Group.all - elsif params[:all_available] - GroupsFinder.new(current_user).execute - else - current_user.groups - end - + find_params = { all_available: params[:all_available], owned: params[:owned] } + groups = GroupsFinder.new(current_user, find_params).execute groups = groups.search(params[:search]) if params[:search].present? groups = groups.where.not(id: params[:skip_groups]) if params[:skip_groups].present? groups = groups.reorder(params[:order_by] => params[:sort]) diff --git a/spec/features/projects/files/find_files_spec.rb b/spec/features/projects/files/find_files_spec.rb deleted file mode 100644 index 57d67b28920..00000000000 --- a/spec/features/projects/files/find_files_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -require 'spec_helper' - -feature 'Find files button in the tree header' do - given(:user) { create(:user) } - given(:project) { create(:project, :repository) } - - background do - sign_in(user) - project.team << [user, :developer] - end - - scenario 'project main screen' do - visit project_path(project) - - expect(page).to have_selector('.tree-controls .shortcuts-find-file') - end - - scenario 'project tree screen' do - visit project_tree_path(project, project.default_branch) - - expect(page).to have_selector('.tree-controls .shortcuts-find-file') - end -end diff --git a/spec/features/projects/files/user_searches_for_files_spec.rb b/spec/features/projects/files/user_searches_for_files_spec.rb new file mode 100644 index 00000000000..a105685bca7 --- /dev/null +++ b/spec/features/projects/files/user_searches_for_files_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe 'User searches for files' do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + sign_in(user) + end + + describe 'project main screen' do + context 'when project is empty' do + let(:empty_project) { create(:project) } + + before do + empty_project.add_developer(user) + visit project_path(empty_project) + end + + it 'does not show any result' do + fill_in('search', with: 'coffee') + click_button('Go') + + expect(page).to have_content("We couldn't find any") + end + end + + context 'when project is not empty' do + before do + project.add_developer(user) + visit project_path(project) + end + + it 'shows "Find file" button' do + expect(page).to have_selector('.tree-controls .shortcuts-find-file') + end + end + end + + describe 'project tree screen' do + before do + project.add_developer(user) + visit project_tree_path(project, project.default_branch) + end + + it 'shows "Find file" button' do + expect(page).to have_selector('.tree-controls .shortcuts-find-file') + end + + it 'shows found files' do + fill_in('search', with: 'coffee') + click_button('Go') + + expect(page).to have_content('coffee') + expect(page).to have_content('CONTRIBUTING.md') + end + end +end diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index 6742d77937f..31d509455ba 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -281,4 +281,30 @@ describe "Search" do expect(page).to have_selector('.commit-row-description', count: 9) end end + + context 'anonymous user' do + let(:project) { create(:project, :public) } + + before do + sign_out(user) + end + + it 'preserves the group being searched in' do + visit search_path(group_id: project.namespace.id) + + fill_in 'search', with: 'foo' + click_button 'Search' + + expect(find('#group_id').value).to eq(project.namespace.id.to_s) + end + + it 'preserves the project being searched in' do + visit search_path(project_id: project.id) + + fill_in 'search', with: 'foo' + click_button 'Search' + + expect(find('#project_id').value).to eq(project.id.to_s) + end + end end diff --git a/spec/helpers/button_helper_spec.rb b/spec/helpers/button_helper_spec.rb index 250ba239033..4423560ecaa 100644 --- a/spec/helpers/button_helper_spec.rb +++ b/spec/helpers/button_helper_spec.rb @@ -62,4 +62,67 @@ describe ButtonHelper do end end end + + describe 'clipboard_button' do + let(:user) { create(:user) } + let(:project) { build_stubbed(:project) } + + def element(data = {}) + element = helper.clipboard_button(data) + Nokogiri::HTML::DocumentFragment.parse(element).first_element_child + end + + before do + allow(helper).to receive(:current_user).and_return(user) + end + + context 'with default options' do + context 'when no `text` attribute is not provided' do + it 'shows copy to clipboard button with default configuration and no text set to copy' do + expect(element.attr('class')).to eq('btn btn-clipboard btn-transparent') + expect(element.attr('type')).to eq('button') + expect(element.attr('aria-label')).to eq('Copy to clipboard') + expect(element.attr('data-toggle')).to eq('tooltip') + expect(element.attr('data-placement')).to eq('bottom') + expect(element.attr('data-container')).to eq('body') + expect(element.attr('data-clipboard-text')).to eq(nil) + expect(element.inner_text).to eq("") + + expect(element).to have_selector('.fa.fa-clipboard') + end + end + + context 'when `text` attribute is provided' do + it 'shows copy to clipboard button with provided `text` to copy' do + expect(element(text: 'Hello World!').attr('data-clipboard-text')).to eq('Hello World!') + end + end + + context 'when `title` attribute is provided' do + it 'shows copy to clipboard button with provided `title` as tooltip' do + expect(element(title: 'Copy to my clipboard!').attr('aria-label')).to eq('Copy to my clipboard!') + end + end + end + + context 'with `button_text` attribute provided' do + it 'shows copy to clipboard button with provided `button_text` as button label' do + expect(element(button_text: 'Copy text').inner_text).to eq('Copy text') + end + end + + context 'with `hide_tooltip` attribute provided' do + it 'shows copy to clipboard button without tooltip support' do + expect(element(hide_tooltip: true).attr('data-placement')).to eq(nil) + expect(element(hide_tooltip: true).attr('data-toggle')).to eq(nil) + expect(element(hide_tooltip: true).attr('data-container')).to eq(nil) + end + end + + context 'with `hide_button_icon` attribute provided' do + it 'shows copy to clipboard button without tooltip support' do + expect(element(hide_button_icon: true)).not_to have_selector('.fa.fa-clipboard') + end + end + end end diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index 867322ce8ae..8c68ceff914 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -17,7 +17,7 @@ describe('Api', () => { beforeEach(() => { originalGon = window.gon; - window.gon = dummyGon; + window.gon = Object.assign({}, dummyGon); }); afterEach(() => { @@ -98,10 +98,11 @@ describe('Api', () => { }); describe('projects', () => { - it('fetches projects', (done) => { + it('fetches projects with membership when logged in', (done) => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json?simple=true`; + window.gon.current_user_id = 1; const expectedData = Object.assign({ search: query, per_page: 20, @@ -119,6 +120,27 @@ describe('Api', () => { done(); }); }); + + it('fetches projects without membership when not logged in', (done) => { + const query = 'dummy query'; + const options = { unused: 'option' }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json?simple=true`; + const expectedData = Object.assign({ + search: query, + per_page: 20, + }, options); + spyOn(jQuery, 'ajax').and.callFake((request) => { + expect(request.url).toEqual(expectedUrl); + expect(request.dataType).toEqual('json'); + expect(request.data).toEqual(expectedData); + return sendDummyResponse(); + }); + + Api.projects(query, options, (response) => { + expect(response).toBe(dummyResponse); + done(); + }); + }); }); describe('newLabel', () => { diff --git a/spec/javascripts/project_title_spec.js b/spec/javascripts/project_title_spec.js index cc336180ff7..3d36bb3e4d4 100644 --- a/spec/javascripts/project_title_spec.js +++ b/spec/javascripts/project_title_spec.js @@ -7,6 +7,7 @@ import '~/project_select'; import '~/project'; describe('Project Title', () => { + const dummyApiVersion = 'v3000'; preloadFixtures('issues/open-issue.html.raw'); loadJSONFixtures('projects.json'); @@ -14,7 +15,7 @@ describe('Project Title', () => { loadFixtures('issues/open-issue.html.raw'); window.gon = {}; - window.gon.api_version = 'v3'; + window.gon.api_version = dummyApiVersion; // eslint-disable-next-line no-new new Project(); @@ -37,9 +38,10 @@ describe('Project Title', () => { it('toggles dropdown', () => { const $menu = $('.js-dropdown-menu-projects'); + window.gon.current_user_id = 1; $('.js-projects-dropdown-toggle').click(); expect($menu).toHaveClass('open'); - expect(reqUrl).toBe('/api/v3/projects.json?simple=true'); + expect(reqUrl).toBe(`/api/${dummyApiVersion}/projects.json?simple=true`); expect(reqData).toEqual({ search: '', order_by: 'last_activity_at', diff --git a/spec/lib/gitlab/bare_repository_importer_spec.rb b/spec/lib/gitlab/bare_repository_importer_spec.rb index 892f2dafc96..36d1844b5b1 100644 --- a/spec/lib/gitlab/bare_repository_importer_spec.rb +++ b/spec/lib/gitlab/bare_repository_importer_spec.rb @@ -2,67 +2,99 @@ require 'spec_helper' describe Gitlab::BareRepositoryImporter, repository: true do subject(:importer) { described_class.new('default', project_path) } - let(:project_path) { 'a-group/a-sub-group/a-project' } + let!(:admin) { create(:admin) } before do allow(described_class).to receive(:log) end - describe '.execute' do - it 'creates a project for a repository in storage' do - FileUtils.mkdir_p(File.join(TestEnv.repos_path, "#{project_path}.git")) - fake_importer = double + shared_examples 'importing a repository' do + describe '.execute' do + it 'creates a project for a repository in storage' do + FileUtils.mkdir_p(File.join(TestEnv.repos_path, "#{project_path}.git")) + fake_importer = double - expect(described_class).to receive(:new).with('default', project_path) - .and_return(fake_importer) - expect(fake_importer).to receive(:create_project_if_needed) + expect(described_class).to receive(:new).with('default', project_path) + .and_return(fake_importer) + expect(fake_importer).to receive(:create_project_if_needed) - described_class.execute - end + described_class.execute + end - it 'skips wiki repos' do - FileUtils.mkdir_p(File.join(TestEnv.repos_path, 'the-group', 'the-project.wiki.git')) + it 'skips wiki repos' do + FileUtils.mkdir_p(File.join(TestEnv.repos_path, 'the-group', 'the-project.wiki.git')) - expect(described_class).to receive(:log).with(' * Skipping wiki repo') - expect(described_class).not_to receive(:new) + expect(described_class).to receive(:log).with(' * Skipping wiki repo') + expect(described_class).not_to receive(:new) - described_class.execute + described_class.execute + end end - end - describe '#initialize' do - context 'without admin users' do - let(:admin) { nil } + describe '#initialize' do + context 'without admin users' do + let(:admin) { nil } - it 'raises an error' do - expect { importer }.to raise_error(Gitlab::BareRepositoryImporter::NoAdminError) + it 'raises an error' do + expect { importer }.to raise_error(Gitlab::BareRepositoryImporter::NoAdminError) + end end end - end - describe '#create_project_if_needed' do - it 'starts an import for a project that did not exist' do - expect(importer).to receive(:create_project) + describe '#create_project_if_needed' do + it 'starts an import for a project that did not exist' do + expect(importer).to receive(:create_project) + + importer.create_project_if_needed + end + + it 'skips importing when the project already exists' do + project = create(:project, path: 'a-project', namespace: existing_group) + + expect(importer).not_to receive(:create_project) + expect(importer).to receive(:log).with(" * #{project.name} (#{project_path}) exists") + + importer.create_project_if_needed + end + + it 'creates a project with the correct path in the database' do + importer.create_project_if_needed - importer.create_project_if_needed + expect(Project.find_by_full_path(project_path)).not_to be_nil + end end + end + + context 'with subgroups', :nested_groups do + let(:project_path) { 'a-group/a-sub-group/a-project' } - it 'skips importing when the project already exists' do + let(:existing_group) do group = create(:group, path: 'a-group') - subgroup = create(:group, path: 'a-sub-group', parent: group) - project = create(:project, path: 'a-project', namespace: subgroup) + create(:group, path: 'a-sub-group', parent: group) + end - expect(importer).not_to receive(:create_project) - expect(importer).to receive(:log).with(" * #{project.name} (a-group/a-sub-group/a-project) exists") + it_behaves_like 'importing a repository' + end - importer.create_project_if_needed - end + context 'without subgroups' do + let(:project_path) { 'a-group/a-project' } + let(:existing_group) { create(:group, path: 'a-group') } - it 'creates a project with the correct path in the database' do - importer.create_project_if_needed + it_behaves_like 'importing a repository' + end + + context 'when subgroups are not available' do + let(:project_path) { 'a-group/a-sub-group/a-project' } + + before do + expect(Group).to receive(:supports_nested_groups?) { false } + end - expect(Project.find_by_full_path(project_path)).not_to be_nil + describe '#create_project_if_needed' do + it 'raises an error' do + expect { importer.create_project_if_needed }.to raise_error('Nested groups are not supported on MySQL') + end end end end diff --git a/spec/migrations/migrate_stages_statuses_spec.rb b/spec/migrations/migrate_stages_statuses_spec.rb index 4102d57e368..094c9bc604e 100644 --- a/spec/migrations/migrate_stages_statuses_spec.rb +++ b/spec/migrations/migrate_stages_statuses_spec.rb @@ -12,7 +12,7 @@ describe MigrateStagesStatuses, :migration do before do stub_const("#{described_class.name}::BATCH_SIZE", 2) - stub_const("#{described_class.name}::RANGE_SIZE", 2) + stub_const("#{described_class.name}::RANGE_SIZE", 1) projects.create!(id: 1, name: 'gitlab1', path: 'gitlab1') projects.create!(id: 2, name: 'gitlab2', path: 'gitlab2') @@ -50,9 +50,10 @@ describe MigrateStagesStatuses, :migration do Timecop.freeze do migrate! - expect(described_class::MIGRATION).to be_scheduled_migration(5.minutes, 1, 2) + expect(described_class::MIGRATION).to be_scheduled_migration(5.minutes, 1, 1) + expect(described_class::MIGRATION).to be_scheduled_migration(5.minutes, 2, 2) expect(described_class::MIGRATION).to be_scheduled_migration(10.minutes, 3, 3) - expect(BackgroundMigrationWorker.jobs.size).to eq 2 + expect(BackgroundMigrationWorker.jobs.size).to eq 3 end end end diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 313c38cd29c..a7557c7fb22 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -20,10 +20,15 @@ describe API::Groups do describe "GET /groups" do context "when unauthenticated" do - it "returns authentication error" do + it "returns public groups" do get api("/groups") - expect(response).to have_http_status(401) + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response) + .to satisfy_one { |group| group['name'] == group1.name } end end @@ -165,6 +170,18 @@ describe API::Groups do end describe "GET /groups/:id" do + context 'when unauthenticated' do + it 'returns 404 for a private group' do + get api("/groups/#{group2.id}") + expect(response).to have_http_status(404) + end + + it 'returns 200 for a public group' do + get api("/groups/#{group1.id}") + expect(response).to have_http_status(200) + end + end + context "when authenticated as user" do it "returns one of user1's groups" do project = create(:project, namespace: group2, path: 'Foo') diff --git a/spec/services/groups/nested_create_service_spec.rb b/spec/services/groups/nested_create_service_spec.rb index 6d11edb5842..6491fb34777 100644 --- a/spec/services/groups/nested_create_service_spec.rb +++ b/spec/services/groups/nested_create_service_spec.rb @@ -2,52 +2,87 @@ require 'spec_helper' describe Groups::NestedCreateService do let(:user) { create(:user) } - let(:params) { { group_path: 'a-group/a-sub-group' } } subject(:service) { described_class.new(user, params) } - describe "#execute" do - it 'returns the group if it already existed' do - parent = create(:group, path: 'a-group', owner: user) - child = create(:group, path: 'a-sub-group', parent: parent, owner: user) + shared_examples 'with a visibility level' do + it 'creates the group with correct visibility level' do + allow(Gitlab::CurrentSettings.current_application_settings) + .to receive(:default_group_visibility) { Gitlab::VisibilityLevel::INTERNAL } + + group = service.execute - expect(service.execute).to eq(child) + expect(group.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL) end - it 'reuses a parent if it already existed', :nested_groups do - parent = create(:group, path: 'a-group') - parent.add_owner(user) + context 'adding a visibility level ' do + it 'overwrites the visibility level' do + service = described_class.new(user, params.merge(visibility_level: Gitlab::VisibilityLevel::PRIVATE)) + + group = service.execute - expect(service.execute.parent).to eq(parent) + expect(group.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) + end end + end + + describe 'without subgroups' do + let(:params) { { group_path: 'a-group' } } - it 'creates group and subgroup in the database', :nested_groups do - service.execute + before do + allow(Group).to receive(:supports_nested_groups?) { false } + end - parent = Group.find_by_full_path('a-group') - child = parent.children.find_by(path: 'a-sub-group') + it 'creates the group' do + group = service.execute - expect(parent).not_to be_nil - expect(child).not_to be_nil + expect(group).to be_persisted end - it 'creates the group with correct visibility level' do - allow(Gitlab::CurrentSettings.current_application_settings) - .to receive(:default_group_visibility) { Gitlab::VisibilityLevel::INTERNAL } + it 'returns the group if it already existed' do + existing_group = create(:group, path: 'a-group') - group = service.execute + expect(service.execute).to eq(existing_group) + end - expect(group.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL) + it 'raises an error when tring to create a subgroup' do + service = described_class.new(user, group_path: 'a-group/a-sub-group') + + expect { service.execute }.to raise_error('Nested groups are not supported on MySQL') end - context 'adding a visibility level ' do - let(:params) { { group_path: 'a-group/a-sub-group', visibility_level: Gitlab::VisibilityLevel::PRIVATE } } + it_behaves_like 'with a visibility level' + end - it 'overwrites the visibility level' do - group = service.execute + describe 'with subgroups', :nested_groups do + let(:params) { { group_path: 'a-group/a-sub-group' } } - expect(group.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) + describe "#execute" do + it 'returns the group if it already existed' do + parent = create(:group, path: 'a-group', owner: user) + child = create(:group, path: 'a-sub-group', parent: parent, owner: user) + + expect(service.execute).to eq(child) end + + it 'reuses a parent if it already existed' do + parent = create(:group, path: 'a-group') + parent.add_owner(user) + + expect(service.execute.parent).to eq(parent) + end + + it 'creates group and subgroup in the database' do + service.execute + + parent = Group.find_by_full_path('a-group') + child = parent.children.find_by(path: 'a-sub-group') + + expect(parent).not_to be_nil + expect(child).not_to be_nil + end + + it_behaves_like 'with a visibility level' end end end |