Add client wrapper for github.com/google/go-github/v62

This commit is contained in:
Joshua Casey
2024-05-17 00:22:24 -05:00
parent 49c468f00a
commit c087e33b86
4 changed files with 775 additions and 0 deletions

View File

@@ -0,0 +1,160 @@
package githubclient
import (
"context"
"fmt"
"net/http"
"slices"
"github.com/google/go-github/v62/github"
)
const emptyUserMeansTheAuthenticatedUser = ""
type UserInfo struct {
ID string
Login string
}
type TeamInfo struct {
Name string
Slug string
Org string
}
type GitHubInterface interface {
GetUserInfo() (*UserInfo, error)
GetOrgMembership() ([]string, error)
GetTeamMembership(allowedOrganizations []string) ([]TeamInfo, error)
}
type githubClient struct {
client *github.Client
}
var _ GitHubInterface = (*githubClient)(nil)
func NewGitHubClient(httpClient *http.Client, apiBaseURL, token string) (GitHubInterface, error) {
if httpClient == nil {
return nil, fmt.Errorf("httpClient cannot be nil")
}
if token == "" {
return nil, fmt.Errorf("token cannot be empty string")
}
if apiBaseURL == "https://github.com" {
apiBaseURL = "https://api.github.com/"
}
client, err := github.NewClient(httpClient).WithEnterpriseURLs(apiBaseURL, "")
if err != nil {
return nil, fmt.Errorf("unable to create GitHub client using WithEnterpriseURLs: %w", err)
}
if client.BaseURL.Scheme != "https" {
return nil, fmt.Errorf(`apiBaseURL must use "https" protocol, found "%s" instead`, client.BaseURL.Scheme)
}
return &githubClient{
client: client.WithAuthToken(token),
}, nil
}
// GetUserInfo returns the "Login" and "ID" attributes of the logged-in user.
// TODO: where should context come from?
func (g *githubClient) GetUserInfo() (*UserInfo, error) {
user, response, err := g.client.Users.Get(context.Background(), emptyUserMeansTheAuthenticatedUser)
if err != nil {
return nil, fmt.Errorf("error fetching authenticated user: %w", err)
}
if user == nil { // untested
return nil, fmt.Errorf("error fetching authenticated user: user is nil")
}
if response == nil { // untested
return nil, fmt.Errorf("error fetching authenticated user: response is nil")
}
if user.ID == nil {
return nil, fmt.Errorf(`the "ID" attribute is missing for authenticated user`)
}
if user.Login == nil {
return nil, fmt.Errorf(`the "login" attribute is missing for authenticated user`)
}
return &UserInfo{
Login: user.GetLogin(),
ID: fmt.Sprintf("%d", user.GetID()),
}, nil
}
// GetOrgMembership returns an array of the "Login" attributes for all organizations to which the authenticated user belongs.
// TODO: where should context come from?
// TODO: what happens if login is nil?
func (g *githubClient) GetOrgMembership() ([]string, error) {
organizationsAsStrings := make([]string, 0)
opt := &github.ListOptions{PerPage: 10}
// get all pages of results
for {
organizationResults, response, err := g.client.Organizations.List(context.Background(), emptyUserMeansTheAuthenticatedUser, opt)
if err != nil {
return nil, fmt.Errorf("error fetching organizations for authenticated user: %w", err)
}
for _, organization := range organizationResults {
organizationsAsStrings = append(organizationsAsStrings, organization.GetLogin())
}
if response.NextPage == 0 {
break
}
opt.Page = response.NextPage
}
return organizationsAsStrings, nil
}
// GetTeamMembership returns a description of each team to which the authenticated user belongs, filtered by allowedOrganizations.
// Parent teams will also be returned.
// TODO: where should context come from?
// TODO: what happens if org or login or id are nil?
func (g *githubClient) GetTeamMembership(allowedOrganizations []string) ([]TeamInfo, error) {
teamInfos := make([]TeamInfo, 0)
opt := &github.ListOptions{PerPage: 10}
// get all pages of results
for {
teamsResults, response, err := g.client.Teams.ListUserTeams(context.Background(), opt)
if err != nil {
return nil, fmt.Errorf("error fetching team membership for authenticated user: %w", err)
}
for _, team := range teamsResults {
org := team.GetOrganization().GetLogin()
if !slices.Contains(allowedOrganizations, org) {
continue
}
teamInfos = append(teamInfos, TeamInfo{
Name: team.GetName(),
Slug: team.GetSlug(),
Org: org,
})
parent := team.GetParent()
if parent != nil {
teamInfos = append(teamInfos, TeamInfo{
Name: parent.GetName(),
Slug: parent.GetSlug(),
Org: org,
})
}
}
if response.NextPage == 0 {
break
}
opt.Page = response.NextPage
}
return teamInfos, nil
}

