diff --git a/pkg/api/v1beta1/users.go b/pkg/api/v1beta1/users.go index 1751e6a..7aba160 100644 --- a/pkg/api/v1beta1/users.go +++ b/pkg/api/v1beta1/users.go @@ -32,6 +32,7 @@ var ( type User struct { *models.User Memberships []string `json:"memberships,omitempty"` + MembershipsDirect []string `json:"memberships_direct,omitempty"` MembershipRequests []string `json:"membership_requests,omitempty"` } diff --git a/pkg/client/governor_test.go b/pkg/client/governor_test.go index b728067..c8df11d 100644 --- a/pkg/client/governor_test.go +++ b/pkg/client/governor_test.go @@ -225,6 +225,40 @@ var ( "github_id": 10000001, "github_username": "johnnyTog" } +`) + + testGroupHierarchiesResponse = []byte(` +[ + { + "id": "31bcb9c0-95e0-4c78-b9af-8b998c8bd21c", + "parent_group_id": "186c5a52-4421-4573-8bbf-78d85d3c277e", + "parent_group_slug": "test-1", + "member_group_id": "f94c8cc2-375b-4043-863d-1dcd57ff60c7", + "member_group_slug": "test-2", + "expires_at": null + }, + { + "id": "622b27f2-c1b6-4b91-aed7-784c8bf76736", + "parent_group_id": "f94c8cc2-375b-4043-863d-1dcd57ff60c7", + "parent_group_slug": "test-2", + "member_group_id": "fa606133-18f0-4ff4-b92e-d344398ed05b", + "member_group_slug": "test-3", + "expires_at": null + } +] +`) + + testMemberGroupsResponse = []byte(` +[ + { + "id": "31bcb9c0-95e0-4c78-b9af-8b998c8bd21c", + "parent_group_id": "186c5a52-4421-4573-8bbf-78d85d3c277e", + "parent_group_slug": "test-1", + "member_group_id": "f94c8cc2-375b-4043-863d-1dcd57ff60c7", + "member_group_slug": "test-2", + "expires_at": null + } +] `) ) diff --git a/pkg/client/hierarchies.go b/pkg/client/hierarchies.go new file mode 100644 index 0000000..5a613d0 --- /dev/null +++ b/pkg/client/hierarchies.go @@ -0,0 +1,176 @@ +package client + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + + "github.com/goccy/go-json" + "github.com/metal-toolbox/governor-api/pkg/api/v1alpha1" + "github.com/volatiletech/null/v8" +) + +// GroupHierarchies lists all hierarchical group relationships in governor +func (c *Client) GroupHierarchies(ctx context.Context) (*[]v1alpha1.GroupHierarchy, error) { + u := fmt.Sprintf("%s/api/%s/groups/hierarchies", c.url, governorAPIVersionAlpha) + + req, err := c.newGovernorRequest(ctx, http.MethodGet, u) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, ErrRequestNonSuccess + } + + out := []v1alpha1.GroupHierarchy{} + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + return &out, nil +} + +// MemberGroups lists member groups of a parent group in governor +func (c *Client) MemberGroups(ctx context.Context, id string) (*[]v1alpha1.GroupHierarchy, error) { + if id == "" { + return nil, ErrMissingGroupID + } + + u := fmt.Sprintf("%s/api/%s/groups/%s/hierarchies", c.url, governorAPIVersionAlpha, id) + + req, err := c.newGovernorRequest(ctx, http.MethodGet, u) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, ErrRequestNonSuccess + } + + out := []v1alpha1.GroupHierarchy{} + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + return &out, nil +} + +// AddMemberGroup creates a new group hierarchy relationship in governor +func (c *Client) AddMemberGroup(ctx context.Context, parentGroupID, memberGroupID string, expiresAt null.Time) error { + if parentGroupID == "" || memberGroupID == "" { + return ErrNilGroupRequest + } + + body := struct { + ExpiresAt null.Time `json:"expires_at"` + MemberGroupID string `json:"member_group_id"` + }{ + ExpiresAt: expiresAt, + MemberGroupID: memberGroupID, + } + + req, err := c.newGovernorRequest(ctx, http.MethodPost, fmt.Sprintf("%s/api/%s/groups/%s/hierarchies", c.url, governorAPIVersionAlpha, parentGroupID)) + if err != nil { + return err + } + + b, err := json.Marshal(body) + if err != nil { + return err + } + + req.Body = io.NopCloser(bytes.NewBuffer(b)) + + resp, err := c.httpClient.Do(req.WithContext(ctx)) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent { + return ErrRequestNonSuccess + } + + return nil +} + +// UpdateMemberGroup updates the expiration on a group hierarchy relationship in governor +func (c *Client) UpdateMemberGroup(ctx context.Context, parentGroupID, memberGroupID string, expiresAt null.Time) error { + if parentGroupID == "" || memberGroupID == "" { + return ErrNilGroupRequest + } + + body := struct { + ExpiresAt null.Time `json:"expires_at"` + }{ + ExpiresAt: expiresAt, + } + + req, err := c.newGovernorRequest(ctx, http.MethodPatch, fmt.Sprintf("%s/api/%s/groups/%s/hierarchies/%s", c.url, governorAPIVersionAlpha, parentGroupID, memberGroupID)) + if err != nil { + return err + } + + b, err := json.Marshal(body) + if err != nil { + return err + } + + req.Body = io.NopCloser(bytes.NewBuffer(b)) + + resp, err := c.httpClient.Do(req.WithContext(ctx)) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent { + return ErrRequestNonSuccess + } + + return nil +} + +// DeleteMemberGroup deletes a group hierarchy relationship in governor +func (c *Client) DeleteMemberGroup(ctx context.Context, parentGroupID, memberGroupID string) error { + if parentGroupID == "" || memberGroupID == "" { + return ErrNilGroupRequest + } + + req, err := c.newGovernorRequest(ctx, http.MethodDelete, fmt.Sprintf("%s/api/%s/groups/%s/hierarchies/%s", c.url, governorAPIVersionAlpha, parentGroupID, memberGroupID)) + if err != nil { + return err + } + + resp, err := c.httpClient.Do(req.WithContext(ctx)) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent { + return ErrRequestNonSuccess + } + + return nil +} diff --git a/pkg/client/hierarchies_test.go b/pkg/client/hierarchies_test.go new file mode 100644 index 0000000..85ada4a --- /dev/null +++ b/pkg/client/hierarchies_test.go @@ -0,0 +1,372 @@ +package client + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/metal-toolbox/governor-api/pkg/api/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/volatiletech/null/v8" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +func TestClient_GroupHierarchies(t *testing.T) { + testResp := func(r []byte) *[]v1alpha1.GroupHierarchy { + resp := []v1alpha1.GroupHierarchy{} + if err := json.Unmarshal(r, &resp); err != nil { + t.Error(err) + } + + return &resp + } + + type fields struct { + httpClient HTTPDoer + } + + tests := []struct { + name string + fields fields + want *[]v1alpha1.GroupHierarchy + wantErr bool + }{ + { + name: "example request", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + resp: testGroupHierarchiesResponse, + statusCode: http.StatusOK, + }, + }, + want: testResp(testGroupHierarchiesResponse), + }, + { + name: "non-success", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + statusCode: http.StatusInternalServerError, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{ + url: "https://the.gov/", + logger: zap.NewNop(), + httpClient: tt.fields.httpClient, + clientCredentialConfig: &mockTokener{t: t}, + token: &oauth2.Token{AccessToken: "topSekret"}, + } + got, err := c.GroupHierarchies(context.TODO()) + if tt.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestClient_MemberGroups(t *testing.T) { + testResp := func(r []byte) *[]v1alpha1.GroupHierarchy { + resp := []v1alpha1.GroupHierarchy{} + if err := json.Unmarshal(r, &resp); err != nil { + t.Error(err) + } + + return &resp + } + + type fields struct { + httpClient HTTPDoer + } + + tests := []struct { + name string + fields fields + id string + want *[]v1alpha1.GroupHierarchy + wantErr bool + }{ + { + name: "example request", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + resp: testMemberGroupsResponse, + statusCode: http.StatusOK, + }, + }, + id: "186c5a52-4421-4573-8bbf-78d85d3c277e", + want: testResp(testMemberGroupsResponse), + }, + { + name: "non-success", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + statusCode: http.StatusInternalServerError, + }, + }, + id: "186c5a52-4421-4573-8bbf-78d85d3c277e", + wantErr: true, + }, + { + name: "bad json response", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + statusCode: http.StatusOK, + resp: []byte(`{`), + }, + }, + id: "186c5a52-4421-4573-8bbf-78d85d3c277e", + wantErr: true, + }, + { + name: "missing id in request", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + statusCode: http.StatusOK, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{ + url: "https://the.gov/", + logger: zap.NewNop(), + httpClient: tt.fields.httpClient, + clientCredentialConfig: &mockTokener{t: t}, + token: &oauth2.Token{AccessToken: "topSekret"}, + } + got, err := c.MemberGroups(context.TODO(), tt.id) + if tt.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestClient_AddMemberGroup(t *testing.T) { + type fields struct { + httpClient HTTPDoer + } + + tests := []struct { + name string + fields fields + parentGroupID string + memberGroupID string + expiresAt null.Time + wantErr bool + }{ + { + name: "example request", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + resp: testMemberGroupsResponse, + statusCode: http.StatusOK, + }, + }, + parentGroupID: "186c5a52-4421-4573-8bbf-78d85d3c277e", + memberGroupID: "186c5a52-4421-4573-8bbf-78d85d3c277f", + expiresAt: null.Time{}, + }, + { + name: "non-success", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + statusCode: http.StatusInternalServerError, + }, + }, + parentGroupID: "186c5a52-4421-4573-8bbf-78d85d3c277e", + memberGroupID: "186c5a52-4421-4573-8bbf-78d85d3c277f", + expiresAt: null.Time{}, + wantErr: true, + }, + { + name: "missing fields in request", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + statusCode: http.StatusOK, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{ + url: "https://the.gov/", + logger: zap.NewNop(), + httpClient: tt.fields.httpClient, + clientCredentialConfig: &mockTokener{t: t}, + token: &oauth2.Token{AccessToken: "topSekret"}, + } + err := c.AddMemberGroup(context.TODO(), tt.parentGroupID, tt.memberGroupID, tt.expiresAt) + if tt.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +} + +func TestClient_UpdateMemberGroup(t *testing.T) { + type fields struct { + httpClient HTTPDoer + } + + tests := []struct { + name string + fields fields + parentGroupID string + memberGroupID string + expiresAt null.Time + wantErr bool + }{ + { + name: "example request", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + resp: testMemberGroupsResponse, + statusCode: http.StatusOK, + }, + }, + parentGroupID: "186c5a52-4421-4573-8bbf-78d85d3c277e", + memberGroupID: "186c5a52-4421-4573-8bbf-78d85d3c277f", + expiresAt: null.Time{}, + }, + { + name: "non-success", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + statusCode: http.StatusInternalServerError, + }, + }, + parentGroupID: "186c5a52-4421-4573-8bbf-78d85d3c277e", + memberGroupID: "186c5a52-4421-4573-8bbf-78d85d3c277f", + expiresAt: null.Time{}, + wantErr: true, + }, + { + name: "missing fields in request", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + statusCode: http.StatusOK, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{ + url: "https://the.gov/", + logger: zap.NewNop(), + httpClient: tt.fields.httpClient, + clientCredentialConfig: &mockTokener{t: t}, + token: &oauth2.Token{AccessToken: "topSekret"}, + } + err := c.UpdateMemberGroup(context.TODO(), tt.parentGroupID, tt.memberGroupID, tt.expiresAt) + if tt.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +} + +func TestClient_DeleteMemberGroup(t *testing.T) { + type fields struct { + httpClient HTTPDoer + } + + tests := []struct { + name string + fields fields + parentGroupID string + memberGroupID string + wantErr bool + }{ + { + name: "example request", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + resp: testMemberGroupsResponse, + statusCode: http.StatusOK, + }, + }, + parentGroupID: "186c5a52-4421-4573-8bbf-78d85d3c277e", + memberGroupID: "186c5a52-4421-4573-8bbf-78d85d3c277f", + }, + { + name: "non-success", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + statusCode: http.StatusInternalServerError, + }, + }, + parentGroupID: "186c5a52-4421-4573-8bbf-78d85d3c277e", + memberGroupID: "186c5a52-4421-4573-8bbf-78d85d3c277f", + wantErr: true, + }, + { + name: "missing fields in request", + fields: fields{ + httpClient: &mockHTTPDoer{ + t: t, + statusCode: http.StatusOK, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{ + url: "https://the.gov/", + logger: zap.NewNop(), + httpClient: tt.fields.httpClient, + clientCredentialConfig: &mockTokener{t: t}, + token: &oauth2.Token{AccessToken: "topSekret"}, + } + err := c.DeleteMemberGroup(context.TODO(), tt.parentGroupID, tt.memberGroupID) + if tt.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +}