Files
pinniped/internal/githubclient/githubclient_test.go
2025-05-12 15:47:36 -07:00

834 lines
23 KiB
Go

// 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/v71/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)
}
})
}
}