From 039600c736462af96a3ced0dbfae55da037dabda Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Mon, 9 Mar 2026 14:37:28 -0500 Subject: [PATCH] sync outside collaborators as part of user List --- pkg/connector/helpers.go | 24 +++ pkg/connector/org.go | 2 +- pkg/connector/org_role.go | 2 +- pkg/connector/org_role_test.go | 2 +- pkg/connector/org_test.go | 2 +- pkg/connector/repository.go | 2 +- pkg/connector/repository_test.go | 2 +- pkg/connector/team.go | 2 +- pkg/connector/team_test.go | 2 +- pkg/connector/user.go | 290 ++++++++++++++++++++++--------- pkg/connector/user_test.go | 63 ++++++- test/mocks/endpointpattern.go | 5 + test/mocks/github.go | 67 +++++-- 13 files changed, 353 insertions(+), 112 deletions(-) diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 76ce3ad0..fad502a3 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -114,6 +114,30 @@ func parsePageToken(i string, resourceID *v2.ResourceId) (*pagination.Bag, int, return b, page, nil } +// isOutsideCollaboratorPhase reports whether the pagination bag is in the +// outside-collaborator phase (phase 2 of the user List). +func isOutsideCollaboratorPhase(bag *pagination.Bag) bool { + return bag.Current().ResourceTypeID == outsideCollaboratorPhase +} + +// membersNextPageToken advances the pagination bag for the org-members phase. +// When there are no more member pages (nextPage == ""), it transitions the bag +// to the outside-collaborator phase so the next List call fetches phase 2. +// Precondition: parentID must not be nil (callers must guard this before calling). +func membersNextPageToken(bag *pagination.Bag, nextPage string, parentID *v2.ResourceId) (string, error) { + if nextPage == "" { + // bag.Current() is always the members-phase state here; Pop removes it + // before pushing the outside-collaborator phase. + bag.Pop() + bag.Push(pagination.PageState{ + ResourceTypeID: outsideCollaboratorPhase, + ResourceID: parentID.Resource, + }) + return bag.Marshal() + } + return bag.NextToken(nextPage) +} + // convertPageToken converts a string token into an int. func convertPageToken(token string) (int, error) { if token == "" { diff --git a/pkg/connector/org.go b/pkg/connector/org.go index 59f1fb7b..9e1584f1 100644 --- a/pkg/connector/org.go +++ b/pkg/connector/org.go @@ -241,7 +241,7 @@ func (o *orgResourceType) Grants( } for _, user := range users { - ur, err := userResource(ctx, user, user.GetEmail(), nil) + ur, err := userResource(ctx, user, user.GetEmail(), nil, false) if err != nil { return nil, nil, err } diff --git a/pkg/connector/org_role.go b/pkg/connector/org_role.go index 32faf6c6..ed21633e 100644 --- a/pkg/connector/org_role.go +++ b/pkg/connector/org_role.go @@ -190,7 +190,7 @@ func (o *orgRoleResourceType) Grants( // Create regular grants for direct user assignments. for _, user := range users { - userResource, err := userResource(ctx, user, user.GetEmail(), nil) + userResource, err := userResource(ctx, user, user.GetEmail(), nil, false) if err != nil { return nil, nil, err } diff --git a/pkg/connector/org_role_test.go b/pkg/connector/org_role_test.go index e5fe6fb9..8110e49d 100644 --- a/pkg/connector/org_role_test.go +++ b/pkg/connector/org_role_test.go @@ -33,7 +33,7 @@ func TestOrgRole(t *testing.T) { Name: orgRole.Name, Description: orgRole.Description, }, organization) - user, _ := userResource(ctx, githubUser, *githubUser.Email, nil) + user, _ := userResource(ctx, githubUser, *githubUser.Email, nil, false) entitlement := v2.Entitlement{ Id: entitlement2.NewEntitlementID(roleResource, "assigned"), diff --git a/pkg/connector/org_test.go b/pkg/connector/org_test.go index 590f13a0..27c2b686 100644 --- a/pkg/connector/org_test.go +++ b/pkg/connector/org_test.go @@ -27,7 +27,7 @@ func TestOrganization(t *testing.T) { client := orgBuilder(githubClient, nil, cache, nil, false) organization, _ := organizationResource(ctx, githubOrganization, nil, false) - user, _ := userResource(ctx, githubUser, *githubUser.Email, nil) + user, _ := userResource(ctx, githubUser, *githubUser.Email, nil, false) entitlement := v2.Entitlement{ Id: entitlement.NewEntitlementID(organization, orgRoleMember), diff --git a/pkg/connector/repository.go b/pkg/connector/repository.go index 28546fe9..4ccfe31a 100644 --- a/pkg/connector/repository.go +++ b/pkg/connector/repository.go @@ -208,7 +208,7 @@ func (o *repositoryResourceType) Grants( continue } - ur, err := userResource(ctx, user, user.GetEmail(), nil) + ur, err := userResource(ctx, user, user.GetEmail(), nil, false) if err != nil { return nil, nil, err } diff --git a/pkg/connector/repository_test.go b/pkg/connector/repository_test.go index e1d213e0..671b1cb4 100644 --- a/pkg/connector/repository_test.go +++ b/pkg/connector/repository_test.go @@ -29,7 +29,7 @@ func TestRepository(t *testing.T) { organization, _ := organizationResource(ctx, githubOrganization, nil, false) repository, _ := repositoryResource(ctx, githubRepository, organization.Id) - user, _ := userResource(ctx, githubUser, *githubUser.Email, nil) + user, _ := userResource(ctx, githubUser, *githubUser.Email, nil, false) entitlement := v2.Entitlement{ Id: entitlement2.NewEntitlementID(repository, "admin"), diff --git a/pkg/connector/team.go b/pkg/connector/team.go index e6eff4c7..f36b804f 100644 --- a/pkg/connector/team.go +++ b/pkg/connector/team.go @@ -220,7 +220,7 @@ func (o *teamResourceType) Grants(ctx context.Context, resource *v2.Resource, op } for _, user := range users { - ur, err := userResource(ctx, user, user.GetEmail(), nil) + ur, err := userResource(ctx, user, user.GetEmail(), nil, false) if err != nil { return nil, nil, err } diff --git a/pkg/connector/team_test.go b/pkg/connector/team_test.go index 5700f14c..75f34a70 100644 --- a/pkg/connector/team_test.go +++ b/pkg/connector/team_test.go @@ -29,7 +29,7 @@ func TestTeam(t *testing.T) { organization, _ := organizationResource(ctx, githubOrganization, nil, false) team, _ := teamResource(githubTeam, organization.Id) - user, _ := userResource(ctx, githubUser, *githubUser.Email, nil) + user, _ := userResource(ctx, githubUser, *githubUser.Email, nil, false) entitlement := v2.Entitlement{ Id: entitlement2.NewEntitlementID(team, "member"), diff --git a/pkg/connector/user.go b/pkg/connector/user.go index 7a22e1ab..98f517e9 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -9,6 +9,7 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/google/go-github/v69/github" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" @@ -17,8 +18,10 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) +const outsideCollaboratorPhase = "outside_collaborator" + // Create a new connector resource for a GitHub user. -func userResource(ctx context.Context, user *github.User, userEmail string, extraEmails []string) (*v2.Resource, error) { +func userResource(ctx context.Context, user *github.User, userEmail string, extraEmails []string, isOutsideCollaborator bool) (*v2.Resource, error) { displayName := user.GetName() if displayName == "" { // users do not always specify a name and we only get public email from @@ -37,10 +40,11 @@ func userResource(ctx context.Context, user *github.User, userEmail string, extr } profile := map[string]interface{}{ - "first_name": firstName, - "last_name": lastName, - "login": user.GetLogin(), - "user_id": strconv.Itoa(int(user.GetID())), + "first_name": firstName, + "last_name": lastName, + "login": user.GetLogin(), + "user_id": strconv.Itoa(int(user.GetID())), + "is_outside_collaborator": isOutsideCollaborator, } userTrait := []resource.UserTraitOption{ @@ -97,42 +101,121 @@ func (o *userResourceType) ResourceType(_ context.Context) *v2.ResourceType { return o.resourceType } -func (o *userResourceType) List(ctx context.Context, parentID *v2.ResourceId, opts resource.SyncOpAttrs) ([]*v2.Resource, *resource.SyncOpResults, error) { +// fetchUserDetails fetches full user details by ID, falling back to the summary +// user on 404 (this undocumented API can return 404 for some users). +func (o *userResourceType) fetchUserDetails(ctx context.Context, user *github.User) (*github.User, error) { l := ctxzap.Extract(ctx) - var annotations annotations.Annotations - if parentID == nil { - return nil, &resource.SyncOpResults{}, nil + u, res, err := o.client.Users.GetByID(ctx, user.GetID()) + if err != nil { + if isNotFoundError(res) { + l.Warn("error fetching user by id", zap.Error(err), zap.Int64("user_id", user.GetID())) + return user, nil + } + return nil, wrapGitHubError(err, res, "github-connector: failed to get user by id") } + return u, nil +} - bag, page, err := parsePageToken(opts.PageToken.Token, &v2.ResourceId{ResourceType: resourceTypeUser.Id}) +// enrichUserWithSAML queries the GraphQL API for the user's SAML identity. +// Returns the primary email, any extra emails, and the GraphQL rate limit description. +// If the org uses Enterprise-level SAML instead of org-level, it disables future SAML +// queries and returns empty strings with no error. +func (o *userResourceType) enrichUserWithSAML(ctx context.Context, orgName string, userLogin string) (string, []string, *v2.RateLimitDescription, error) { + l := ctxzap.Extract(ctx) + q := listUsersQuery{} + variables := map[string]interface{}{ + "orgLoginName": githubv4.String(orgName), + "userName": githubv4.String(userLogin), + } + + err := o.graphqlClient.Query(ctx, &q, variables) if err != nil { - return nil, nil, err + if strings.Contains(err.Error(), "SAML identity provider is disabled when an Enterprise SAML identity provider is available") { + l.Debug("org SAML disabled in favor of Enterprise SAML, skipping SAML identity enrichment", + zap.String("org", orgName), + zap.String("user", userLogin)) + samlDisabled := false + o.hasSAMLEnabled = &samlDisabled + return "", nil, nil, nil + } + return "", nil, nil, err } - orgName, err := o.orgCache.GetOrgName(ctx, opts.Session, parentID) + rlDesc := &v2.RateLimitDescription{ + Limit: int64(q.RateLimit.Limit), + Remaining: int64(q.RateLimit.Remaining), + ResetAt: timestamppb.New(q.RateLimit.ResetAt.Time), + } + + if len(q.Organization.SamlIdentityProvider.ExternalIdentities.Edges) != 1 { + return "", nil, rlDesc, nil + } + + samlIdent := q.Organization.SamlIdentityProvider.ExternalIdentities.Edges[0].Node.SamlIdentity + primaryEmail := samlIdent.NameId + setPrimary := primaryEmail != "" + + var extraEmails []string + for _, email := range samlIdent.Emails { + if !isEmail(email.Value) { + continue + } + if !setPrimary { + primaryEmail = email.Value + setPrimary = true + } else { + extraEmails = append(extraEmails, email.Value) + } + } + + return primaryEmail, extraEmails, rlDesc, nil +} + +// buildUserResource fetches full user details, optionally enriches with SAML identity, +// and returns a connector resource along with the GraphQL rate limit (if SAML was queried). +func (o *userResourceType) buildUserResource(ctx context.Context, orgName string, hasSaml bool, user *github.User) (*v2.Resource, *v2.RateLimitDescription, error) { + u, err := o.fetchUserDetails(ctx, user) if err != nil { return nil, nil, err } - hasSamlBool, err := o.hasSAML(ctx, orgName) + userEmail := u.GetEmail() + var extraEmails []string + var graphqlRateLimit *v2.RateLimitDescription + + if hasSaml { + var samlEmail string + samlEmail, extraEmails, graphqlRateLimit, err = o.enrichUserWithSAML(ctx, orgName, u.GetLogin()) + if err != nil { + return nil, nil, err + } + if samlEmail != "" { + userEmail = samlEmail + } + } + + ur, err := userResource(ctx, u, userEmail, extraEmails, false) if err != nil { return nil, nil, err } - var restApiRateLimit *v2.RateLimitDescription + return ur, graphqlRateLimit, nil +} - listOpts := github.ListMembersOptions{ +// listOutsideCollaboratorsPage fetches one page of outside collaborators and returns +// them as user resources with proper SDK-driven pagination. +func (o *userResourceType) listOutsideCollaboratorsPage(ctx context.Context, orgName string, page int, bag *pagination.Bag) ([]*v2.Resource, *resource.SyncOpResults, error) { + opts := &github.ListOutsideCollaboratorsOptions{ ListOptions: github.ListOptions{ Page: page, PerPage: maxPageSize, }, } - - users, resp, err := o.client.Organizations.ListMembers(ctx, orgName, &listOpts) + users, resp, err := o.client.Organizations.ListOutsideCollaborators(ctx, orgName, opts) if err != nil { - return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to list organization members") + return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to list outside collaborators") } - restApiRateLimit, err = extractRateLimitData(resp) + rlDesc, err := extractRateLimitData(resp) if err != nil { return nil, nil, err } @@ -147,88 +230,125 @@ func (o *userResourceType) List(ctx context.Context, parentID *v2.ResourceId, op return nil, nil, err } - q := listUsersQuery{} rv := make([]*v2.Resource, 0, len(users)) for _, user := range users { - u, res, err := o.client.Users.GetByID(ctx, user.GetID()) + u, err := o.fetchUserDetails(ctx, user) if err != nil { - // This undocumented API can return 404 for some users. If this fails it means we won't get some of their details like email - if isNotFoundError(res) { - l.Warn("error fetching user by id", zap.Error(err), zap.Int64("user_id", user.GetID())) - u = user - } else { - return nil, nil, wrapGitHubError(err, res, "github-connector: failed to get user by id") - } - } - userEmail := u.GetEmail() - var extraEmails []string - if hasSamlBool { - variables := map[string]interface{}{ - "orgLoginName": githubv4.String(orgName), - "userName": githubv4.String(u.GetLogin()), - } - err = o.graphqlClient.Query(ctx, &q, variables) - if err != nil { - // When SAML is configured at the Enterprise level (not org level), - // GitHub returns this error. Fall back to using the regular user email - // and disable further SAML queries for this connector instance. - if strings.Contains(err.Error(), "SAML identity provider is disabled when an Enterprise SAML identity provider is available") { - l.Debug("org SAML disabled in favor of Enterprise SAML, skipping SAML identity enrichment", - zap.String("org", orgName), - zap.String("user", u.GetLogin())) - samlDisabled := false - o.hasSAMLEnabled = &samlDisabled - hasSamlBool = false - } else { - return nil, nil, err - } - } - if err == nil && len(q.Organization.SamlIdentityProvider.ExternalIdentities.Edges) == 1 { - samlIdent := q.Organization.SamlIdentityProvider.ExternalIdentities.Edges[0].Node.SamlIdentity - userEmail = samlIdent.NameId - setUserEmail := false - - if userEmail != "" { - setUserEmail = true - } - for _, email := range samlIdent.Emails { - ok := isEmail(email.Value) - if !ok { - continue - } - - if !setUserEmail { - userEmail = email.Value - setUserEmail = true - } else { - extraEmails = append(extraEmails, email.Value) - } - } - } + return nil, nil, err } - ur, err := userResource(ctx, u, userEmail, extraEmails) + // Outside collaborators are external to the org, so org-level SAML + // identities do not apply to them. Use the REST API email as-is. + isOutsideCollaborator := true + ur, err := userResource(ctx, u, u.GetEmail(), nil, isOutsideCollaborator) if err != nil { return nil, nil, err } - rv = append(rv, ur) } - annotations.WithRateLimiting(restApiRateLimit) - if *o.hasSAMLEnabled && int64(q.RateLimit.Remaining) < restApiRateLimit.Remaining { - graphqlRateLimit := &v2.RateLimitDescription{ - Limit: int64(q.RateLimit.Limit), - Remaining: int64(q.RateLimit.Remaining), - ResetAt: timestamppb.New(q.RateLimit.ResetAt.Time), - } - annotations.WithRateLimiting(graphqlRateLimit) + + var annos annotations.Annotations + annos.WithRateLimiting(rlDesc) + + return rv, &resource.SyncOpResults{ + NextPageToken: pageToken, + Annotations: annos, + }, nil +} + +func (o *userResourceType) List(ctx context.Context, parentID *v2.ResourceId, opts resource.SyncOpAttrs) ([]*v2.Resource, *resource.SyncOpResults, error) { + if parentID == nil { + return nil, &resource.SyncOpResults{}, nil } + bag, page, err := parsePageToken(opts.PageToken.Token, &v2.ResourceId{ResourceType: resourceTypeUser.Id}) + if err != nil { + return nil, nil, err + } + + orgName, err := o.orgCache.GetOrgName(ctx, opts.Session, parentID) + if err != nil { + return nil, nil, err + } + + // List runs in two phases: + // Phase 1 – org members (ResourceTypeID == resourceTypeUser.Id) + // Phase 2 – outside collaborators (ResourceTypeID == outsideCollaboratorPhase) + if isOutsideCollaboratorPhase(bag) { + return o.listOutsideCollaboratorsPage(ctx, orgName, page, bag) + } + + users, resp, err := o.client.Organizations.ListMembers(ctx, orgName, &github.ListMembersOptions{ + ListOptions: github.ListOptions{ + Page: page, + PerPage: maxPageSize, + }, + }) + if err != nil { + return nil, nil, wrapGitHubError(err, resp, "github-connector: failed to list organization members") + } + + restApiRateLimit, err := extractRateLimitData(resp) + if err != nil { + return nil, nil, err + } + + nextPage, _, err := parseResp(resp) + if err != nil { + return nil, nil, err + } + + hasSamlBool, err := o.hasSAML(ctx, orgName) + if err != nil { + return nil, nil, err + } + + rv, graphqlRateLimit, err := o.buildUserResources(ctx, orgName, hasSamlBool, users) + if err != nil { + return nil, nil, err + } + + pageToken, err := membersNextPageToken(bag, nextPage, parentID) + if err != nil { + return nil, nil, err + } + + var annos annotations.Annotations + annos.WithRateLimiting(selectRateLimit(restApiRateLimit, graphqlRateLimit)) + return rv, &resource.SyncOpResults{ NextPageToken: pageToken, - Annotations: annotations, + Annotations: annos, }, nil } +func (o *userResourceType) buildUserResources( + ctx context.Context, orgName string, hasSAML bool, users []*github.User, +) ([]*v2.Resource, *v2.RateLimitDescription, error) { + rv := make([]*v2.Resource, 0, len(users)) + var graphqlRateLimit *v2.RateLimitDescription + for _, user := range users { + ur, rl, err := o.buildUserResource(ctx, orgName, hasSAML, user) + if err != nil { + return nil, nil, err + } + rv = append(rv, ur) + if rl != nil { + graphqlRateLimit = rl + } + } + return rv, graphqlRateLimit, nil +} + +func selectRateLimit(rest, graphql *v2.RateLimitDescription) *v2.RateLimitDescription { + if rest == nil { + return graphql + } + if graphql != nil && graphql.Remaining < rest.Remaining { + return graphql + } + return rest +} + func isEmail(email string) bool { _, err := mail.ParseAddress(email) return err == nil diff --git a/pkg/connector/user_test.go b/pkg/connector/user_test.go index bf97eb0d..e5c9d674 100644 --- a/pkg/connector/user_test.go +++ b/pkg/connector/user_test.go @@ -53,6 +53,7 @@ func TestUsersList(t *testing.T) { []string{organization.DisplayName}, ) + // First call: fetches org members, transitions to outside collaborators phase. users, results, err := client.List( ctx, organization.Id, @@ -63,9 +64,69 @@ func TestUsersList(t *testing.T) { ) require.Nil(t, err) test.AssertHasRatelimitAnnotations(t, results.Annotations) - require.Equal(t, "", results.NextPageToken) + require.NotEmpty(t, results.NextPageToken) require.Len(t, users, 1) require.Equal(t, *githubUser.Login, users[0].Id.Resource) + + // Second call: fetches outside collaborators (none seeded), completes pagination. + outsideCollabUsers, results, err := client.List( + ctx, + organization.Id, + resourceSdk.SyncOpAttrs{ + PageToken: pagination.Token{Token: results.NextPageToken}, + Session: &noOpSessionStore{}, + }, + ) + require.Nil(t, err) + require.Equal(t, "", results.NextPageToken) + require.Len(t, outsideCollabUsers, 0) }) } } + +func TestUsersListWithOutsideCollaborators(t *testing.T) { + ctx := context.Background() + falseBool := false + + mgh := mocks.NewMockGitHub() + githubOrganization, _, _, githubUser, _, _ := mgh.Seed() + + outsideCollabID := int64(99) + outsideCollabLogin := "outside-collab-99" + outsideCollabEmail := "99@example.com" + outsideCollabUser := github.User{ + ID: &outsideCollabID, + Login: &outsideCollabLogin, + Email: &outsideCollabEmail, + } + mgh.AddUser(outsideCollabUser) + mgh.AddOutsideCollaborator(*githubOrganization.ID, outsideCollabID) + + organization, err := organizationResource(ctx, githubOrganization, nil, false) + require.Nil(t, err) + + githubClient := github.NewClient(mgh.Server()) + graphQLClient := mocks.MockGraphQL() + cache := newOrgNameCache(githubClient) + client := userBuilder(githubClient, &falseBool, graphQLClient, cache, []string{organization.DisplayName}) + + // Phase 1: org members. + members, results, err := client.List(ctx, organization.Id, resourceSdk.SyncOpAttrs{ + PageToken: pagination.Token{}, + Session: &noOpSessionStore{}, + }) + require.Nil(t, err) + require.NotEmpty(t, results.NextPageToken) + require.Len(t, members, 1) + require.Equal(t, *githubUser.Login, members[0].Id.Resource) + + // Phase 2: outside collaborators. + collabs, results, err := client.List(ctx, organization.Id, resourceSdk.SyncOpAttrs{ + PageToken: pagination.Token{Token: results.NextPageToken}, + Session: &noOpSessionStore{}, + }) + require.Nil(t, err) + require.Equal(t, "", results.NextPageToken) + require.Len(t, collabs, 1) + require.Equal(t, fmt.Sprintf("%d", outsideCollabID), collabs[0].Id.Resource) +} diff --git a/test/mocks/endpointpattern.go b/test/mocks/endpointpattern.go index c57cefff..173cb685 100644 --- a/test/mocks/endpointpattern.go +++ b/test/mocks/endpointpattern.go @@ -67,3 +67,8 @@ var DeleteOrgsRolesUsersByOrgByRoleIdByUsername = mock.EndpointPattern{ Pattern: "/orgs/{org}/organization-roles/users/{username}/{role_id}", Method: "DELETE", } + +var GetOrgsOutsideCollaboratorsByOrg = mock.EndpointPattern{ + Pattern: "/orgs/{org}/outside_collaborators", + Method: "GET", +} diff --git a/test/mocks/github.go b/test/mocks/github.go index 1e50ef5e..65de954c 100644 --- a/test/mocks/github.go +++ b/test/mocks/github.go @@ -1,4 +1,3 @@ -//nolint:gosec // We don't care about XSS in mock data for tests. package mocks import ( @@ -16,27 +15,29 @@ import ( ) type MockGitHub struct { - teamMemberships map[int64]mapset.Set[int64] - repositoryMemberships map[int64]mapset.Set[int64] - organizationMemberships map[int64]mapset.Set[int64] - organizations map[int64]github.Organization - repositories map[int64]github.Repository - teams map[int64]github.Team - users map[int64]github.User - orgRoles map[int64]mapset.Set[int64] // Maps role ID to set of user IDs - SimulateOrgRolePermErr bool // Simulate permission error for org roles + teamMemberships map[int64]mapset.Set[int64] + repositoryMemberships map[int64]mapset.Set[int64] + organizationMemberships map[int64]mapset.Set[int64] + outsideCollaboratorsByOrg map[int64]mapset.Set[int64] + organizations map[int64]github.Organization + repositories map[int64]github.Repository + teams map[int64]github.Team + users map[int64]github.User + orgRoles map[int64]mapset.Set[int64] // Maps role ID to set of user IDs + SimulateOrgRolePermErr bool // Simulate permission error for org roles } func NewMockGitHub() *MockGitHub { return &MockGitHub{ - teamMemberships: map[int64]mapset.Set[int64]{}, - repositoryMemberships: map[int64]mapset.Set[int64]{}, - organizationMemberships: map[int64]mapset.Set[int64]{}, - organizations: map[int64]github.Organization{}, - repositories: map[int64]github.Repository{}, - teams: map[int64]github.Team{}, - users: map[int64]github.User{}, - orgRoles: map[int64]mapset.Set[int64]{}, + teamMemberships: map[int64]mapset.Set[int64]{}, + repositoryMemberships: map[int64]mapset.Set[int64]{}, + organizationMemberships: map[int64]mapset.Set[int64]{}, + outsideCollaboratorsByOrg: map[int64]mapset.Set[int64]{}, + organizations: map[int64]github.Organization{}, + repositories: map[int64]github.Repository{}, + teams: map[int64]github.Team{}, + users: map[int64]github.User{}, + orgRoles: map[int64]mapset.Set[int64]{}, } } @@ -202,6 +203,22 @@ func (mgh MockGitHub) getUser( } } +func (mgh MockGitHub) getOutsideCollaborators( + w http.ResponseWriter, + variables map[string]string, +) { + orgID, _ := getCrossTableId(w, variables, "org") + users := make([]github.User, 0) + if memberships, ok := mgh.outsideCollaboratorsByOrg[orgID]; ok { + for _, userID := range memberships.ToSlice() { + if user, ok := mgh.users[userID]; ok { + users = append(users, user) + } + } + } + _, _ = w.Write(mock.MustMarshal(users)) +} + func (mgh MockGitHub) addUser( w http.ResponseWriter, variables map[string]string, @@ -752,6 +769,7 @@ func (mgh MockGitHub) Server() *http.Client { }: mgh.getOrgRoleByID, PutOrgsRolesUsersByOrgByRoleIdByUsername: mgh.addOrgRoleUser, DeleteOrgsRolesUsersByOrgByRoleIdByUsername: mgh.removeOrgRoleUser, + GetOrgsOutsideCollaboratorsByOrg: mgh.getOutsideCollaborators, } options := make([]mock.MockBackendOption, 0) @@ -781,3 +799,16 @@ func (mgh *MockGitHub) AddMembership(teamID int64, userID int64) { } mgh.teamMemberships[teamID].Add(userID) } + +// AddUser adds a user to the mock's user store for testing purposes. +func (mgh *MockGitHub) AddUser(user github.User) { + mgh.users[user.GetID()] = user +} + +// AddOutsideCollaborator adds a user as an outside collaborator to an org for testing purposes. +func (mgh *MockGitHub) AddOutsideCollaborator(orgID int64, userID int64) { + if _, ok := mgh.outsideCollaboratorsByOrg[orgID]; !ok { + mgh.outsideCollaboratorsByOrg[orgID] = mapset.NewSet[int64]() + } + mgh.outsideCollaboratorsByOrg[orgID].Add(userID) +}