Commit 99c14063 authored by Dmitry Gruzd's avatar Dmitry Gruzd

Merge branch '287940-group-csv-export' into 'master'

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

See merge request gitlab-org/gitlab!66755
parents 8c6d3ab2 60d4c054
<script>
import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
import { GlTabs, GlTab, GlBadge, GlButton } from '@gitlab/ui';
import { mapState } from 'vuex';
import { queryToObject } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
......@@ -35,8 +35,8 @@ export default {
queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest,
},
],
components: { MembersApp, GlTabs, GlTab, GlBadge },
inject: ['canManageMembers'],
components: { MembersApp, GlTabs, GlTab, GlBadge, GlButton },
inject: ['canManageMembers', 'canExportMembers', 'exportCsvPath'],
data() {
return {
selectedTabIndex: 0,
......@@ -121,5 +121,15 @@ export default {
<members-app :namespace="tab.namespace" :tab-query-param-value="tab.queryParamValue" />
</gl-tab>
</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>
</template>
......@@ -14,7 +14,13 @@ export const initMembersApp = (el, options) => {
Vue.use(Vuex);
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 namespacedOptions = options[namespace];
......@@ -54,6 +60,8 @@ export const initMembersApp = (el, options) => {
currentUserId: gon.current_user_id || null,
sourceId,
canManageMembers,
canExportMembers,
exportCsvPath,
},
render: (createElement) => createElement('members-tabs'),
});
......
......@@ -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
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 } }),
group: group_group_links_list_data(group),
......@@ -21,7 +21,7 @@ module Groups::GroupMembersHelper
access_request: group_members_list_data(group, access_requests.nil? ? [] : access_requests),
source_id: group.id,
can_manage_members: can?(current_user, :admin_group_member, group)
}.to_json
}
end
private
......
......@@ -35,9 +35,9 @@
= render_if_exists 'groups/group_members/ldap_sync'
.js-group-members-list-app{ data: { members_data: group_members_app_data_json(@group,
members: @members,
invited: @invited_members,
access_requests: @requesters) } }
.js-group-members-list-app{ data: { members_data: group_members_app_data(@group,
members: @members,
invited: @invited_members,
access_requests: @requesters).to_json } }
.loading
.gl-spinner.gl-spinner-md
......@@ -168,6 +168,8 @@
- 1
- - group_wikis_git_garbage_collect
- 1
- - groups_export_memberships
- 1
- - groups_schedule_bulk_repository_shard_moves
- 1
- - groups_update_repository_storage
......
......@@ -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.
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)**
> - [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
# rubocop: enable CodeReuse/ActiveRecord
# 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
def authorize_update_group_member!
......
......@@ -14,4 +14,12 @@ module EE::Groups::GroupMembersHelper
ldap_override_path: override_group_group_member_path(group, ':id')
})
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
......@@ -13,6 +13,7 @@ module EE
include ::Emails::Requirements
include ::Emails::UserCap
include ::Emails::OncallRotation
include ::Emails::GroupMemberships
end
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
@subject.feature_available?(:dora4_analytics)
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
::Gitlab::CurrentSettings.allow_group_owners_to_manage_ldap?
end
......@@ -366,6 +370,7 @@ module EE
prevent :create_subgroup
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) & group_level_compliance_pipeline_available }.enable :admin_compliance_pipeline_configuration
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 @@
:idempotent:
:tags:
- :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
:worker_name: Groups::ScheduleBulkRepositoryShardMovesWorker
: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
resources :group_members, only: [], concerns: :access_requestable do
patch :override, on: :member
collection do
get :export_csv
end
end
resources :compliance_frameworks, only: [:new, :edit]
......
......@@ -275,6 +275,89 @@ RSpec.describe Groups::GroupMembersController do
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
context 'when user has minimal access' do
let_it_be(:membership) { create(:group_member, :minimal_access, source: group, user: create(:user)) }
......
......@@ -22,15 +22,13 @@ RSpec.describe Groups::GroupMembersHelper do
end
end
describe '#group_members_app_data_json' do
describe '#group_members_app_data' do
subject do
Gitlab::Json.parse(
helper.group_members_app_data_json(
group,
members: [],
invited: [],
access_requests: []
)
helper.group_members_app_data(
group,
members: [],
invited: [],
access_requests: []
)
end
......@@ -38,10 +36,19 @@ RSpec.describe Groups::GroupMembersHelper 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(: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, :export_group_memberships, group).and_return(true)
end
it 'adds `ldap_override_path` to returned json' do
expect(subject['user']['ldap_override_path']).to eq('/groups/foo-bar/-/group_members/:id/override')
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
it 'adds `export_csv_path`' do
expect(subject[:export_csv_path]).not_to be_nil
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
it { is_expected.not_to be_allowed(:read_dora4_analytics) }
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
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:
- :release_id
project_members:
- :source_id
- :invite_email_success
metrics:
- :merge_request_id
- :pipeline_id
......
......@@ -6010,6 +6010,9 @@ msgstr ""
msgid "CPU"
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."
msgstr ""
......
import { GlTabs } from '@gitlab/ui';
import { GlTabs, GlButton } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import setWindowLocation from 'helpers/set_window_location_helper';
......@@ -17,7 +17,7 @@ describe('MembersTabs', () => {
let wrapper;
const createComponent = ({ totalItems = 10, options = {} } = {}) => {
const createComponent = ({ totalItems = 10, provide = {} } = {}) => {
const store = new Vuex.Store({
modules: {
[MEMBER_TYPES.user]: {
......@@ -79,8 +79,10 @@ describe('MembersTabs', () => {
stubs: ['members-app'],
provide: {
canManageMembers: true,
canExportMembers: true,
exportCsvPath: '',
...provide,
},
...options,
});
return nextTick();
......@@ -89,6 +91,7 @@ describe('MembersTabs', () => {
const findTabs = () => wrapper.findAllByRole('tab').wrappers;
const findTabByText = (text) => findTabs().find((tab) => tab.text().includes(text));
const findActiveTab = () => wrapper.findByRole('tab', { selected: true });
const findExportButton = () => wrapper.findComponent(GlButton);
beforeEach(() => {
setWindowLocation('https://localhost');
......@@ -164,7 +167,7 @@ describe('MembersTabs', () => {
describe('when `canManageMembers` is `false`', () => {
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('Groups')).not.toBeUndefined();
......@@ -172,4 +175,20 @@ describe('MembersTabs', () => {
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
let_it_be(:group) { create(:group) }
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(:current_user).and_return(current_user)
end
......@@ -23,7 +24,7 @@ RSpec.describe Groups::GroupMembersHelper do
end
end
describe '#group_members_app_data_json' do
describe '#group_members_app_data' do
include_context 'group_group_link'
let(:members) { create_list(:group_member, 2, group: shared_group, created_by: current_user) }
......@@ -33,27 +34,26 @@ RSpec.describe Groups::GroupMembersHelper do
let(:members_collection) { members }
subject do
Gitlab::Json.parse(
helper.group_members_app_data_json(
shared_group,
members: present_members(members_collection),
invited: present_members(invited),
access_requests: present_members(access_requests)
)
helper.group_members_app_data(
shared_group,
members: present_members(members_collection),
invited: present_members(invited),
access_requests: present_members(access_requests)
)
end
shared_examples 'members.json' do |member_type|
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
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
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_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)
......@@ -63,7 +63,7 @@ RSpec.describe Groups::GroupMembersHelper do
expected = {
source_id: shared_group.id,
can_manage_members: true
}.as_json
}
expect(subject).to include(expected)
end
......@@ -90,11 +90,11 @@ RSpec.describe Groups::GroupMembersHelper do
context 'group links' 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
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
......@@ -108,7 +108,7 @@ RSpec.describe Groups::GroupMembersHelper do
params: {}
}.as_json
expect(subject['access_request']['pagination']).to include(expected)
expect(subject[:access_request][:pagination].as_json).to include(expected)
end
end
......@@ -124,7 +124,7 @@ RSpec.describe Groups::GroupMembersHelper do
params: { invited_members_page: nil, search_invited: nil }
}.as_json
expect(subject['user']['pagination']).to include(expected)
expect(subject[:user][:pagination].as_json).to include(expected)
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