// Copyright 2024-2025 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package githubclient import ( "context" "net/http" "strings" "testing" "github.com/google/go-github/v76/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/setutil" "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, "unable to build new github client: 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: "adds trailing slash to path for https://api.github.com", apiBaseURL: "https://api.github.com", token: "other-token", wantBaseURL: "https://api.github.com/", }, { name: "adds trailing slash to path for Enterprise URL https://fake.enterprise.tld/api/v3", apiBaseURL: "https://fake.enterprise.tld/api/v3", token: "some-enterprise-token", wantBaseURL: "https://fake.enterprise.tld/api/v3/", }, { name: "rejects apiBaseURL without https:// scheme", apiBaseURL: "scp://github.com", token: "some-token", wantErr: `unable to build new github client: apiBaseURL must use "https" protocol, found "scp" instead`, }, { name: "rejects apiBaseURL with empty scheme", apiBaseURL: "github.com", token: "some-token", wantErr: `unable to build new github client: apiBaseURL must use "https" protocol, found "" instead`, }, { name: "rejects empty token", apiBaseURL: "https://api.github.com/", wantErr: "unable to build new github client: token cannot be empty string", }, { name: "returns errors from url.Parse", apiBaseURL: "https:// example.com", token: "some-token", wantErr: `unable to build new github client: 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) } else { require.NoError(t, err) 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()) // Force the githubClient's httpClient roundTrippers to run and add the Authorization header req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testServer.URL, nil) require.NoError(t, err) _, err = actual.client.Client().Do(req) //nolint:bodyclose require.NoError(t, err) } }) } } func TestGetUser(t *testing.T) { t.Parallel() tests := []struct { name string httpClient *http.Client token string ctx context.Context wantErr string wantUserInfo UserInfo }{ { name: "happy path", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUser, github.User{ Login: github.Ptr("some-username"), ID: github.Ptr[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.Len(t, r.Header["Authorization"], 1) 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.Ptr[int64](12345678), }, ), ), token: "does-this-token-work", wantErr: `error fetching authenticated user: the "login" attribute is missing`, }, { name: "handles missing ID", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUser, github.User{ Login: github.Ptr("some-username"), }, ), ), token: "does-this-token-work", wantErr: `error fetching authenticated user: the "id" attribute is missing`, }, { name: "passes the context parameter into the API call", token: "some-token", httpClient: mock.NewMockedHTTPClient(), ctx: func() context.Context { canceledCtx, cancel := context.WithCancel(context.Background()) cancel() return canceledCtx }(), wantErr: "error fetching authenticated user: context canceled", }, { 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), } ctx := context.Background() if test.ctx != nil { ctx = test.ctx } actual, err := githubClient.GetUserInfo(ctx) 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) } else { require.NoError(t, err) 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 ctx context.Context wantErr string wantOrgs []string }{ { name: "happy path", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUserOrgs, []github.Organization{ {Login: github.Ptr("org1")}, {Login: github.Ptr("org2")}, {Login: github.Ptr("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.Ptr("page1-org1")}, {Login: github.Ptr("page1-org2")}, {Login: github.Ptr("page1-org3")}, }, []github.Organization{ {Login: github.Ptr("page2-org1")}, {Login: github.Ptr("page2-org2")}, {Login: github.Ptr("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.Len(t, r.Header["Authorization"], 1) 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: "errors when a Login field is empty", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUserOrgs, []github.Organization{ {Login: github.Ptr("page1-org1")}, {Login: nil}, {Login: github.Ptr("page1-org3")}, }, ), ), token: "some-token", wantErr: `error fetching organizations for authenticated user: one or more organizations is missing the "login" attribute`, }, { name: "passes the context parameter into the API call", token: "some-token", httpClient: mock.NewMockedHTTPClient(), ctx: func() context.Context { canceledCtx, cancel := context.WithCancel(context.Background()) cancel() return canceledCtx }(), wantErr: "error fetching organizations for authenticated user: context canceled", }, { 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=100: 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), } ctx := context.Background() if test.ctx != nil { ctx = test.ctx } actual, err := githubClient.GetOrgMembership(ctx) 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.ElementsMatch(t, test.wantOrgs, actual) }) } } func TestGetTeamMembership(t *testing.T) { t.Parallel() tests := []struct { name string httpClient *http.Client token string ctx context.Context allowedOrganizations *setutil.CaseInsensitiveSet wantErr string wantTeams []TeamInfo }{ { name: "happy path", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUserTeams, []github.Team{ { Name: github.Ptr("orgAlpha-team1-name"), Slug: github.Ptr("orgAlpha-team1-slug"), Organization: &github.Organization{ Login: github.Ptr("alpha"), }, }, { Name: github.Ptr("orgAlpha-team2-name"), Slug: github.Ptr("orgAlpha-team2-slug"), Organization: &github.Organization{ Login: github.Ptr("alpha"), }, }, { Name: github.Ptr("orgAlpha-team3-name"), Slug: github.Ptr("orgAlpha-team3-slug"), Organization: &github.Organization{ Login: github.Ptr("alpha"), }, }, { Name: github.Ptr("orgBeta-team1-name"), Slug: github.Ptr("orgBeta-team1-slug"), Organization: &github.Organization{ Login: github.Ptr("beta"), }, }, }, ), ), token: "some-token", allowedOrganizations: setutil.NewCaseInsensitiveSet("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 in a case-insensitive way, but preserves case as returned by GitHub API in the result", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUserTeams, []github.Team{ { Name: github.Ptr("team1-name"), Slug: github.Ptr("team1-slug"), Organization: &github.Organization{ Login: github.Ptr("alPhA"), }, }, { Name: github.Ptr("team2-name"), Slug: github.Ptr("team2-slug"), Organization: &github.Organization{ Login: github.Ptr("bEtA"), }, }, { Name: github.Ptr("team3-name"), Slug: github.Ptr("team3-slug"), Organization: &github.Organization{ Login: github.Ptr("gAmmA"), }, }, }, ), ), token: "some-token", allowedOrganizations: setutil.NewCaseInsensitiveSet("ALPHA", "gamma"), wantTeams: []TeamInfo{ { Name: "team1-name", Slug: "team1-slug", Org: "alPhA", }, { Name: "team3-name", Slug: "team3-slug", Org: "gAmmA", }, }, }, { name: "when allowedOrganizations is empty, return all teams", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUserTeams, []github.Team{ { Name: github.Ptr("team1-name"), Slug: github.Ptr("team1-slug"), Organization: &github.Organization{ Login: github.Ptr("alpha"), }, }, { Name: github.Ptr("team2-name"), Slug: github.Ptr("team2-slug"), Organization: &github.Organization{ Login: github.Ptr("beta"), }, }, { Name: github.Ptr("team3-name"), Slug: github.Ptr("team3-slug"), Parent: &github.Team{ Name: github.Ptr("delta-team-name"), Slug: github.Ptr("delta-team-slug"), Organization: nil, // the real GitHub API does not return Org on "Parent" team. }, Organization: &github.Organization{ Login: github.Ptr("gamma"), }, }, }, ), ), token: "some-token", wantTeams: []TeamInfo{ { Name: "team1-name", Slug: "team1-slug", Org: "alpha", }, { Name: "team2-name", Slug: "team2-slug", Org: "beta", }, { Name: "delta-team-name", Slug: "delta-team-slug", Org: "gamma", }, { Name: "team3-name", Slug: "team3-slug", Org: "gamma", }, }, }, { name: "includes parent team in allowed orgs if present", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUserTeams, []github.Team{ { Name: github.Ptr("team-name-with-parent"), Slug: github.Ptr("team-slug-with-parent"), Parent: &github.Team{ Name: github.Ptr("parent-team-name"), Slug: github.Ptr("parent-team-slug"), Organization: nil, // the real GitHub API does not return Org on "Parent" team. }, Organization: &github.Organization{ Login: github.Ptr("org-with-nested-teams"), }, }, { Name: github.Ptr("team-name-with-same-parent-again"), Slug: github.Ptr("team-slug-with-same-parent-again"), Parent: &github.Team{ Name: github.Ptr("parent-team-name"), Slug: github.Ptr("parent-team-slug"), Organization: nil, // the real GitHub API does not return Org on "Parent" team. }, Organization: &github.Organization{ Login: github.Ptr("org-with-nested-teams"), }, }, { Name: github.Ptr("parent-team-name"), Slug: github.Ptr("parent-team-slug"), Organization: &github.Organization{ Login: github.Ptr("org-with-nested-teams"), }, }, { Name: github.Ptr("team-name-with-parent-from-disallowed-org"), Slug: github.Ptr("team-slug-with-parent-from-disallowed-org"), Parent: &github.Team{ Name: github.Ptr("parent-team-name-from-disallowed-org"), Slug: github.Ptr("parent-team-slug-from-disallowed-org"), Organization: nil, // the real GitHub API does not return Org on "Parent" team. }, Organization: &github.Organization{ Login: github.Ptr("disallowed-org"), }, }, { Name: github.Ptr("team-name-without-parent"), Slug: github.Ptr("team-slug-without-parent"), Organization: &github.Organization{ Login: github.Ptr("beta"), }, }, }, ), ), token: "some-token", allowedOrganizations: setutil.NewCaseInsensitiveSet("org-with-nested-teams", "beta"), wantTeams: []TeamInfo{ { Name: "team-name-without-parent", Slug: "team-slug-without-parent", Org: "beta", }, { Name: "parent-team-name", Slug: "parent-team-slug", Org: "org-with-nested-teams", }, { Name: "team-name-with-parent", Slug: "team-slug-with-parent", Org: "org-with-nested-teams", }, { Name: "team-name-with-same-parent-again", Slug: "team-slug-with-same-parent-again", Org: "org-with-nested-teams", }, }, }, { name: "happy path with pagination", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatchPages( mock.GetUserTeams, []github.Team{ { Name: github.Ptr("page1-team-name"), Slug: github.Ptr("page1-team-slug"), Organization: &github.Organization{ Login: github.Ptr("page1-org-name"), }, }, }, []github.Team{ { Name: github.Ptr("page2-team-name"), Slug: github.Ptr("page2-team-slug"), Organization: &github.Organization{ Login: github.Ptr("page2-org-name"), }, }, }, ), ), token: "some-token", allowedOrganizations: setutil.NewCaseInsensitiveSet("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: "missing organization attribute returns an error", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUserTeams, []github.Team{ { Name: github.Ptr("team-name"), Slug: github.Ptr("team-slug"), }, }, ), ), wantErr: `error fetching team membership for authenticated user: missing the "organization" attribute for a team`, }, { name: "missing organization's login attribute returns an error", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUserTeams, []github.Team{ { Name: github.Ptr("team-name"), Slug: github.Ptr("team-slug"), Organization: &github.Organization{}, }, }, ), ), wantErr: `error fetching team membership for authenticated user: missing the organization's "login" attribute for a team`, }, { name: "missing the name attribute for a team returns an error", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUserTeams, []github.Team{ { Slug: github.Ptr("team-slug"), Organization: &github.Organization{ Login: github.Ptr("some-org"), }, }, }, ), ), wantErr: `error fetching team membership for authenticated user: the "name" attribute is missing for a team`, }, { name: "missing the slug attribute for a team returns an error", httpClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetUserTeams, []github.Team{ { Name: github.Ptr("team-name"), Organization: &github.Organization{ Login: github.Ptr("some-org"), }, }, }, ), ), wantErr: `error fetching team membership for authenticated user: the "slug" attribute is missing for a team`, }, { 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.Len(t, r.Header["Authorization"], 1) 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: setutil.NewCaseInsensitiveSet("org-login"), wantTeams: []TeamInfo{ { Name: "team1-name", Slug: "team1-slug", Org: "org-login", }, }, }, { name: "passes the context parameter into the API call", token: "some-token", httpClient: mock.NewMockedHTTPClient(), ctx: func() context.Context { canceledCtx, cancel := context.WithCancel(context.Background()) cancel() return canceledCtx }(), wantErr: "error fetching team membership for authenticated user: context canceled", }, { 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=100: 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), } ctx := context.Background() if test.ctx != nil { ctx = test.ctx } actual, err := githubClient.GetTeamMembership(ctx, 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) } else { require.NoError(t, err) require.NotNil(t, actual) require.Equal(t, test.wantTeams, actual) } }) } }