Commit 351d9b6f authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch '233856-cablett-group-users' into 'master'

GraphQL - Expose group memberships under group

See merge request gitlab-org/gitlab!39331
parents 697698a6 a5ffcc26
# frozen_string_literal: true
module Resolvers
class GroupMembersResolver < MembersResolver
authorize :read_group_member
private
def preloads
{
user: [:user, :source]
}
end
def finder_class
GroupMembersFinder
end
end
end
# frozen_string_literal: true
module Resolvers
class MembersResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
include LooksAhead
argument :search, GraphQL::STRING_TYPE,
required: false,
description: 'Search query'
def resolve_with_lookahead(**args)
authorize!(object)
apply_lookahead(finder_class.new(object, current_user, params: args).execute)
end
private
def finder_class
# override in subclass
end
end
end
# frozen_string_literal: true # frozen_string_literal: true
module Resolvers module Resolvers
class ProjectMembersResolver < BaseResolver class ProjectMembersResolver < MembersResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
argument :search, GraphQL::STRING_TYPE,
required: false,
description: 'Search query'
type Types::MemberInterface, null: true type Types::MemberInterface, null: true
authorize :read_project_member authorize :read_project_member
alias_method :project, :object private
def resolve(**args)
authorize!(project)
def finder_class
MembersFinder MembersFinder
.new(project, current_user, params: args)
.execute
end end
end end
end end
...@@ -75,6 +75,12 @@ module Types ...@@ -75,6 +75,12 @@ module Types
description: 'Title of the label' description: 'Title of the label'
end end
field :group_members,
Types::GroupMemberType.connection_type,
description: 'A membership of a user within this group',
extras: [:lookahead],
resolver: Resolvers::GroupMembersResolver
def label(title:) def label(title:)
BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args| BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
LabelsFinder LabelsFinder
......
...@@ -23,8 +23,7 @@ module Types ...@@ -23,8 +23,7 @@ module Types
description: 'Date and time the membership expires' description: 'Date and time the membership expires'
field :user, Types::UserType, null: false, field :user, Types::UserType, null: false,
description: 'User that is associated with the member object', description: 'User that is associated with the member object'
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.user_id).find }
definition_methods do definition_methods do
def resolve_type(object, context) def resolve_type(object, context)
......
...@@ -80,6 +80,7 @@ class GroupPolicy < BasePolicy ...@@ -80,6 +80,7 @@ class GroupPolicy < BasePolicy
enable :read_list enable :read_list
enable :read_label enable :read_label
enable :read_board enable :read_board
enable :read_group_member
end end
rule { ~can?(:read_group) }.policy do rule { ~can?(:read_group) }.policy do
......
---
title: Expose group memberships under group via GraphQL
merge_request: 39331
author:
type: added
...@@ -6711,6 +6711,36 @@ type Group { ...@@ -6711,6 +6711,36 @@ type Group {
""" """
fullPath: ID! fullPath: ID!
"""
A membership of a user within this group
"""
groupMembers(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Search query
"""
search: String
): GroupMemberConnection
""" """
Indicates if Group timelogs are enabled for namespace Indicates if Group timelogs are enabled for namespace
""" """
......
...@@ -18665,6 +18665,69 @@ ...@@ -18665,6 +18665,69 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "groupMembers",
"description": "A membership of a user within this group",
"args": [
{
"name": "search",
"description": "Search query",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "GroupMemberConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "groupTimelogsEnabled", "name": "groupTimelogsEnabled",
"description": "Indicates if Group timelogs are enabled for namespace", "description": "Indicates if Group timelogs are enabled for namespace",
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::GroupMembersResolver do
include GraphqlHelpers
it_behaves_like 'querying members with a group' do
let_it_be(:resource_member) { create(:group_member, user: user_1, group: group_1) }
let_it_be(:resource) { group_1 }
end
end
...@@ -5,62 +5,9 @@ require 'spec_helper' ...@@ -5,62 +5,9 @@ require 'spec_helper'
RSpec.describe Resolvers::ProjectMembersResolver do RSpec.describe Resolvers::ProjectMembersResolver do
include GraphqlHelpers include GraphqlHelpers
context "with a group" do it_behaves_like 'querying members with a group' do
let_it_be(:root_group) { create(:group) } let_it_be(:project) { create(:project, group: group_1) }
let_it_be(:group_1) { create(:group, parent: root_group) } let_it_be(:resource_member) { create(:project_member, user: user_1, project: project) }
let_it_be(:group_2) { create(:group, parent: root_group) } let_it_be(:resource) { project }
let_it_be(:project) { create(:project, group: group_1) }
let_it_be(:user_1) { create(:user, name: 'test user') }
let_it_be(:user_2) { create(:user, name: 'test user 2') }
let_it_be(:user_3) { create(:user, name: 'another user 1') }
let_it_be(:user_4) { create(:user, name: 'another user 2') }
let_it_be(:project_member) { create(:project_member, user: user_1, project: project) }
let_it_be(:group_1_member) { create(:group_member, user: user_2, group: group_1) }
let_it_be(:group_2_member) { create(:group_member, user: user_3, group: group_2) }
let_it_be(:root_group_member) { create(:group_member, user: user_4, group: root_group) }
let(:args) { {} }
subject do
resolve(described_class, obj: project, args: args, ctx: { current_user: user_4 })
end
describe '#resolve' do
it 'finds all project members' do
expect(subject).to contain_exactly(project_member, group_1_member, root_group_member)
end
context 'with search' do
context 'when the search term matches a user' do
let(:args) { { search: 'test' } }
it 'searches users by user name' do
expect(subject).to contain_exactly(project_member, group_1_member)
end
end
context 'when the search term does not match any user' do
let(:args) { { search: 'nothing' } }
it 'is empty' do
expect(subject).to be_empty
end
end
end
context 'when user can not see project members' do
let_it_be(:other_user) { create(:user) }
subject do
resolve(described_class, obj: project, args: args, ctx: { current_user: other_user })
end
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
end end
end end
...@@ -16,7 +16,7 @@ RSpec.describe GitlabSchema.types['Group'] do ...@@ -16,7 +16,7 @@ RSpec.describe GitlabSchema.types['Group'] do
web_url avatar_url share_with_group_lock project_creation_level web_url avatar_url share_with_group_lock project_creation_level
subgroup_creation_level require_two_factor_authentication subgroup_creation_level require_two_factor_authentication
two_factor_grace_period auto_devops_enabled emails_disabled two_factor_grace_period auto_devops_enabled emails_disabled
mentions_disabled parent boards milestones mentions_disabled parent boards milestones group_members
] ]
expect(described_class).to include_graphql_fields(*expected_fields) expect(described_class).to include_graphql_fields(*expected_fields)
...@@ -30,5 +30,12 @@ RSpec.describe GitlabSchema.types['Group'] do ...@@ -30,5 +30,12 @@ RSpec.describe GitlabSchema.types['Group'] do
end end
end end
describe 'members field' do
subject { described_class.fields['groupMembers'] }
it { is_expected.to have_graphql_type(Types::GroupMemberType.connection_type) }
it { is_expected.to have_graphql_resolver(Resolvers::GroupMembersResolver) }
end
it_behaves_like 'a GraphQL type with labels' it_behaves_like 'a GraphQL type with labels'
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'getting group members information' do
include GraphqlHelpers
let_it_be(:group) { create(:group, :public) }
let_it_be(:user) { create(:user) }
let_it_be(:user_1) { create(:user, username: 'user') }
let_it_be(:user_2) { create(:user, username: 'test') }
let(:member_data) { graphql_data['group']['groupMembers']['edges'] }
before do
[user_1, user_2].each { |user| group.add_guest(user) }
end
context 'when the request is correct' do
it_behaves_like 'a working graphql query' do
before do
fetch_members(user)
end
end
it 'returns group members successfully' do
fetch_members(user)
expect(graphql_errors).to be_nil
expect_array_response(user_1.to_global_id.to_s, user_2.to_global_id.to_s)
end
it 'returns members that match the search query' do
fetch_members(user, { search: 'test' })
expect(graphql_errors).to be_nil
expect_array_response(user_2.to_global_id.to_s)
end
end
def fetch_members(user = nil, args = {})
post_graphql(members_query(args), current_user: user)
end
def members_query(args = {})
members_node = <<~NODE
edges {
node {
user {
id
}
}
}
NODE
graphql_query_for("group",
{ full_path: group.full_path },
[query_graphql_field("groupMembers", args, members_node)]
)
end
def expect_array_response(*items)
expect(response).to have_gitlab_http_status(:success)
expect(member_data).to be_an Array
expect(member_data.map { |node| node["node"]["user"]["id"] }).to match_array(items)
end
end
...@@ -89,18 +89,13 @@ RSpec.describe 'getting group information', :do_not_mock_admin_mode do ...@@ -89,18 +89,13 @@ RSpec.describe 'getting group information', :do_not_mock_admin_mode do
end end
it 'avoids N+1 queries' do it 'avoids N+1 queries' do
control_count = ActiveRecord::QueryRecorder.new do pending('See: https://gitlab.com/gitlab-org/gitlab/-/issues/245272')
post_graphql(group_query(group1), current_user: admin)
end.count
queries = [{ query: group_query(group1) }, queries = [{ query: group_query(group1) },
{ query: group_query(group2) }] { query: group_query(group2) }]
expect do expect { post_multiplex(queries, current_user: admin) }
post_multiplex(queries, current_user: admin) .to issue_same_number_of_queries_as { post_graphql(group_query(group1), current_user: admin) }
end.not_to exceed_query_limit(control_count)
expect(graphql_errors).to contain_exactly(nil, nil)
end end
end end
......
...@@ -20,3 +20,64 @@ RSpec.shared_examples 'a working membership object query' do |model_option| ...@@ -20,3 +20,64 @@ RSpec.shared_examples 'a working membership object query' do |model_option|
).to eq('DEVELOPER') ).to eq('DEVELOPER')
end end
end end
RSpec.shared_examples 'querying members with a group' do
let_it_be(:root_group) { create(:group, :private) }
let_it_be(:group_1) { create(:group, :private, parent: root_group, name: 'Main Group') }
let_it_be(:group_2) { create(:group, :private, parent: root_group) }
let_it_be(:user_1) { create(:user, name: 'test user') }
let_it_be(:user_2) { create(:user, name: 'test user 2') }
let_it_be(:user_3) { create(:user, name: 'another user 1') }
let_it_be(:user_4) { create(:user, name: 'another user 2') }
let_it_be(:root_group_member) { create(:group_member, user: user_4, group: root_group) }
let_it_be(:group_1_member) { create(:group_member, user: user_2, group: group_1) }
let_it_be(:group_2_member) { create(:group_member, user: user_3, group: group_2) }
let(:args) { {} }
subject do
resolve(described_class, obj: resource, args: args, ctx: { current_user: user_4 })
end
describe '#resolve' do
before do
group_1.add_maintainer(user_4)
end
it 'finds all resource members' do
expect(subject).to contain_exactly(resource_member, group_1_member, root_group_member)
end
context 'with search' do
context 'when the search term matches a user' do
let(:args) { { search: 'test' } }
it 'searches users by user name' do
expect(subject).to contain_exactly(resource_member, group_1_member)
end
end
context 'when the search term does not match any user' do
let(:args) { { search: 'nothing' } }
it 'is empty' do
expect(subject).to be_empty
end
end
end
context 'when user can not see resource members' do
let_it_be(:other_user) { create(:user) }
subject do
resolve(described_class, obj: resource, args: args, ctx: { current_user: other_user })
end
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
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