View File

@@ -0,0 +1,600 @@
package githubclient
import (
"net/http"
"strings"
"testing"
"github.com/google/go-github/v62/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/require"
"k8s.io/client-go/util/cert"
"go.pinniped.dev/internal/net/phttp"
"go.pinniped.dev/internal/testutil/tlsserver"
)
func TestNewGitHubClient(t *testing.T) {
t.Parallel()
t.Run("rejects nil http client", func(t *testing.T) {
_, err := NewGitHubClient(nil, "https://api.github.com/", "")
require.EqualError(t, err, "httpClient cannot be nil")
})
tests := []struct {
name string
apiBaseURL string
token string
wantBaseURL string
wantErr string
}{
{
name: "happy path with https://api.github.com/",
apiBaseURL: "https://api.github.com/",
token: "some-token",
wantBaseURL: "https://api.github.com/",
},
{
name: "happy path with https://api.github.com",
apiBaseURL: "https://api.github.com",
token: "other-token",
wantBaseURL: "https://api.github.com/",
},
{
name: "happy path with Enterprise URL https://fake.enterprise.tld",
apiBaseURL: "https://fake.enterprise.tld",
token: "some-enterprise-token",
wantBaseURL: "https://fake.enterprise.tld/api/v3/",
},
{
name: "coerces https://github.com into https://api.github.com/",
apiBaseURL: "https://github.com",
token: "some-token",
wantBaseURL: "https://api.github.com/",
},
{
name: "rejects apiBaseURL without https:// scheme",
apiBaseURL: "scp://github.com",
token: "some-token",
wantErr: `apiBaseURL must use "https" protocol, found "scp" instead`,
},
{
name: "rejects apiBaseURL with empty scheme",
apiBaseURL: "github.com",
token: "some-token",
wantErr: `apiBaseURL must use "https" protocol, found "" instead`,
},
{
name: "rejects empty token",
apiBaseURL: "https://api.github.com/",
wantErr: "token cannot be empty string",
},
{
name: "returns errors from WithEnterpriseURLs",
apiBaseURL: "https:// example.com",
token: "some-token",
wantErr: `unable to create GitHub client using WithEnterpriseURLs: parse "https:// example.com": invalid character " " in host name`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
called := false
testServer, testServerCA := tlsserver.TestServerIPv4(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Len(t, r.Header["Authorization"], 1)
require.Equal(t, "Bearer "+test.token, r.Header.Get("Authorization"))
called = true
}), nil)
t.Cleanup(func() {
require.True(t, (test.wantErr == "" && called) || (test.wantErr != "" && !called))
})
pool, err := cert.NewPoolFromBytes(testServerCA)
require.NoError(t, err)
httpClient := phttp.Default(pool)
actualI, err := NewGitHubClient(httpClient, test.apiBaseURL, test.token)
if test.wantErr != "" {
require.EqualError(t, err, test.wantErr)
return
}
require.NotNil(t, actualI)
actual, ok := actualI.(*githubClient)
require.True(t, ok)
require.NotNil(t, actual.client.BaseURL)
require.Equal(t, test.wantBaseURL, actual.client.BaseURL.String())
//require.Equal(t, httpClient, actual.client.Client())
// Force the githubClient's httpClient roundTrippers to run and add the Authorization header
_, err = actual.client.Client().Get(testServer.URL)
require.NoError(t, err)
})
}
}
func TestGetUser(t *testing.T) {
t.Parallel()
tests := []struct {
name string
httpClient *http.Client
token string
wantErr string
wantUserInfo UserInfo
}{
{
name: "happy path",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUser,
github.User{
Login: github.String("some-username"),
ID: github.Int64(12345678),
},
),
),
token: "some-token",
wantUserInfo: UserInfo{
Login: "some-username",
ID: "12345678",
},
},
{
name: "the token is added in the Authorization header",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUser,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "Bearer does-this-token-work", r.Header.Get("Authorization"))
_, err := w.Write([]byte(`{"login":"some-authenticated-username","id":999888}`))
require.NoError(t, err)
}),
),
),
token: "does-this-token-work",
wantUserInfo: UserInfo{
Login: "some-authenticated-username",
ID: "999888",
},
},
{
name: "handles missing login",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUser,
github.User{
ID: github.Int64(12345678),
},
),
),
token: "does-this-token-work",
wantErr: `the "login" attribute is missing for authenticated user`,
},
{
name: "handles missing ID",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUser,
github.User{
Login: github.String("some-username"),
},
),
),
token: "does-this-token-work",
wantErr: `the "ID" attribute is missing for authenticated user`,
},
{
name: "returns errors from the API",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUser,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mock.WriteError(
w,
http.StatusInternalServerError,
"internal server error from the server",
)
}),
),
),
token: "some-token",
wantErr: "error fetching authenticated user: GET {SERVER_URL}/user: 500 internal server error from the server []",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
githubClient := &githubClient{
client: github.NewClient(test.httpClient).WithAuthToken(test.token),
}
actual, err := githubClient.GetUserInfo()
if test.wantErr != "" {
rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper)
require.True(t, ok)
test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host)
require.EqualError(t, err, test.wantErr)
return
}
require.NotNil(t, actual)
require.Equal(t, test.wantUserInfo, *actual)
})
}
}
func TestGetOrgMembership(t *testing.T) {
t.Parallel()
tests := []struct {
name string
httpClient *http.Client
token string
wantErr string
wantOrgs []string
}{
{
name: "happy path",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserOrgs,
[]github.Organization{
{Login: github.String("org1")},
{Login: github.String("org2")},
{Login: github.String("org3")},
},
),
),
token: "some-token",
wantOrgs: []string{"org1", "org2", "org3"},
},
{
name: "happy path with pagination",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchPages(
mock.GetUserOrgs,
[]github.Organization{
{Login: github.String("page1-org1")},
{Login: github.String("page1-org2")},
{Login: github.String("page1-org3")},
},
[]github.Organization{
{Login: github.String("page2-org1")},
{Login: github.String("page2-org2")},
{Login: github.String("page2-org3")},
},
),
),
token: "some-token",
wantOrgs: []string{"page1-org1", "page1-org2", "page1-org3", "page2-org1", "page2-org2", "page2-org3"},
},
{
name: "the token is added in the Authorization header",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUserOrgs,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "Bearer does-this-token-work", r.Header.Get("Authorization"))
_, err := w.Write([]byte(`[{"login":"some-org-to-which-the-authenticated-user-belongs"}]`))
require.NoError(t, err)
}),
),
),
token: "does-this-token-work",
wantOrgs: []string{"some-org-to-which-the-authenticated-user-belongs"},
},
{
name: "returns errors from the API",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUserOrgs,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mock.WriteError(
w,
http.StatusFailedDependency,
"some random client error",
)
}),
),
),
token: "some-token",
wantErr: "error fetching organizations for authenticated user: GET {SERVER_URL}/user/orgs?per_page=10: 424 some random client error []",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
githubClient := &githubClient{
client: github.NewClient(test.httpClient).WithAuthToken(test.token),
}
actual, err := githubClient.GetOrgMembership()
if test.wantErr != "" {
rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper)
require.True(t, ok)
test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host)
require.EqualError(t, err, test.wantErr)
return
}
require.NotNil(t, actual)
require.Equal(t, test.wantOrgs, actual)
})
}
}
func TestGetTeamMembership(t *testing.T) {
t.Parallel()
tests := []struct {
name string
httpClient *http.Client
token string
allowedOrganizations []string
wantErr string
wantTeams []TeamInfo
}{
{
name: "happy path",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserTeams,
[]github.Team{
{
Name: github.String("orgAlpha-team1-name"),
Slug: github.String("orgAlpha-team1-slug"),
Organization: &github.Organization{
Login: github.String("alpha"),
},
},
{
Name: github.String("orgAlpha-team2-name"),
Slug: github.String("orgAlpha-team2-slug"),
Organization: &github.Organization{
Login: github.String("alpha"),
},
},
{
Name: github.String("orgAlpha-team3-name"),
Slug: github.String("orgAlpha-team3-slug"),
Organization: &github.Organization{
Login: github.String("alpha"),
},
},
{
Name: github.String("orgBeta-team1-name"),
Slug: github.String("orgBeta-team1-slug"),
Organization: &github.Organization{
Login: github.String("beta"),
},
},
},
),
),
token: "some-token",
allowedOrganizations: []string{"alpha", "beta"},
wantTeams: []TeamInfo{
{
Name: "orgAlpha-team1-name",
Slug: "orgAlpha-team1-slug",
Org: "alpha",
},
{
Name: "orgAlpha-team2-name",
Slug: "orgAlpha-team2-slug",
Org: "alpha",
},
{
Name: "orgAlpha-team3-name",
Slug: "orgAlpha-team3-slug",
Org: "alpha",
},
{
Name: "orgBeta-team1-name",
Slug: "orgBeta-team1-slug",
Org: "beta",
},
},
},
{
name: "filters by allowedOrganizations",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserTeams,
[]github.Team{
{
Name: github.String("team1-name"),
Slug: github.String("team1-slug"),
Organization: &github.Organization{
Login: github.String("alpha"),
},
},
{
Name: github.String("team2-name"),
Slug: github.String("team2-slug"),
Organization: &github.Organization{
Login: github.String("beta"),
},
},
{
Name: github.String("team3-name"),
Slug: github.String("team3-slug"),
Organization: &github.Organization{
Login: github.String("gamma"),
},
},
},
),
),
token: "some-token",
allowedOrganizations: []string{"alpha", "gamma"},
wantTeams: []TeamInfo{
{
Name: "team1-name",
Slug: "team1-slug",
Org: "alpha",
},
{
Name: "team3-name",
Slug: "team3-slug",
Org: "gamma",
},
},
},
{
name: "includes parent team if present",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetUserTeams,
[]github.Team{
{
Name: github.String("team-name-with-parent"),
Slug: github.String("team-slug-with-parent"),
Parent: &github.Team{
Name: github.String("parent-team-name"),
Slug: github.String("parent-team-slug"),
Organization: &github.Organization{
Login: github.String("parent-team-org-that-in-reality-can-never-be-different-than-child-team-org"),
},
},
Organization: &github.Organization{
Login: github.String("org-with-nested-teams"),
},
},
{
Name: github.String("team-name-without-parent"),
Slug: github.String("team-slug-without-parent"),
Organization: &github.Organization{
Login: github.String("beta"),
},
},
},
),
),
token: "some-token",
allowedOrganizations: []string{"org-with-nested-teams", "beta"},
wantTeams: []TeamInfo{
{
Name: "team-name-with-parent",
Slug: "team-slug-with-parent",
Org: "org-with-nested-teams",
},
{
Name: "parent-team-name",
Slug: "parent-team-slug",
Org: "org-with-nested-teams",
},
{
Name: "team-name-without-parent",
Slug: "team-slug-without-parent",
Org: "beta",
},
},
},
{
name: "happy path with pagination",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchPages(
mock.GetUserTeams,
[]github.Team{
{
Name: github.String("page1-team-name"),
Slug: github.String("page1-team-slug"),
Organization: &github.Organization{
Login: github.String("page1-org-name"),
},
},
},
[]github.Team{
{
Name: github.String("page2-team-name"),
Slug: github.String("page2-team-slug"),
Organization: &github.Organization{
Login: github.String("page2-org-name"),
},
},
},
),
),
token: "some-token",
allowedOrganizations: []string{"page1-org-name", "page2-org-name"},
wantTeams: []TeamInfo{
{
Name: "page1-team-name",
Slug: "page1-team-slug",
Org: "page1-org-name",
},
{
Name: "page2-team-name",
Slug: "page2-team-slug",
Org: "page2-org-name",
},
},
},
{
name: "the token is added in the Authorization header",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUserTeams,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "Bearer does-this-token-work", r.Header.Get("Authorization"))
_, err := w.Write([]byte(`[{"name":"team1-name","slug":"team1-slug","organization":{"login":"org-login"}}]`))
require.NoError(t, err)
}),
),
),
token: "does-this-token-work",
allowedOrganizations: []string{"org-login"},
wantTeams: []TeamInfo{
{
Name: "team1-name",
Slug: "team1-slug",
Org: "org-login",
},
},
},
{
name: "returns errors from the API",
httpClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetUserTeams,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mock.WriteError(
w,
http.StatusFailedDependency,
"some random client error",
)
}),
),
),
token: "some-token",
wantErr: "error fetching team membership for authenticated user: GET {SERVER_URL}/user/teams?per_page=10: 424 some random client error []",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
githubClient := &githubClient{
client: github.NewClient(test.httpClient).WithAuthToken(test.token),
}
actual, err := githubClient.GetTeamMembership(test.allowedOrganizations)
if test.wantErr != "" {
rt, ok := test.httpClient.Transport.(*mock.EnforceHostRoundTripper)
require.True(t, ok)
test.wantErr = strings.ReplaceAll(test.wantErr, "{SERVER_URL}", rt.Host)
require.EqualError(t, err, test.wantErr)
return
}
require.NotNil(t, actual)
require.Equal(t, test.wantTeams, actual)
})
}
}