Commit 60d4c054 authored by Max Woolf's avatar Max Woolf Committed by Dmitry Gruzd

Add membership CSV export to root group [RUN-AS-IF-FOSS] [RUN ALL RSPEC]

parent 465fdc49
<script> <script>
import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; import { GlTabs, GlTab, GlBadge, GlButton } from '@gitlab/ui';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import { queryToObject } from '~/lib/utils/url_utility'; import { queryToObject } from '~/lib/utils/url_utility';
import { __ } from '~/locale'; import { __ } from '~/locale';
...@@ -35,8 +35,8 @@ export default { ...@@ -35,8 +35,8 @@ export default {
queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest, queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest,
}, },
], ],
components: { MembersApp, GlTabs, GlTab, GlBadge }, components: { MembersApp, GlTabs, GlTab, GlBadge, GlButton },
inject: ['canManageMembers'], inject: ['canManageMembers', 'canExportMembers', 'exportCsvPath'],
data() { data() {
return { return {
selectedTabIndex: 0, selectedTabIndex: 0,
...@@ -121,5 +121,15 @@ export default { ...@@ -121,5 +121,15 @@ export default {
<members-app :namespace="tab.namespace" :tab-query-param-value="tab.queryParamValue" /> <members-app :namespace="tab.namespace" :tab-query-param-value="tab.queryParamValue" />
</gl-tab> </gl-tab>
</template> </template>
<template #tabs-end>
<gl-button
v-if="canExportMembers"
class="gl-align-self-center gl-ml-auto"
icon="export"
:href="exportCsvPath"
>
{{ __('Export as CSV') }}
</gl-button>
</template>
</gl-tabs> </gl-tabs>
</template> </template>
...@@ -14,7 +14,13 @@ export const initMembersApp = (el, options) => { ...@@ -14,7 +14,13 @@ export const initMembersApp = (el, options) => {
Vue.use(Vuex); Vue.use(Vuex);
Vue.use(GlToast); Vue.use(GlToast);
const { sourceId, canManageMembers, ...vuexStoreAttributes } = parseDataAttributes(el); const {
sourceId,
canManageMembers,
canExportMembers,
exportCsvPath,
...vuexStoreAttributes
} = parseDataAttributes(el);
const modules = Object.keys(MEMBER_TYPES).reduce((accumulator, namespace) => { const modules = Object.keys(MEMBER_TYPES).reduce((accumulator, namespace) => {
const namespacedOptions = options[namespace]; const namespacedOptions = options[namespace];
...@@ -54,6 +60,8 @@ export const initMembersApp = (el, options) => { ...@@ -54,6 +60,8 @@ export const initMembersApp = (el, options) => {
currentUserId: gon.current_user_id || null, currentUserId: gon.current_user_id || null,
sourceId, sourceId,
canManageMembers, canManageMembers,
canExportMembers,
exportCsvPath,
}, },
render: (createElement) => createElement('members-tabs'), render: (createElement) => createElement('members-tabs'),
}); });
......
...@@ -13,7 +13,7 @@ module Groups::GroupMembersHelper ...@@ -13,7 +13,7 @@ module Groups::GroupMembersHelper
render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: group.access_level_roles, default_access_level: default_access_level render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: group.access_level_roles, default_access_level: default_access_level
end end
def group_members_app_data_json(group, members:, invited:, access_requests:) def group_members_app_data(group, members:, invited:, access_requests:)
{ {
user: group_members_list_data(group, members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }), user: group_members_list_data(group, members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }),
group: group_group_links_list_data(group), group: group_group_links_list_data(group),
...@@ -21,7 +21,7 @@ module Groups::GroupMembersHelper ...@@ -21,7 +21,7 @@ module Groups::GroupMembersHelper
access_request: group_members_list_data(group, access_requests.nil? ? [] : access_requests), access_request: group_members_list_data(group, access_requests.nil? ? [] : access_requests),
source_id: group.id, source_id: group.id,
can_manage_members: can?(current_user, :admin_group_member, group) can_manage_members: can?(current_user, :admin_group_member, group)
}.to_json }
end end
private private
......
...@@ -35,9 +35,9 @@ ...@@ -35,9 +35,9 @@
= render_if_exists 'groups/group_members/ldap_sync' = render_if_exists 'groups/group_members/ldap_sync'
.js-group-members-list-app{ data: { members_data: group_members_app_data_json(@group, .js-group-members-list-app{ data: { members_data: group_members_app_data(@group,
members: @members, members: @members,
invited: @invited_members, invited: @invited_members,
access_requests: @requesters) } } access_requests: @requesters).to_json } }
.loading .loading
.gl-spinner.gl-spinner-md .gl-spinner.gl-spinner-md
...@@ -168,6 +168,8 @@ ...@@ -168,6 +168,8 @@
- 1 - 1
- - group_wikis_git_garbage_collect - - group_wikis_git_garbage_collect
- 1 - 1
- - groups_export_memberships
- 1
- - groups_schedule_bulk_repository_shard_moves - - groups_schedule_bulk_repository_shard_moves
- 1 - 1
- - groups_update_repository_storage - - groups_update_repository_storage
......
...@@ -516,6 +516,20 @@ To prevent members from being added to a group: ...@@ -516,6 +516,20 @@ To prevent members from being added to a group:
All users who previously had permissions can no longer add members to a group. All users who previously had permissions can no longer add members to a group.
API requests to add a new user to a project are not possible. API requests to add a new user to a project are not possible.
## Export members as CSV **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/287940) in GitLab 14.2.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available per group, ask an administrator to [enable the :ff_group_membership_export flag](../../administration/feature_flags.md). On GitLab.com, this feature is not available.
The feature is not ready for production use.
You can export a list of members in a group as a CSV.
1. Go to your project and select **Project information > Members**.
1. Select **Export as CSV**.
1. Once the CSV file has been generated, it is emailed as an attachment to the user that requested it.
## Restrict group access by IP address **(PREMIUM)** ## Restrict group access by IP address **(PREMIUM)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/1985) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.0. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/1985) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.0.
......
...@@ -43,6 +43,14 @@ module EE ...@@ -43,6 +43,14 @@ module EE
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
# rubocop:enable Gitlab/ModuleWithInstanceVariables # rubocop:enable Gitlab/ModuleWithInstanceVariables
def export_csv
return render_404 unless current_user.can?(:export_group_memberships, group)
::Groups::ExportMembershipsWorker.perform_async(group.id, current_user.id)
redirect_to group_group_members_path(group), notice: _('CSV is being generated and will be emailed to you upon completion.')
end
protected protected
def authorize_update_group_member! def authorize_update_group_member!
......
...@@ -14,4 +14,12 @@ module EE::Groups::GroupMembersHelper ...@@ -14,4 +14,12 @@ module EE::Groups::GroupMembersHelper
ldap_override_path: override_group_group_member_path(group, ':id') ldap_override_path: override_group_group_member_path(group, ':id')
}) })
end end
override :group_members_app_data
def group_members_app_data(group, members:, invited:, access_requests:)
super.merge!({
can_export_members: can?(current_user, :export_group_memberships, group),
export_csv_path: export_csv_group_group_members_path(group)
})
end
end end
...@@ -13,6 +13,7 @@ module EE ...@@ -13,6 +13,7 @@ module EE
include ::Emails::Requirements include ::Emails::Requirements
include ::Emails::UserCap include ::Emails::UserCap
include ::Emails::OncallRotation include ::Emails::OncallRotation
include ::Emails::GroupMemberships
end end
attr_reader :group attr_reader :group
......
# frozen_string_literal: true
module Emails
module GroupMemberships
def memberships_export_email(csv_data:, requested_by:, group:)
@group = group
filename = "#{group.full_path.parameterize}_group_memberships_#{Date.current.iso8601}.csv"
attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
mail(to: requested_by.notification_email_for(group), subject: "Exported group membership list") do |format|
format.html { render layout: 'mailer' }
format.text { render layout: 'mailer' }
end
end
end
end
...@@ -49,6 +49,10 @@ module EE ...@@ -49,6 +49,10 @@ module EE
@subject.feature_available?(:dora4_analytics) @subject.feature_available?(:dora4_analytics)
end end
condition(:group_membership_export_available) do
@subject.feature_available?(:export_user_permissions) && ::Feature.enabled?(:ff_group_membership_export, @subject, default_enabled: :yaml)
end
condition(:can_owners_manage_ldap, scope: :global) do condition(:can_owners_manage_ldap, scope: :global) do
::Gitlab::CurrentSettings.allow_group_owners_to_manage_ldap? ::Gitlab::CurrentSettings.allow_group_owners_to_manage_ldap?
end end
...@@ -366,6 +370,7 @@ module EE ...@@ -366,6 +370,7 @@ module EE
prevent :create_subgroup prevent :create_subgroup
end end
rule { can?(:owner_access) & group_membership_export_available }.enable :export_group_memberships
rule { can?(:owner_access) & compliance_framework_available }.enable :admin_compliance_framework rule { can?(:owner_access) & compliance_framework_available }.enable :admin_compliance_framework
rule { can?(:owner_access) & group_level_compliance_pipeline_available }.enable :admin_compliance_pipeline_configuration rule { can?(:owner_access) & group_level_compliance_pipeline_available }.enable :admin_compliance_pipeline_configuration
end end
......
# frozen_string_literal: true
module Groups
module Memberships
class ExportService < ::BaseContainerService
def execute
return ServiceResponse.error(message: 'Not available') unless current_user.can?(:export_group_memberships, container)
ServiceResponse.success(payload: csv_builder.render)
end
private
def csv_builder
@csv_builder ||= CsvBuilder.new(data, header_to_value_hash)
end
def data
GroupMembersFinder.new(container, current_user).execute(include_relations: [:descendants, :direct, :inherited])
end
def header_to_value_hash
{
'Username' => 'user_username',
'Name' => 'user_name',
'Access granted' => -> (member) { member.created_at.to_s(:csv) },
'Access expires' => -> (member) { member.expires_at },
'Max role' => 'human_access',
'Source' => -> (member) { member.source == container ? 'Direct member' : 'Inherited member' }
}
end
end
end
end
<p>Hi,<br />
<p>Attached to this email is the list of members of <%= @group.name %> in CSV format.</p>
Hi,
Attached to this email is the list of members of <%= @group.name %> in CSV format.
...@@ -1042,6 +1042,15 @@ ...@@ -1042,6 +1042,15 @@
:idempotent: :idempotent:
:tags: :tags:
- :exclude_from_kubernetes - :exclude_from_kubernetes
- :name: groups_export_memberships
:worker_name: Groups::ExportMembershipsWorker
:feature_category: :compliance_management
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent:
:tags: []
- :name: groups_schedule_bulk_repository_shard_moves - :name: groups_schedule_bulk_repository_shard_moves
:worker_name: Groups::ScheduleBulkRepositoryShardMovesWorker :worker_name: Groups::ScheduleBulkRepositoryShardMovesWorker
:feature_category: :gitaly :feature_category: :gitaly
......
# frozen_string_literal: true
# rubocop:disable Scalability/IdempotentWorker
# Worker triggers email so cannot be considered idempotent.
module Groups
class ExportMembershipsWorker
include ApplicationWorker
sidekiq_options retry: true
feature_category :compliance_management
data_consistency :sticky
def perform(group_id, current_user_id)
@group = Group.find_by_id(group_id)
@current_user = User.find_by_id(current_user_id)
@response = Groups::Memberships::ExportService.new(container: @group, current_user: @current_user).execute
send_email if @response.success?
end
private
def send_email
Notify.memberships_export_email(csv_data: @response.payload,
requested_by: @current_user,
group: @group).deliver_later
end
end
end
---
name: ff_group_membership_export
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66755
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/336520
milestone: '14.2'
type: development
group: group::compliance
default_enabled: false
...@@ -9,6 +9,10 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -9,6 +9,10 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
resources :group_members, only: [], concerns: :access_requestable do resources :group_members, only: [], concerns: :access_requestable do
patch :override, on: :member patch :override, on: :member
collection do
get :export_csv
end
end end
resources :compliance_frameworks, only: [:new, :edit] resources :compliance_frameworks, only: [:new, :edit]
......
...@@ -275,6 +275,89 @@ RSpec.describe Groups::GroupMembersController do ...@@ -275,6 +275,89 @@ RSpec.describe Groups::GroupMembersController do
end end
end end
describe 'GET #export_csv' do
context 'when flag is disabled' do
before do
stub_licensed_features(export_user_permissions: true)
stub_feature_flags(ff_group_membership_export: false)
end
it 'responds with :not_found' do
get :export_csv, params: { group_id: group.id }
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when feature is unlicensed' do
before do
stub_licensed_features(export_user_permissions: false)
stub_feature_flags(ff_group_membership_export: true)
end
it 'responds with :not_found' do
get :export_csv, params: { group_id: group.id }
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when feature is licensed and enabled' do
before do
stub_licensed_features(export_user_permissions: true)
stub_feature_flags(ff_group_membership_export: true)
end
it 'enqueues a worker job' do
expect(::Groups::ExportMembershipsWorker).to receive(:perform_async).once
get :export_csv, params: { group_id: group }
end
context 'current user is a group maintainer' do
let_it_be(:maintainer) { create(:user) }
before do
group.add_user(maintainer, Gitlab::Access::MAINTAINER)
end
it 'returns a 404' do
get :export_csv, params: { group_id: group.id }
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'current user is a group developer' do
let_it_be(:maintainer) { create(:user) }
before do
group.add_user(maintainer, Gitlab::Access::DEVELOPER)
end
it 'returns a 404' do
get :export_csv, params: { group_id: group.id }
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'current user is a group guest' do
let_it_be(:maintainer) { create(:user) }
before do
group.add_user(maintainer, Gitlab::Access::GUEST)
end
it 'returns a 404' do
get :export_csv, params: { group_id: group.id }
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
describe 'POST #resend_invite' do describe 'POST #resend_invite' do
context 'when user has minimal access' do context 'when user has minimal access' do
let_it_be(:membership) { create(:group_member, :minimal_access, source: group, user: create(:user)) } let_it_be(:membership) { create(:group_member, :minimal_access, source: group, user: create(:user)) }
......
...@@ -22,26 +22,33 @@ RSpec.describe Groups::GroupMembersHelper do ...@@ -22,26 +22,33 @@ RSpec.describe Groups::GroupMembersHelper do
end end
end end
describe '#group_members_app_data_json' do describe '#group_members_app_data' do
subject do subject do
Gitlab::Json.parse( helper.group_members_app_data(
helper.group_members_app_data_json(
group, group,
members: [], members: [],
invited: [], invited: [],
access_requests: [] access_requests: []
) )
)
end end
before do before do
allow(helper).to receive(:override_group_group_member_path).with(group, ':id').and_return('/groups/foo-bar/-/group_members/:id/override') allow(helper).to receive(:override_group_group_member_path).with(group, ':id').and_return('/groups/foo-bar/-/group_members/:id/override')
allow(helper).to receive(:group_group_member_path).with(group, ':id').and_return('/groups/foo-bar/-/group_members/:id') allow(helper).to receive(:group_group_member_path).with(group, ':id').and_return('/groups/foo-bar/-/group_members/:id')
allow(helper).to receive(:can?).with(current_user, :admin_group_member, group).and_return(true) allow(helper).to receive(:can?).with(current_user, :admin_group_member, group).and_return(true)
allow(helper).to receive(:can?).with(current_user, :export_group_memberships, group).and_return(true)
end
it 'adds `ldap_override_path`' do
expect(subject[:user][:ldap_override_path]).to eq('/groups/foo-bar/-/group_members/:id/override')
end
it 'adds `can_export_members`' do
expect(subject[:can_export_members]).to be true
end end
it 'adds `ldap_override_path` to returned json' do it 'adds `export_csv_path`' do
expect(subject['user']['ldap_override_path']).to eq('/groups/foo-bar/-/group_members/:id/override') expect(subject[:export_csv_path]).not_to be_nil
end end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Emails::GroupMemberships do
include EmailSpec::Matchers
let_it_be(:group) { create(:group) }
let_it_be(:owner) { create(:group_member, :owner, group: group) }
let(:csv) { CSV.parse_line("a,b,c\nd,e,f") }
describe "#user_cap_reached" do
subject { Notify.memberships_export_email(csv_data: csv, requested_by: owner.user, group: group) }
it { is_expected.to have_subject('Exported group membership list') }
it { is_expected.to be_delivered_to([owner.user.notification_email_for(group)]) }
it 'contains one attachment' do
freeze_time do
expect(subject.attachments.size).to eq(1)
expect(subject.attachments[0].content_type).to eq('text/csv')
expect(subject.attachments[0].filename).to eq("#{group.full_path.parameterize}_group_memberships_#{Date.current.iso8601}.csv")
end
end
end
end
...@@ -215,6 +215,35 @@ RSpec.describe GroupPolicy do ...@@ -215,6 +215,35 @@ RSpec.describe GroupPolicy do
it { is_expected.not_to be_allowed(:read_dora4_analytics) } it { is_expected.not_to be_allowed(:read_dora4_analytics) }
end end
context 'export group memberships' do
let(:current_user) { owner }
context 'when exporting user permissions is not available' do
before do
stub_licensed_features(export_user_permissions: false)
end
it { is_expected.not_to be_allowed(:export_group_memberships) }
end
context 'when exporting user permissions is available' do
before do
stub_licensed_features(export_user_permissions: true)
stub_feature_flags(ff_group_membership_export: true)
end
it { is_expected.to be_allowed(:export_group_memberships) }
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(ff_group_membership_export: false)
end
it { is_expected.not_to be_allowed(:export_group_memberships) }
end
end
context 'when group activity analytics is available' do context 'when group activity analytics is available' do
let(:current_user) { developer } let(:current_user) { developer }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Groups::Memberships::ExportService do
let(:group) { create(:group) }
let(:owner_member) { create(:group_member, :owner, group: group)}
let(:current_user) { owner_member.user }
let(:service) { described_class.new(container: group, current_user: current_user) }
shared_examples 'not available' do
it 'returns a failed response' do
response = service.execute
expect(response.success?).to be false
expect(response.message).to eq('Not available')
end
end
describe '#execute' do
context 'when unlicensed' do
before do
stub_licensed_features(export_user_permissions: false)
stub_feature_flags(ff_group_membership_export: true)
end
it_behaves_like 'not available'
end
context 'when disabled' do
before do
stub_licensed_features(export_user_permissions: true)
stub_feature_flags(ff_group_membership_export: false)
end
it_behaves_like 'not available'
end
context 'when licensed and enabled' do
before do
stub_licensed_features(export_user_permissions: true)
stub_feature_flags(ff_group_membership_export: true)
group.add_user(current_user, Gitlab::Access::OWNER)
end
it 'is successful' do
response = service.execute
expect(response.success?).to be true
end
context 'current_user is not an owner of this group' do
let(:service) { described_class.new(container: group, current_user: create(:user)) }
it_behaves_like 'not available'
end
context 'current_user is a group developer' do
let(:current_user) { create(:user) }
before do
group.add_developer(current_user)
end
it_behaves_like 'not available'
end
context 'current_user is a group maintainer' do
let(:current_user) { create(:user) }
before do
group.add_maintainer(current_user)
end
it_behaves_like 'not available'
end
context 'current_user is a guest' do
let(:current_user) { create(:user) }
before do
group.add_guest(current_user)
end
it_behaves_like 'not available'
end
context 'data verification' do
before do
create_list(:group_member, 4, group: group)
create(:group_member, group: group, created_at: '2021-02-01', expires_at: '2022-01-01', user: create(:user, username: 'mwoolf', name: 'Max Woolf'))
end
let(:csv) { CSV.parse(service.execute.payload, headers: true) }
it 'has the correct headers' do
expect(csv.headers).to contain_exactly('Username', 'Name', 'Access granted', 'Access expires', 'Max role', 'Source')
end
it 'has the correct number of rows' do
expect(csv.size).to eq(6)
end
context 'a direct user', :aggregate_failures do
let(:direct_user_row) { csv[5] }
it 'has the correct information' do
expect(direct_user_row[0]).to eq('mwoolf')
expect(direct_user_row[1]).to eq('Max Woolf')
expect(direct_user_row[2]).to eq('2021-02-01 00:00:00')
expect(direct_user_row[3]).to eq('2022-01-01')
expect(direct_user_row[4]).to eq('Owner')
expect(direct_user_row[5]).to eq('Direct member')
end
end
context 'a user in a subgroup' do
before do
sub_group = create(:group, parent: group)
create(:group_member, group: sub_group, user: create(:user, username: 'Oliver', name: 'Oliver D', email: 'oliver@test.com'))
end
it 'has the correct information' do
row = csv.find { |row| row['Username'] == 'Oliver' }
expect(row[0]).to eq('Oliver')
expect(row[1]).to eq('Oliver D')
expect(row[4]).to eq('Owner')
expect(row[5]).to eq('Inherited member')
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Groups::ExportMembershipsWorker do
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
before do
stub_licensed_features(export_user_permissions: true)
stub_feature_flags(ff_group_membership_export: true)
group.add_owner(user)
end
subject(:worker) { described_class.new }
it 'enqueues an email' do
expect(Notify).to receive(:memberships_export_email).once.and_call_original
worker.perform(group.id, user.id)
end
end
...@@ -329,6 +329,7 @@ excluded_attributes: ...@@ -329,6 +329,7 @@ excluded_attributes:
- :release_id - :release_id
project_members: project_members:
- :source_id - :source_id
- :invite_email_success
metrics: metrics:
- :merge_request_id - :merge_request_id
- :pipeline_id - :pipeline_id
......
...@@ -6010,6 +6010,9 @@ msgstr "" ...@@ -6010,6 +6010,9 @@ msgstr ""
msgid "CPU" msgid "CPU"
msgstr "" msgstr ""
msgid "CSV is being generated and will be emailed to you upon completion."
msgstr ""
msgid "CVE|As a maintainer, requesting a CVE for a vulnerability in your project will help your users stay secure and informed." msgid "CVE|As a maintainer, requesting a CVE for a vulnerability in your project will help your users stay secure and informed."
msgstr "" msgstr ""
......
import { GlTabs } from '@gitlab/ui'; import { GlTabs, GlButton } from '@gitlab/ui';
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import setWindowLocation from 'helpers/set_window_location_helper'; import setWindowLocation from 'helpers/set_window_location_helper';
...@@ -17,7 +17,7 @@ describe('MembersTabs', () => { ...@@ -17,7 +17,7 @@ describe('MembersTabs', () => {
let wrapper; let wrapper;
const createComponent = ({ totalItems = 10, options = {} } = {}) => { const createComponent = ({ totalItems = 10, provide = {} } = {}) => {
const store = new Vuex.Store({ const store = new Vuex.Store({
modules: { modules: {
[MEMBER_TYPES.user]: { [MEMBER_TYPES.user]: {
...@@ -79,8 +79,10 @@ describe('MembersTabs', () => { ...@@ -79,8 +79,10 @@ describe('MembersTabs', () => {
stubs: ['members-app'], stubs: ['members-app'],
provide: { provide: {
canManageMembers: true, canManageMembers: true,
canExportMembers: true,
exportCsvPath: '',
...provide,
}, },
...options,
}); });
return nextTick(); return nextTick();
...@@ -89,6 +91,7 @@ describe('MembersTabs', () => { ...@@ -89,6 +91,7 @@ describe('MembersTabs', () => {
const findTabs = () => wrapper.findAllByRole('tab').wrappers; const findTabs = () => wrapper.findAllByRole('tab').wrappers;
const findTabByText = (text) => findTabs().find((tab) => tab.text().includes(text)); const findTabByText = (text) => findTabs().find((tab) => tab.text().includes(text));
const findActiveTab = () => wrapper.findByRole('tab', { selected: true }); const findActiveTab = () => wrapper.findByRole('tab', { selected: true });
const findExportButton = () => wrapper.findComponent(GlButton);
beforeEach(() => { beforeEach(() => {
setWindowLocation('https://localhost'); setWindowLocation('https://localhost');
...@@ -164,7 +167,7 @@ describe('MembersTabs', () => { ...@@ -164,7 +167,7 @@ describe('MembersTabs', () => {
describe('when `canManageMembers` is `false`', () => { describe('when `canManageMembers` is `false`', () => {
it('shows all tabs except `Invited` and `Access requests`', async () => { it('shows all tabs except `Invited` and `Access requests`', async () => {
await createComponent({ options: { provide: { canManageMembers: false } } }); await createComponent({ provide: { canManageMembers: false } });
expect(findTabByText('Members')).not.toBeUndefined(); expect(findTabByText('Members')).not.toBeUndefined();
expect(findTabByText('Groups')).not.toBeUndefined(); expect(findTabByText('Groups')).not.toBeUndefined();
...@@ -172,4 +175,20 @@ describe('MembersTabs', () => { ...@@ -172,4 +175,20 @@ describe('MembersTabs', () => {
expect(findTabByText('Access requests')).toBeUndefined(); expect(findTabByText('Access requests')).toBeUndefined();
}); });
}); });
describe('when `canExportMembers` is true', () => {
it('shows the CSV export button with export path', async () => {
await createComponent({ provide: { canExportMembers: true, exportCsvPath: 'foo' } });
expect(findExportButton().attributes('href')).toBe('foo');
});
});
describe('when `canExportMembers` is false', () => {
it('does not show the CSV export button', async () => {
await createComponent({ provide: { canExportMembers: false } });
expect(findExportButton().exists()).toBe(false);
});
});
}); });
...@@ -9,6 +9,7 @@ RSpec.describe Groups::GroupMembersHelper do ...@@ -9,6 +9,7 @@ RSpec.describe Groups::GroupMembersHelper do
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
before do before do
allow(helper).to receive(:can?).with(current_user, :export_group_memberships, group).and_return(false)
allow(helper).to receive(:can?).with(current_user, :owner_access, group).and_return(true) allow(helper).to receive(:can?).with(current_user, :owner_access, group).and_return(true)
allow(helper).to receive(:current_user).and_return(current_user) allow(helper).to receive(:current_user).and_return(current_user)
end end
...@@ -23,7 +24,7 @@ RSpec.describe Groups::GroupMembersHelper do ...@@ -23,7 +24,7 @@ RSpec.describe Groups::GroupMembersHelper do
end end
end end
describe '#group_members_app_data_json' do describe '#group_members_app_data' do
include_context 'group_group_link' include_context 'group_group_link'
let(:members) { create_list(:group_member, 2, group: shared_group, created_by: current_user) } let(:members) { create_list(:group_member, 2, group: shared_group, created_by: current_user) }
...@@ -33,27 +34,26 @@ RSpec.describe Groups::GroupMembersHelper do ...@@ -33,27 +34,26 @@ RSpec.describe Groups::GroupMembersHelper do
let(:members_collection) { members } let(:members_collection) { members }
subject do subject do
Gitlab::Json.parse( helper.group_members_app_data(
helper.group_members_app_data_json(
shared_group, shared_group,
members: present_members(members_collection), members: present_members(members_collection),
invited: present_members(invited), invited: present_members(invited),
access_requests: present_members(access_requests) access_requests: present_members(access_requests)
) )
)
end end
shared_examples 'members.json' do |member_type| shared_examples 'members.json' do |member_type|
it 'returns `members` property that matches json schema' do it 'returns `members` property that matches json schema' do
expect(subject[member_type]['members'].to_json).to match_schema('members') expect(subject[member_type.to_sym][:members].to_json).to match_schema('members')
end end
it 'sets `member_path` property' do it 'sets `member_path` property' do
expect(subject[member_type]['member_path']).to eq('/groups/foo-bar/-/group_members/:id') expect(subject[member_type.to_sym][:member_path]).to eq('/groups/foo-bar/-/group_members/:id')
end end
end end
before do before do
allow(helper).to receive(:can?).with(current_user, :export_group_memberships, shared_group).and_return(true)
allow(helper).to receive(:group_group_member_path).with(shared_group, ':id').and_return('/groups/foo-bar/-/group_members/:id') allow(helper).to receive(:group_group_member_path).with(shared_group, ':id').and_return('/groups/foo-bar/-/group_members/:id')
allow(helper).to receive(:group_group_link_path).with(shared_group, ':id').and_return('/groups/foo-bar/-/group_links/:id') allow(helper).to receive(:group_group_link_path).with(shared_group, ':id').and_return('/groups/foo-bar/-/group_links/:id')
allow(helper).to receive(:can?).with(current_user, :admin_group_member, shared_group).and_return(true) allow(helper).to receive(:can?).with(current_user, :admin_group_member, shared_group).and_return(true)
...@@ -63,7 +63,7 @@ RSpec.describe Groups::GroupMembersHelper do ...@@ -63,7 +63,7 @@ RSpec.describe Groups::GroupMembersHelper do
expected = { expected = {
source_id: shared_group.id, source_id: shared_group.id,
can_manage_members: true can_manage_members: true
}.as_json }
expect(subject).to include(expected) expect(subject).to include(expected)
end end
...@@ -90,11 +90,11 @@ RSpec.describe Groups::GroupMembersHelper do ...@@ -90,11 +90,11 @@ RSpec.describe Groups::GroupMembersHelper do
context 'group links' do context 'group links' do
it 'sets `group.members` property that matches json schema' do it 'sets `group.members` property that matches json schema' do
expect(subject['group']['members'].to_json).to match_schema('group_link/group_group_links') expect(subject[:group][:members].to_json).to match_schema('group_link/group_group_links')
end end
it 'sets `member_path` property' do it 'sets `member_path` property' do
expect(subject['group']['member_path']).to eq('/groups/foo-bar/-/group_links/:id') expect(subject[:group][:member_path]).to eq('/groups/foo-bar/-/group_links/:id')
end end
end end
...@@ -108,7 +108,7 @@ RSpec.describe Groups::GroupMembersHelper do ...@@ -108,7 +108,7 @@ RSpec.describe Groups::GroupMembersHelper do
params: {} params: {}
}.as_json }.as_json
expect(subject['access_request']['pagination']).to include(expected) expect(subject[:access_request][:pagination].as_json).to include(expected)
end end
end end
...@@ -124,7 +124,7 @@ RSpec.describe Groups::GroupMembersHelper do ...@@ -124,7 +124,7 @@ RSpec.describe Groups::GroupMembersHelper do
params: { invited_members_page: nil, search_invited: nil } params: { invited_members_page: nil, search_invited: nil }
}.as_json }.as_json
expect(subject['user']['pagination']).to include(expected) expect(subject[:user][:pagination].as_json).to include(expected)
end end
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment