Verified Commit 11c4632c authored by Heidi Berry's avatar Heidi Berry Committed by GitLab
Browse files

Merge branch '1617-groups-custom-attrs' into 'main'

Add custom attributes filtering for groups

Closes #1617

See merge request !2959
parents 1a947ee2 149c9519
Loading
Loading
Loading
Loading
Loading
+114 −0
Original line number Diff line number Diff line
@@ -583,6 +583,120 @@ func Test_GroupsListSAMLUsers_Integration(t *testing.T) {
	assert.NotNil(t, users)
}

// Test_GroupsListGroups_CustomAttributesFiltering_Integration verifies that
// ListGroupsOptions.CustomAttributes is filtered server-side, and that
// multiple keys are combined with AND. Filtering by custom attributes requires
// an administrator token.
func Test_GroupsListGroups_CustomAttributesFiltering_Integration(t *testing.T) {
	// GIVEN a GitLab client and two test groups, only one of which carries the
	// attributes under test. The location value is unique per run so groups
	// left behind by an earlier run can't match the filter.
	client := SetupIntegrationClient(t)

	matching := CreateTestGroup(t, client)
	other := CreateTestGroup(t, client)

	location := fmt.Sprintf("antarctica-%d", time.Now().UnixNano())
	SetTestGroupCustomAttribute(t, client, matching.ID, "location", location)
	SetTestGroupCustomAttribute(t, client, matching.ID, "role", "developer")
	SetTestGroupCustomAttribute(t, client, other.ID, "location", "arctic")

	// WHEN listing groups filtered by both custom attributes
	groups, _, err := client.Groups.ListGroups(&gitlab.ListGroupsOptions{
		WithCustomAttributes: gitlab.Ptr(true),
		CustomAttributes: gitlab.CustomAttributesFilter{
			"location": location,
			"role":     "developer",
		},
	}, gitlab.WithContext(t.Context()))
	require.NoError(t, err, "Failed to list groups")

	// THEN only the group carrying both attributes is returned, with its
	// custom attributes included in the response
	require.Len(t, groups, 1)
	assert.Equal(t, matching.ID, groups[0].ID)
	assert.ElementsMatch(t, []*gitlab.CustomAttribute{
		{Key: "location", Value: location},
		{Key: "role", Value: "developer"},
	}, groups[0].CustomAttributes)

	// AND WHEN one of the two attribute values doesn't match
	groups, _, err = client.Groups.ListGroups(&gitlab.ListGroupsOptions{
		CustomAttributes: gitlab.CustomAttributesFilter{
			"location": location,
			"role":     "maintainer",
		},
	}, gitlab.WithContext(t.Context()))
	require.NoError(t, err, "Failed to list groups")

	// THEN no groups are returned, i.e. the keys are combined with AND
	assert.Empty(t, groups)
}

// Test_GroupsListSubGroups_CustomAttributesFiltering_Integration verifies the
// filter also applies to the subgroups endpoint, which uses the
// ListSubGroupsOptions alias of ListGroupsOptions.
func Test_GroupsListSubGroups_CustomAttributesFiltering_Integration(t *testing.T) {
	// GIVEN a GitLab client and a parent group with two subgroups, only one of
	// which carries the attribute under test
	client := SetupIntegrationClient(t)

	parent := CreateTestGroup(t, client)
	matching := CreateTestSubGroup(t, client, parent.ID)
	other := CreateTestSubGroup(t, client, parent.ID)

	location := fmt.Sprintf("antarctica-%d", time.Now().UnixNano())
	SetTestGroupCustomAttribute(t, client, matching.ID, "location", location)
	SetTestGroupCustomAttribute(t, client, other.ID, "location", "arctic")

	// WHEN listing the subgroups filtered by the custom attribute
	groups, _, err := client.Groups.ListSubGroups(parent.ID, &gitlab.ListSubGroupsOptions{
		WithCustomAttributes: gitlab.Ptr(true),
		CustomAttributes:     gitlab.CustomAttributesFilter{"location": location},
	}, gitlab.WithContext(t.Context()))
	require.NoError(t, err, "Failed to list subgroups")

	// THEN only the matching subgroup is returned
	require.Len(t, groups, 1)
	assert.Equal(t, matching.ID, groups[0].ID)
	assert.Equal(t, []*gitlab.CustomAttribute{
		{Key: "location", Value: location},
	}, groups[0].CustomAttributes)
}

// Test_GroupsListDescendantGroups_CustomAttributesFiltering_Integration
// verifies the filter also applies to the descendant groups endpoint, which
// uses the ListDescendantGroupsOptions alias of ListGroupsOptions. The
// attribute is set on a group nested two levels deep, which the subgroups
// endpoint wouldn't return.
func Test_GroupsListDescendantGroups_CustomAttributesFiltering_Integration(t *testing.T) {
	// GIVEN a GitLab client and a group hierarchy where only the deepest group
	// carries the attribute under test
	client := SetupIntegrationClient(t)

	parent := CreateTestGroup(t, client)
	child := CreateTestSubGroup(t, client, parent.ID)
	grandchild := CreateTestSubGroup(t, client, child.ID)

	location := fmt.Sprintf("antarctica-%d", time.Now().UnixNano())
	SetTestGroupCustomAttribute(t, client, grandchild.ID, "location", location)
	SetTestGroupCustomAttribute(t, client, child.ID, "location", "arctic")

	// WHEN listing the descendant groups filtered by the custom attribute
	groups, _, err := client.Groups.ListDescendantGroups(parent.ID, &gitlab.ListDescendantGroupsOptions{
		WithCustomAttributes: gitlab.Ptr(true),
		CustomAttributes:     gitlab.CustomAttributesFilter{"location": location},
	}, gitlab.WithContext(t.Context()))
	require.NoError(t, err, "Failed to list descendant groups")

	// THEN only the matching descendant group is returned
	require.Len(t, groups, 1)
	assert.Equal(t, grandchild.ID, groups[0].ID)
	assert.Equal(t, []*gitlab.CustomAttribute{
		{Key: "location", Value: location},
	}, groups[0].CustomAttributes)
}

func Test_GroupsSyncGroupWithLDAP_Integration(t *testing.T) {
	// GIVEN a GitLab client and a test group
	client := SetupIntegrationClient(t)
+32 −0
Original line number Diff line number Diff line
@@ -241,6 +241,38 @@ func CreateTestGroupWithOptions(t *testing.T, client *gitlab.Client, opts *gitla
	return group
}

// CreateTestSubGroup creates a test subgroup of the given parent group with a
// random name and path. The subgroup is automatically cleaned up when the test
// finishes.
func CreateTestSubGroup(t *testing.T, client *gitlab.Client, parentID int64) *gitlab.Group {
	t.Helper()

	suffix := time.Now().UnixNano()
	return CreateTestGroupWithOptions(t, client, &gitlab.CreateGroupOptions{
		Name:       gitlab.Ptr(fmt.Sprintf("testsubgroup%d", suffix)),
		Path:       gitlab.Ptr(fmt.Sprintf("testsubgroup%d", suffix)),
		Visibility: gitlab.Ptr(gitlab.PublicVisibility),
		ParentID:   gitlab.Ptr(parentID),
	})
}

// SetTestGroupCustomAttribute sets a custom attribute on the given group.
// Setting custom attributes requires an administrator token. The attribute is
// automatically cleaned up when the test finishes.
func SetTestGroupCustomAttribute(t *testing.T, client *gitlab.Client, gid int64, key, value string) {
	t.Helper()

	_, _, err := client.CustomAttribute.SetCustomGroupAttribute(gid, gitlab.CustomAttribute{
		Key:   key,
		Value: value,
	}, gitlab.WithContext(t.Context()))
	require.NoError(t, err, "Failed to set custom attribute on test group")

	t.Cleanup(func() {
		_, _ = client.CustomAttribute.DeleteCustomGroupAttribute(gid, key, gitlab.WithContext(context.Background()))
	})
}

// CreateTestEpic creates a test epic with a random title in the specified
// group. The epic is automatically cleaned up when the test finishes.
func CreateTestEpic(t *testing.T, client *gitlab.Client, gid any) (*gitlab.Epic, error) {
+6 −0
Original line number Diff line number Diff line
@@ -457,6 +457,12 @@ type ListGroupsOptions struct {
	MarkedForDeletionOn  *ISOTime          `url:"marked_for_deletion_on,omitempty" json:"marked_for_deletion_on,omitempty"`
	Active               *bool             `url:"active,omitempty" json:"active,omitempty"`
	Archived             *bool             `url:"archived,omitempty" json:"archived,omitempty"`

	// CustomAttributes filters groups by custom attributes, producing query
	// parameters of the form custom_attributes[key]=value. This is distinct
	// from WithCustomAttributes, which only controls whether custom attributes
	// are included in the response.
	CustomAttributes CustomAttributesFilter `url:"custom_attributes,omitempty" json:"custom_attributes,omitempty"`
}

func (s *GroupsService) ListGroups(opt *ListGroupsOptions, options ...RequestOptionFunc) ([]*Group, *Response, error) {
+120 −0
Original line number Diff line number Diff line
@@ -61,6 +61,126 @@ func TestListGroups_Filtering(t *testing.T) {
	assert.Equal(t, want, groups)
}

func TestListGroups_CustomAttributesFiltering(t *testing.T) {
	t.Parallel()
	mux, client := setup(t)

	mux.HandleFunc("/api/v4/groups", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodGet)

		testParam(t, r, "custom_attributes[location]", "Antarctica")
		testParam(t, r, "custom_attributes[role]", "Developer")
		testParam(t, r, "with_custom_attributes", "true")

		fmt.Fprint(w, `[
			{
				"id": 1,
				"custom_attributes": [
					{
						"key": "location",
						"value": "Antarctica"
					},
					{
						"key": "role",
						"value": "Developer"
					}
				]
			}
		]`)
	})

	withCustomAttributes := true
	opt := &ListGroupsOptions{
		WithCustomAttributes: &withCustomAttributes,
		CustomAttributes: CustomAttributesFilter{
			"location": "Antarctica",
			"role":     "Developer",
		},
	}

	groups, _, err := client.Groups.ListGroups(opt)
	require.NoError(t, err)

	want := []*Group{{
		ID: 1,
		CustomAttributes: []*CustomAttribute{
			{Key: "location", Value: "Antarctica"},
			{Key: "role", Value: "Developer"},
		},
	}}
	assert.Equal(t, want, groups)
}

// TestListGroups_CustomAttributesFiltering_Empty ensures that a nil or empty
// CustomAttributesFilter emits no custom_attributes query parameters (the
// omitempty tag is honored before EncodeValues is dispatched).
func TestListGroups_CustomAttributesFiltering_Empty(t *testing.T) {
	t.Parallel()
	mux, client := setup(t)

	mux.HandleFunc("/api/v4/groups", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodGet)

		for key := range r.URL.Query() {
			assert.NotContains(t, key, "custom_attributes",
				"expected no custom_attributes params, got %q", key)
		}

		fmt.Fprint(w, `[{"id": 1}]`)
	})

	for name, opt := range map[string]*ListGroupsOptions{
		"nil filter":   {CustomAttributes: nil},
		"empty filter": {CustomAttributes: CustomAttributesFilter{}},
	} {
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			groups, _, err := client.Groups.ListGroups(opt)
			require.NoError(t, err)
			assert.Equal(t, []*Group{{ID: 1}}, groups)
		})
	}
}

