diff --git a/go.mod b/go.mod index 81a922ec8..881971541 100644 --- a/go.mod +++ b/go.mod @@ -47,11 +47,13 @@ require ( github.com/gofrs/flock v0.8.1 github.com/google/cel-go v0.20.1 github.com/google/go-cmp v0.6.0 + github.com/google/go-github/v62 v62.0.0 github.com/google/gofuzz v1.2.0 github.com/google/uuid v1.6.0 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/websocket v1.5.1 github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 + github.com/migueleliasweb/go-github-mock v0.0.23 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 github.com/ory/fosite v0.46.2-0.20240403135905-5e039ca9eef1 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -120,6 +122,9 @@ require ( github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-github/v59 v59.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/gorilla/mux v1.8.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect diff --git a/go.sum b/go.sum index 1eeb8bdb6..29bb12b82 100644 --- a/go.sum +++ b/go.sum @@ -257,6 +257,12 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v59 v59.0.0 h1:7h6bgpF5as0YQLLkEiVqpgtJqjimMYhBkD4jT5aN3VA= +github.com/google/go-github/v59 v59.0.0/go.mod h1:rJU4R0rQHFVFDOkqGWxfLNo6vEk4dv40oDjhV/gH6wM= +github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= +github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= @@ -284,6 +290,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= @@ -440,6 +448,8 @@ github.com/mattn/goveralls v0.0.12/go.mod h1:44ImGEUfmqH8bBtaMrYKsM65LXfNLWmwaxF github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50= +github.com/migueleliasweb/go-github-mock v0.0.23 h1:GOi9oX/+Seu9JQ19V8bPDLqDI7M9iEOjo3g8v1k6L2c= +github.com/migueleliasweb/go-github-mock v0.0.23/go.mod h1:NsT8FGbkvIZQtDu38+295sZEX8snaUiiQgsGxi6GUxk= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= diff --git a/internal/githubclient/githubclient.go b/internal/githubclient/githubclient.go new file mode 100644 index 000000000..998b7d16b --- /dev/null +++ b/internal/githubclient/githubclient.go @@ -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 +} diff --git a/internal/githubclient/githubclient_test.go b/internal/githubclient/githubclient_test.go new file mode 100644 index 000000000..7e0a0af26 --- /dev/null +++ b/internal/githubclient/githubclient_test.go @@ -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) + }) + } +}