diff --git a/internal/federationdomain/downstreamsubject/downstream_subject.go b/internal/federationdomain/downstreamsubject/downstream_subject.go index 5c754d9aa..78d202918 100644 --- a/internal/federationdomain/downstreamsubject/downstream_subject.go +++ b/internal/federationdomain/downstreamsubject/downstream_subject.go @@ -24,3 +24,10 @@ func OIDC(upstreamIssuerAsString string, upstreamSubject string, idpDisplayName oidc.IDTokenClaimSubject, url.QueryEscape(upstreamSubject), ) } + +func GitHub(APIBaseURL, idpDisplayName, login, id string) string { + return fmt.Sprintf("%s?%s=%s&login=%s&id=%s", APIBaseURL, + oidc.IDTokenSubClaimIDPNameQueryParam, url.QueryEscape(idpDisplayName), + url.QueryEscape(login), url.QueryEscape(id), + ) +} diff --git a/internal/federationdomain/downstreamsubject/downstream_subject_test.go b/internal/federationdomain/downstreamsubject/downstream_subject_test.go index b96ec85ad..8add3c077 100644 --- a/internal/federationdomain/downstreamsubject/downstream_subject_test.go +++ b/internal/federationdomain/downstreamsubject/downstream_subject_test.go @@ -89,3 +89,41 @@ func TestOIDC(t *testing.T) { }) } } + +func TestGitHub(t *testing.T) { + tests := []struct { + name string + APIBaseURL string + idpDisplayName string + login string + id string + wantSubject string + }{ + { + name: "simple display name", + APIBaseURL: "https://github.com", + idpDisplayName: "simpleName", + login: "some login", + id: "some id", + wantSubject: "https://github.com?idpName=simpleName&login=some+login&id=some+id", + }, + { + name: "interesting display name", + APIBaseURL: "https://server.example.com:1234/path", + idpDisplayName: "this is a 👍 display name that 🦭 can handle", + login: "some other login", + id: "some other id", + wantSubject: "https://server.example.com:1234/path?idpName=this+is+a+%F0%9F%91%8D+display+name+that+%F0%9F%A6%AD+can+handle&login=some+other+login&id=some+other+id", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + actual := GitHub(test.APIBaseURL, test.idpDisplayName, test.login, test.id) + + require.Equal(t, test.wantSubject, actual) + }) + } +} diff --git a/internal/mocks/mockgithubclient/generate.go b/internal/mocks/mockgithubclient/generate.go new file mode 100644 index 000000000..8bca3dd91 --- /dev/null +++ b/internal/mocks/mockgithubclient/generate.go @@ -0,0 +1,6 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package mockgithubclient + +//go:generate go run -v go.uber.org/mock/mockgen -destination=mockgithubclient.go -package=mockgithubclient -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/githubclient GitHubInterface diff --git a/internal/mocks/mockgithubclient/mockgithubclient.go b/internal/mocks/mockgithubclient/mockgithubclient.go new file mode 100644 index 000000000..6a1901c14 --- /dev/null +++ b/internal/mocks/mockgithubclient/mockgithubclient.go @@ -0,0 +1,91 @@ +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// + +// Code generated by MockGen. DO NOT EDIT. +// Source: go.pinniped.dev/internal/githubclient (interfaces: GitHubInterface) +// +// Generated by this command: +// +// mockgen -destination=mockgithubclient.go -package=mockgithubclient -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/githubclient GitHubInterface +// + +// Package mockgithubclient is a generated GoMock package. +package mockgithubclient + +import ( + context "context" + reflect "reflect" + + githubclient "go.pinniped.dev/internal/githubclient" + gomock "go.uber.org/mock/gomock" + sets "k8s.io/apimachinery/pkg/util/sets" +) + +// MockGitHubInterface is a mock of GitHubInterface interface. +type MockGitHubInterface struct { + ctrl *gomock.Controller + recorder *MockGitHubInterfaceMockRecorder +} + +// MockGitHubInterfaceMockRecorder is the mock recorder for MockGitHubInterface. +type MockGitHubInterfaceMockRecorder struct { + mock *MockGitHubInterface +} + +// NewMockGitHubInterface creates a new mock instance. +func NewMockGitHubInterface(ctrl *gomock.Controller) *MockGitHubInterface { + mock := &MockGitHubInterface{ctrl: ctrl} + mock.recorder = &MockGitHubInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGitHubInterface) EXPECT() *MockGitHubInterfaceMockRecorder { + return m.recorder +} + +// GetOrgMembership mocks base method. +func (m *MockGitHubInterface) GetOrgMembership(arg0 context.Context) (sets.Set[string], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrgMembership", arg0) + ret0, _ := ret[0].(sets.Set[string]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOrgMembership indicates an expected call of GetOrgMembership. +func (mr *MockGitHubInterfaceMockRecorder) GetOrgMembership(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrgMembership", reflect.TypeOf((*MockGitHubInterface)(nil).GetOrgMembership), arg0) +} + +// GetTeamMembership mocks base method. +func (m *MockGitHubInterface) GetTeamMembership(arg0 context.Context, arg1 sets.Set[string]) ([]*githubclient.TeamInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTeamMembership", arg0, arg1) + ret0, _ := ret[0].([]*githubclient.TeamInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTeamMembership indicates an expected call of GetTeamMembership. +func (mr *MockGitHubInterfaceMockRecorder) GetTeamMembership(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamMembership", reflect.TypeOf((*MockGitHubInterface)(nil).GetTeamMembership), arg0, arg1) +} + +// GetUserInfo mocks base method. +func (m *MockGitHubInterface) GetUserInfo(arg0 context.Context) (*githubclient.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserInfo", arg0) + ret0, _ := ret[0].(*githubclient.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserInfo indicates an expected call of GetUserInfo. +func (mr *MockGitHubInterfaceMockRecorder) GetUserInfo(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInfo", reflect.TypeOf((*MockGitHubInterface)(nil).GetUserInfo), arg0) +} diff --git a/internal/upstreamgithub/upstreamgithub.go b/internal/upstreamgithub/upstreamgithub.go index 67f8b683d..6571c2fa7 100644 --- a/internal/upstreamgithub/upstreamgithub.go +++ b/internal/upstreamgithub/upstreamgithub.go @@ -6,14 +6,19 @@ package upstreamgithub import ( "context" + "errors" + "fmt" "net/http" coreosoidc "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" - "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + supervisoridpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + "go.pinniped.dev/internal/federationdomain/downstreamsubject" "go.pinniped.dev/internal/federationdomain/upstreamprovider" + "go.pinniped.dev/internal/githubclient" ) // ProviderConfig holds the active configuration of an upstream GitHub provider. @@ -26,8 +31,8 @@ type ProviderConfig struct { // or https://HOSTNAME/api/v3/ for Enterprise Server. APIBaseURL string - UsernameAttribute v1alpha1.GitHubUsernameAttribute - GroupNameAttribute v1alpha1.GitHubGroupNameAttribute + UsernameAttribute supervisoridpv1alpha1.GitHubUsernameAttribute + GroupNameAttribute supervisoridpv1alpha1.GitHubGroupNameAttribute // AllowedOrganizations, when empty, means to allow users from all orgs. AllowedOrganizations []string @@ -46,7 +51,8 @@ type ProviderConfig struct { } type Provider struct { - c ProviderConfig + c ProviderConfig + buildGitHubClient func(httpClient *http.Client, apiBaseURL, token string) (githubclient.GitHubInterface, error) } var _ upstreamprovider.UpstreamGithubIdentityProviderI = &Provider{} @@ -54,7 +60,10 @@ var _ upstreamprovider.UpstreamGithubIdentityProviderI = &Provider{} // New creates a Provider. The config is not a pointer to ensure that a copy of the config is created, // making the resulting Provider use an effectively read-only configuration. func New(config ProviderConfig) *Provider { - return &Provider{c: config} + return &Provider{ + c: config, + buildGitHubClient: githubclient.NewGitHubClient, + } } func (p *Provider) GetName() string { @@ -73,11 +82,11 @@ func (p *Provider) GetScopes() []string { return p.c.OAuth2Config.Scopes } -func (p *Provider) GetUsernameAttribute() v1alpha1.GitHubUsernameAttribute { +func (p *Provider) GetUsernameAttribute() supervisoridpv1alpha1.GitHubUsernameAttribute { return p.c.UsernameAttribute } -func (p *Provider) GetGroupNameAttribute() v1alpha1.GitHubGroupNameAttribute { +func (p *Provider) GetGroupNameAttribute() supervisoridpv1alpha1.GitHubGroupNameAttribute { return p.c.GroupNameAttribute } @@ -104,19 +113,73 @@ func (p *Provider) ExchangeAuthcode(ctx context.Context, authcode string, redire return tok.AccessToken, nil } -func (p *Provider) GetUser(_ctx context.Context, _accessToken string) (*upstreamprovider.GitHubUser, error) { - // TODO Implement this to make several https calls to github to learn about the user, using a lower-level githubclient package. - // Pass the ctx, accessToken, p.c.HttpClient, and p.c.APIBaseURL to the lower-level package's functions. - // TODO: Reject the auth if the user does not belong to any of p.c.AllowedOrganizations (unless p.c.AllowedOrganizations is empty). - // TODO: Make use of p.c.UsernameAttribute and p.c.GroupNameAttribute when deciding the username and group names. - // TODO: Determine the downstream subject by first writing a helper in downstream_subject.go and then calling it here. - panic("implement me") - //nolint:govet // this code is intentionally unreachable until we resolve the todos - return &upstreamprovider.GitHubUser{ - Username: "TODO", - Groups: []string{"org/TODO"}, - DownstreamSubject: "TODO", - }, nil +// GetUser will use the provided configuration to make HTTPS calls to the GitHub API to find out who the logged-in user is, +// what organizations they belong to, and what teams they belong to. +// If the user's information meets the AllowedOrganization criteria specified on the GitHubIdentityProvider, they will be +// allowed to log in. +// Note that errors from the githubclient package already have helpful error prefixes, so there is no need for additional prefixes here. +// TODO: populate the IDP display name +// TODO: What should we do if the group or team name is outside of the enum? The controller would reject this. +// TODO: should we use the APIBaseURL or some other URL in the downstreamSubject +// +// Examples: +// +// "github.com" or "https://github.com" or "https://api.github.com"? +// "enterprise.tld" or "https://enterprise.tld" or "https://enterprise.tld/api/v3"? +func (p *Provider) GetUser(ctx context.Context, accessToken string) (*upstreamprovider.GitHubUser, error) { + githubClient, err := p.buildGitHubClient(p.c.HttpClient, p.c.APIBaseURL, accessToken) + if err != nil { + return nil, err + } + + githubUser := upstreamprovider.GitHubUser{} + + userInfo, err := githubClient.GetUserInfo(ctx) + if err != nil { + return nil, err + } + + githubUser.DownstreamSubject = downstreamsubject.GitHub(p.c.APIBaseURL, "TODO_IDP_DISPLAY_NAME", userInfo.Login, userInfo.ID) + + switch p.c.UsernameAttribute { + case supervisoridpv1alpha1.GitHubUsernameLoginAndID: + githubUser.Username = fmt.Sprintf("%s:%s", userInfo.Login, userInfo.ID) + case supervisoridpv1alpha1.GitHubUsernameLogin: + githubUser.Username = userInfo.Login + case supervisoridpv1alpha1.GitHubUsernameID: + githubUser.Username = userInfo.ID + } + + orgMembership, err := githubClient.GetOrgMembership(ctx) + if err != nil { + return nil, err + } + + allowedOrgs := sets.New[string](p.c.AllowedOrganizations...) + + if allowedOrgs.Len() > 0 && allowedOrgs.Intersection(orgMembership).Len() < 1 { + return nil, errors.New("user is not allowed to log in due to organization membership policy") + } + + teamMembership, err := githubClient.GetTeamMembership(ctx, allowedOrgs) + if err != nil { + return nil, err + } + + for _, team := range teamMembership { + downstreamGroup := "" + + switch p.c.GroupNameAttribute { + case supervisoridpv1alpha1.GitHubUseTeamNameForGroupName: + downstreamGroup = fmt.Sprintf("%s/%s", team.Org, team.Name) + case supervisoridpv1alpha1.GitHubUseTeamSlugForGroupName: + downstreamGroup = fmt.Sprintf("%s/%s", team.Org, team.Slug) + } + + githubUser.Groups = append(githubUser.Groups, downstreamGroup) + } + + return &githubUser, nil } // GetConfig returns the config. This is not part of the UpstreamGithubIdentityProviderI interface and is just for testing. diff --git a/internal/upstreamgithub/upstreamgithub_test.go b/internal/upstreamgithub/upstreamgithub_test.go index 9726717a1..428872af6 100644 --- a/internal/upstreamgithub/upstreamgithub_test.go +++ b/internal/upstreamgithub/upstreamgithub_test.go @@ -4,14 +4,22 @@ package upstreamgithub import ( + "context" + "errors" "net/http" "testing" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.org/x/oauth2" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/apimachinery/pkg/util/sets" - "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + supervisoridpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" + "go.pinniped.dev/internal/githubclient" + "go.pinniped.dev/internal/mocks/mockgithubclient" ) func TestGitHubProvider(t *testing.T) { @@ -65,11 +73,282 @@ func TestGitHubProvider(t *testing.T) { require.Equal(t, types.UID("resource-uid-12345"), subject.GetResourceUID()) require.Equal(t, "fake-client-id", subject.GetClientID()) require.Equal(t, "fake-client-id", subject.GetClientID()) - require.Equal(t, v1alpha1.GitHubUsernameAttribute("fake-username-attribute"), subject.GetUsernameAttribute()) - require.Equal(t, v1alpha1.GitHubGroupNameAttribute("fake-group-name-attribute"), subject.GetGroupNameAttribute()) + require.Equal(t, supervisoridpv1alpha1.GitHubUsernameAttribute("fake-username-attribute"), subject.GetUsernameAttribute()) + require.Equal(t, supervisoridpv1alpha1.GitHubGroupNameAttribute("fake-group-name-attribute"), subject.GetGroupNameAttribute()) require.Equal(t, []string{"fake-org", "fake-org2"}, subject.GetAllowedOrganizations()) require.Equal(t, "https://fake-authorization-url", subject.GetAuthorizationURL()) require.Equal(t, &http.Client{ Timeout: 1234509, }, subject.GetConfig().HttpClient) } + +func TestGetUser(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + someContext := context.Background() + + someHttpClient := &http.Client{ + Timeout: 1234509, + } + + tests := []struct { + name string + providerConfig ProviderConfig + buildGitHubClientError error + buildMockResponses func(hubInterface *mockgithubclient.MockGitHubInterface) + wantUser *upstreamprovider.GitHubUser + wantErr string + }{ + { + name: "happy path with username=login:id", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]()).Return(nil, nil) + }, + wantUser: &upstreamprovider.GitHubUser{ + Username: "some-github-login:some-github-id", + DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + }, + }, + { + name: "happy path with username=login", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLogin, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]()).Return(nil, nil) + }, + wantUser: &upstreamprovider.GitHubUser{ + Username: "some-github-login", + DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + }, + }, + { + name: "happy path with username=id", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameID, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]()).Return(nil, nil) + }, + wantUser: &upstreamprovider.GitHubUser{ + Username: "some-github-id", + DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + }, + }, + { + name: "happy path with user in allowed organizations", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, + AllowedOrganizations: []string{"allowed-org1", "allowed-org2"}, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("allowed-org2"), nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]("allowed-org1", "allowed-org2")).Return(nil, nil) + }, + wantUser: &upstreamprovider.GitHubUser{ + Username: "some-github-login:some-github-id", + DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + }, + }, + { + name: "returns error when the user does not belong to the allowed organizations", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameID, + AllowedOrganizations: []string{"allowed-org"}, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("disallowed-org"), nil) + }, + wantErr: "user is not allowed to log in due to organization membership policy", + }, + { + name: "happy path with groups=name", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, + AllowedOrganizations: []string{"allowed-org1", "allowed-org2"}, + GroupNameAttribute: supervisoridpv1alpha1.GitHubUseTeamNameForGroupName, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("allowed-org2"), nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]("allowed-org1", "allowed-org2")).Return([]*githubclient.TeamInfo{ + { + Name: "org1-team1-name", + Slug: "org1-team1-slug", + Org: "org1-name", + }, + { + Name: "org1-team2-name", + Slug: "org1-team2-slug", + Org: "org1-name", + }, + { + Name: "org2-team1-name", + Slug: "org2-team1-slug", + Org: "org2-name", + }, + }, nil) + }, + wantUser: &upstreamprovider.GitHubUser{ + Username: "some-github-login:some-github-id", + Groups: []string{"org1-name/org1-team1-name", "org1-name/org1-team2-name", "org2-name/org2-team1-name"}, + DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + }, + }, + { + name: "happy path with groups=slug", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + UsernameAttribute: supervisoridpv1alpha1.GitHubUsernameLoginAndID, + AllowedOrganizations: []string{"allowed-org1", "allowed-org2"}, + GroupNameAttribute: supervisoridpv1alpha1.GitHubUseTeamSlugForGroupName, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{ + Login: "some-github-login", + ID: "some-github-id", + }, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(sets.New[string]("allowed-org2"), nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, sets.New[string]("allowed-org1", "allowed-org2")).Return([]*githubclient.TeamInfo{ + { + Name: "org1-team1-name", + Slug: "org1-team1-slug", + Org: "org1-name", + }, + { + Name: "org1-team2-name", + Slug: "org1-team2-slug", + Org: "org1-name", + }, + { + Name: "org2-team1-name", + Slug: "org2-team1-slug", + Org: "org2-name", + }, + }, nil) + }, + wantUser: &upstreamprovider.GitHubUser{ + Username: "some-github-login:some-github-id", + Groups: []string{"org1-name/org1-team1-slug", "org1-name/org1-team2-slug", "org2-name/org2-team1-slug"}, + DownstreamSubject: "https://some-url?idpName=TODO_IDP_DISPLAY_NAME&login=some-github-login&id=some-github-id", + }, + }, + { + name: "returns errors from buildGitHubClient()", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + }, + buildGitHubClientError: errors.New("error from building a github client"), + wantErr: "error from building a github client", + }, + { + name: "returns errors from githubClient.GetUserInfo()", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(nil, errors.New("error from githubClient.GetUserInfo")) + }, + wantErr: "error from githubClient.GetUserInfo", + }, + { + name: "returns errors from githubClient.GetOrgMembership()", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{}, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, errors.New("error from githubClient.GetOrgMembership")) + }, + wantErr: "error from githubClient.GetOrgMembership", + }, + { + name: "returns errors from githubClient.GetTeamMembership()", + providerConfig: ProviderConfig{ + APIBaseURL: "https://some-url", + HttpClient: someHttpClient, + }, + buildMockResponses: func(mockGitHubInterface *mockgithubclient.MockGitHubInterface) { + mockGitHubInterface.EXPECT().GetUserInfo(someContext).Return(&githubclient.UserInfo{}, nil) + mockGitHubInterface.EXPECT().GetOrgMembership(someContext).Return(nil, nil) + mockGitHubInterface.EXPECT().GetTeamMembership(someContext, gomock.Any()).Return(nil, errors.New("error from githubClient.GetTeamMembership")) + }, + wantErr: "error from githubClient.GetTeamMembership", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + accessToken := "some-opaque-github-access-token" + rand.String(8) + mockGitHubInterface := mockgithubclient.NewMockGitHubInterface(ctrl) + if test.buildMockResponses != nil { + test.buildMockResponses(mockGitHubInterface) + } + + p := New(test.providerConfig) + p.buildGitHubClient = func(httpClient *http.Client, apiBaseURL, token string) (githubclient.GitHubInterface, error) { + require.Equal(t, test.providerConfig.HttpClient, httpClient) + require.Equal(t, test.providerConfig.APIBaseURL, apiBaseURL) + require.Equal(t, accessToken, token) + + return mockGitHubInterface, test.buildGitHubClientError + } + + actualUser, actualErr := p.GetUser(context.Background(), accessToken) + if test.wantErr != "" { + require.EqualError(t, actualErr, test.wantErr) + require.Nil(t, actualUser) + return + } + require.NoError(t, actualErr) + require.Equal(t, test.wantUser, actualUser) + }) + } +}