// TestListSubGroups_CustomAttributesFiltering and its descendant-groups
// counterpart confirm the filter also applies to the ListSubGroupsOptions and
// ListDescendantGroupsOptions aliases of ListGroupsOptions.
func TestListSubGroups_CustomAttributesFiltering(t *testing.T) {
	t.Parallel()
	mux, client := setup(t)

	mux.HandleFunc("/api/v4/groups/1/subgroups", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodGet)
		testParam(t, r, "custom_attributes[location]", "Antarctica")
		fmt.Fprint(w, `[{"id": 2}]`)
	})

	opt := &ListSubGroupsOptions{
		CustomAttributes: CustomAttributesFilter{"location": "Antarctica"},
	}
	groups, _, err := client.Groups.ListSubGroups(1, opt)
	require.NoError(t, err)
	assert.Equal(t, []*Group{{ID: 2}}, groups)
}

func TestListDescendantGroups_CustomAttributesFiltering(t *testing.T) {
	t.Parallel()
	mux, client := setup(t)

	mux.HandleFunc("/api/v4/groups/1/descendant_groups", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodGet)
		testParam(t, r, "custom_attributes[location]", "Antarctica")
		fmt.Fprint(w, `[{"id": 2}]`)
	})

	opt := &ListDescendantGroupsOptions{
		CustomAttributes: CustomAttributesFilter{"location": "Antarctica"},
	}
	groups, _, err := client.Groups.ListDescendantGroups(1, opt)
	require.NoError(t, err)
	assert.Equal(t, []*Group{{ID: 2}}, groups)
}

func TestListGroups_Visibility(t *testing.T) {
	t.Parallel()
	mux, client := setup(t)
+15 −0
Original line number Diff line number Diff line
@@ -595,6 +595,21 @@ func (l *LabelOptions) EncodeValues(key string, v *url.Values) error {
	return nil
}

// CustomAttributesFilter is a set of custom attribute key/value pairs used to
// filter list results. It encodes to query parameters of the form
// custom_attributes[key]=value, as expected by the GitLab API.
//
// GitLab API docs: https://docs.gitlab.com/api/custom_attributes/
type CustomAttributesFilter map[string]string

// EncodeValues implements the query.Encoder interface.
func (f CustomAttributesFilter) EncodeValues(key string, v *url.Values) error {
	for k, val := range f {
		v.Set(key+"["+k+"]", val)
	}
	return nil
}

// LinkTypeValue represents a release link type.
type LinkTypeValue string