diff --git a/go.mod b/go.mod index c9c0081c..0208eb91 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.37.0 github.com/aws/aws-secretsmanager-caching-go/v2 v2.1.1 github.com/golang/mock v1.6.0 + github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.8 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 @@ -45,7 +46,6 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect diff --git a/internal/aws/client_dry.go b/internal/aws/client_dry.go index 60e2d09f..833f7572 100644 --- a/internal/aws/client_dry.go +++ b/internal/aws/client_dry.go @@ -27,6 +27,7 @@ func NewDryClient(c internal_http.Client, config *Config) (Client, error) { func (dc *dryClient) CreateUser(u *interfaces.User) (*interfaces.User, error) { log.WithField("user", u.Username).Info("DRY RUN: Would create user") + u.ID = virtualUserID(u.Username) dc.virtualUsers[u.Username] = *u return u, nil } @@ -61,6 +62,9 @@ func (dc *dryClient) FindUserByEmail(email string) (*interfaces.User, error) { func (dc *dryClient) UpdateUser(u *interfaces.User) (*interfaces.User, error) { log.WithField("user", u.Username).Info("DRY RUN: Would update user") + if u.ID == "" { + u.ID = virtualUserID(u.Username) + } dc.virtualUsers[u.Username] = *u return u, nil } diff --git a/internal/aws/client_dry_test.go b/internal/aws/client_dry_test.go new file mode 100644 index 00000000..990bbb5c --- /dev/null +++ b/internal/aws/client_dry_test.go @@ -0,0 +1,108 @@ +package aws + +import ( + "errors" + "testing" + + "github.com/awslabs/ssosync/internal/interfaces" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubSCIMClient is a minimal stub of the SCIM Client interface for dry-client tests. +type stubSCIMClient struct { + findUserResult *interfaces.User + findUserErr error +} + +func (s *stubSCIMClient) CreateUser(u *interfaces.User) (*interfaces.User, error) { + return u, nil +} +func (s *stubSCIMClient) FindGroupByDisplayName(name string) (*interfaces.Group, error) { + return nil, ErrGroupNotFound +} +func (s *stubSCIMClient) FindUserByEmail(email string) (*interfaces.User, error) { + return s.findUserResult, s.findUserErr +} +func (s *stubSCIMClient) UpdateUser(u *interfaces.User) (*interfaces.User, error) { + return u, nil +} + +func newTestDryClient(t *testing.T, stub *stubSCIMClient) *dryClient { + t.Helper() + if stub == nil { + stub = &stubSCIMClient{findUserErr: ErrUserNotFound} + } + return &dryClient{ + c: stub, + virtualUsers: make(map[string]interfaces.User), + } +} + +func TestDryClient_CreateUser_PopulatesVirtualID(t *testing.T) { + dc := newTestDryClient(t, nil) + u := NewUser("Alice", "Smith", "alice@example.com", true) + + result, err := dc.CreateUser(u) + + require.NoError(t, err) + assert.Equal(t, virtualUserID("alice@example.com"), result.ID) + assert.True(t, isVirtualID(result.ID)) + assert.Regexp(t, awsIDRegex, result.ID) + stored := dc.virtualUsers["alice@example.com"] + assert.Equal(t, result.ID, stored.ID) +} + +func TestDryClient_CreateUser_Deterministic(t *testing.T) { + dc := newTestDryClient(t, nil) + u1 := NewUser("Alice", "Smith", "alice@example.com", true) + u2 := NewUser("Alice", "Smith", "alice@example.com", true) + + r1, _ := dc.CreateUser(u1) + r2, _ := dc.CreateUser(u2) + + assert.Equal(t, r1.ID, r2.ID, "same email must produce the same virtual ID") +} + +func TestDryClient_FindUserByEmail_ReturnsVirtualUserWithID(t *testing.T) { + dc := newTestDryClient(t, &stubSCIMClient{findUserErr: ErrUserNotFound}) + u := NewUser("Bob", "Jones", "bob@example.com", true) + _, err := dc.CreateUser(u) + require.NoError(t, err) + + found, err := dc.FindUserByEmail("bob@example.com") + + require.NoError(t, err) + require.NotNil(t, found) + assert.Equal(t, virtualUserID("bob@example.com"), found.ID) + assert.True(t, isVirtualID(found.ID)) +} + +func TestDryClient_UpdateUser_PreservesExistingID(t *testing.T) { + dc := newTestDryClient(t, nil) + u := UpdateUser("real-aws-id-1234", "Carol", "White", "carol@example.com", false) + + result, err := dc.UpdateUser(u) + + require.NoError(t, err) + assert.Equal(t, "real-aws-id-1234", result.ID, "real ID must not be replaced") +} + +func TestDryClient_UpdateUser_PopulatesVirtualIDWhenEmpty(t *testing.T) { + dc := newTestDryClient(t, nil) + u := NewUser("Dan", "Brown", "dan@example.com", true) + + result, err := dc.UpdateUser(u) + + require.NoError(t, err) + assert.Equal(t, virtualUserID("dan@example.com"), result.ID) +} + +func TestDryClient_FindUserByEmail_ForwardsRealError(t *testing.T) { + dc := newTestDryClient(t, &stubSCIMClient{findUserErr: errors.New("network error")}) + + result, err := dc.FindUserByEmail("err@example.com") + + assert.Nil(t, result) + assert.EqualError(t, err, "network error") +} diff --git a/internal/aws/identitystore_dry.go b/internal/aws/identitystore_dry.go index 200daee4..47eb899e 100644 --- a/internal/aws/identitystore_dry.go +++ b/internal/aws/identitystore_dry.go @@ -2,9 +2,11 @@ package aws import ( "context" + "slices" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/identitystore" + "github.com/aws/aws-sdk-go-v2/service/identitystore/types" "github.com/awslabs/ssosync/internal/interfaces" log "github.com/sirupsen/logrus" ) @@ -26,18 +28,19 @@ func NewDryIdentityStore(client interfaces.IdentityStoreAPI) interfaces.Identity func (d *DryIdentityStore) CreateGroup(ctx context.Context, params *identitystore.CreateGroupInput, optFns ...func(*identitystore.Options)) (*identitystore.CreateGroupOutput, error) { log.WithField("displayName", *params.DisplayName).Info("DRY RUN: Would create group") return &identitystore.CreateGroupOutput{ - GroupId: aws.String(*params.DisplayName + "-virtual"), + GroupId: aws.String(virtualGroupID(*params.DisplayName)), IdentityStoreId: params.IdentityStoreId, }, nil } func (d *DryIdentityStore) CreateGroupMembership(ctx context.Context, params *identitystore.CreateGroupMembershipInput, optFns ...func(*identitystore.Options)) (*identitystore.CreateGroupMembershipOutput, error) { + memberValue := memberIDValue(params.MemberId) log.WithFields(log.Fields{ "groupId": *params.GroupId, - "userId": params.MemberId, + "userId": memberValue, }).Info("DRY RUN: Would create group membership") return &identitystore.CreateGroupMembershipOutput{ - MembershipId: aws.String("virtual-membership-id"), + MembershipId: aws.String(virtualMembershipID(*params.GroupId, memberValue)), IdentityStoreId: params.IdentityStoreId, }, nil } @@ -57,15 +60,42 @@ func (d *DryIdentityStore) DeleteUser(ctx context.Context, params *identitystore return &identitystore.DeleteUserOutput{}, nil } +// GetGroupMembershipId short-circuits when either the group or member is virtual +// so the real client is never called with a synthetic ID. func (d *DryIdentityStore) GetGroupMembershipId(ctx context.Context, params *identitystore.GetGroupMembershipIdInput, optFns ...func(*identitystore.Options)) (*identitystore.GetGroupMembershipIdOutput, error) { + memberValue := memberIDValue(params.MemberId) + if isVirtualID(*params.GroupId) || isVirtualID(memberValue) { + return &identitystore.GetGroupMembershipIdOutput{ + MembershipId: aws.String(virtualMembershipID(*params.GroupId, memberValue)), + IdentityStoreId: params.IdentityStoreId, + }, nil + } return d.client.GetGroupMembershipId(ctx, params, optFns...) } +// IsMemberInGroups short-circuits when the member or any group is virtual — +// a virtual user was never actually added, so membership is always false. func (d *DryIdentityStore) IsMemberInGroups(ctx context.Context, params *identitystore.IsMemberInGroupsInput, optFns ...func(*identitystore.Options)) (*identitystore.IsMemberInGroupsOutput, error) { - return d.client.IsMemberInGroups(ctx, params, optFns...) + memberValue := memberIDValue(params.MemberId) + if !isVirtualID(memberValue) && !slices.ContainsFunc(params.GroupIds, isVirtualID) { + return d.client.IsMemberInGroups(ctx, params, optFns...) + } + results := make([]types.GroupMembershipExistenceResult, len(params.GroupIds)) + for i, gid := range params.GroupIds { + results[i] = types.GroupMembershipExistenceResult{ + GroupId: aws.String(gid), + MembershipExists: false, + } + } + return &identitystore.IsMemberInGroupsOutput{Results: results}, nil } +// ListGroupMemberships short-circuits when the group is virtual — it has no +// real memberships to enumerate. func (d *DryIdentityStore) ListGroupMemberships(ctx context.Context, params *identitystore.ListGroupMembershipsInput, optFns ...func(*identitystore.Options)) (*identitystore.ListGroupMembershipsOutput, error) { + if isVirtualID(*params.GroupId) { + return &identitystore.ListGroupMembershipsOutput{}, nil + } return d.client.ListGroupMemberships(ctx, params, optFns...) } @@ -80,7 +110,17 @@ func (d *DryIdentityStore) ListUsers(ctx context.Context, params *identitystore. func (d *DryIdentityStore) CreateUser(ctx context.Context, params *identitystore.CreateUserInput, optFns ...func(*identitystore.Options)) (*identitystore.CreateUserOutput, error) { log.WithField("userName", *params.UserName).Info("DRY RUN: Would create user") return &identitystore.CreateUserOutput{ - UserId: aws.String(*params.UserName + "-virtual"), + UserId: aws.String(virtualUserID(*params.UserName)), IdentityStoreId: params.IdentityStoreId, }, nil } + +func memberIDValue(mid types.MemberId) string { + if mid == nil { + return "" + } + if m, ok := mid.(*types.MemberIdMemberUserId); ok { + return m.Value + } + return "" +} diff --git a/internal/aws/identitystore_dry_test.go b/internal/aws/identitystore_dry_test.go index 29cb1565..53f7543b 100644 --- a/internal/aws/identitystore_dry_test.go +++ b/internal/aws/identitystore_dry_test.go @@ -32,12 +32,13 @@ func TestDryIdentityStore_CreateGroup(t *testing.T) { DisplayName: aws.String("Test Group"), } - // Should not call the underlying client + // Underlying client must not be called. result, err := dryStore.CreateGroup(ctx, input) require.NoError(t, err) assert.NotNil(t, result) - assert.Equal(t, "Test Group-virtual", *result.GroupId) + assert.Equal(t, virtualGroupID("Test Group"), *result.GroupId) + assert.True(t, isVirtualID(*result.GroupId)) assert.Equal(t, input.IdentityStoreId, result.IdentityStoreId) } @@ -56,7 +57,8 @@ func TestDryIdentityStore_CreateGroupMembership(t *testing.T) { require.NoError(t, err) assert.NotNil(t, result) - assert.Equal(t, "virtual-membership-id", *result.MembershipId) + assert.Equal(t, virtualMembershipID("group-123", "user-123"), *result.MembershipId) + assert.True(t, isVirtualID(*result.MembershipId)) assert.Equal(t, input.IdentityStoreId, result.IdentityStoreId) } @@ -92,13 +94,118 @@ func TestDryIdentityStore_DeleteUser(t *testing.T) { assert.NotNil(t, result) } +func TestDryIdentityStore_IsMemberInGroups_VirtualUser_ShortCircuits(t *testing.T) { + mockClient := mocks.NewMockIdentityStoreAPI(t) + dryStore := NewDryIdentityStore(mockClient) + + ctx := context.Background() + virtualUID := virtualUserID("newuser@example.com") + realGroupID := "12345678-1234-1234-1234-123456789012" + + input := &identitystore.IsMemberInGroupsInput{ + IdentityStoreId: aws.String("d-123456789"), + GroupIds: []string{realGroupID}, + MemberId: &types.MemberIdMemberUserId{Value: virtualUID}, + } + + // The underlying client must NOT be called. + result, err := dryStore.IsMemberInGroups(ctx, input) + + require.NoError(t, err) + require.Len(t, result.Results, 1) + assert.False(t, result.Results[0].MembershipExists, "virtual user must not be a member") + assert.Equal(t, realGroupID, *result.Results[0].GroupId) +} + +func TestDryIdentityStore_IsMemberInGroups_VirtualGroup_ShortCircuits(t *testing.T) { + mockClient := mocks.NewMockIdentityStoreAPI(t) + dryStore := NewDryIdentityStore(mockClient) + + ctx := context.Background() + virtualGID := virtualGroupID("NewGroup") + realUID := "12345678-1234-1234-1234-123456789012" + + input := &identitystore.IsMemberInGroupsInput{ + IdentityStoreId: aws.String("d-123456789"), + GroupIds: []string{virtualGID}, + MemberId: &types.MemberIdMemberUserId{Value: realUID}, + } + + result, err := dryStore.IsMemberInGroups(ctx, input) + + require.NoError(t, err) + require.Len(t, result.Results, 1) + assert.False(t, result.Results[0].MembershipExists) +} + +func TestDryIdentityStore_IsMemberInGroups_RealIDs_PassesThrough(t *testing.T) { + mockClient := mocks.NewMockIdentityStoreAPI(t) + dryStore := NewDryIdentityStore(mockClient) + + ctx := context.Background() + input := &identitystore.IsMemberInGroupsInput{ + IdentityStoreId: aws.String("d-123456789"), + GroupIds: []string{"group-real"}, + MemberId: &types.MemberIdMemberUserId{Value: "user-real"}, + } + expected := &identitystore.IsMemberInGroupsOutput{ + Results: []types.GroupMembershipExistenceResult{ + {GroupId: aws.String("group-real"), MembershipExists: true}, + }, + } + + mockClient.EXPECT().IsMemberInGroups(ctx, input).Return(expected, nil).Once() + + result, err := dryStore.IsMemberInGroups(ctx, input) + require.NoError(t, err) + assert.Equal(t, expected, result) +} + +func TestDryIdentityStore_GetGroupMembershipId_VirtualMember_ShortCircuits(t *testing.T) { + mockClient := mocks.NewMockIdentityStoreAPI(t) + dryStore := NewDryIdentityStore(mockClient) + + ctx := context.Background() + virtualUID := virtualUserID("new@example.com") + groupID := "12345678-1234-1234-1234-123456789012" + + input := &identitystore.GetGroupMembershipIdInput{ + IdentityStoreId: aws.String("d-123456789"), + GroupId: aws.String(groupID), + MemberId: &types.MemberIdMemberUserId{Value: virtualUID}, + } + + // Underlying client must NOT be called. + result, err := dryStore.GetGroupMembershipId(ctx, input) + + require.NoError(t, err) + assert.Equal(t, virtualMembershipID(groupID, virtualUID), *result.MembershipId) +} + +func TestDryIdentityStore_ListGroupMemberships_VirtualGroup_ShortCircuits(t *testing.T) { + mockClient := mocks.NewMockIdentityStoreAPI(t) + dryStore := NewDryIdentityStore(mockClient) + + ctx := context.Background() + input := &identitystore.ListGroupMembershipsInput{ + IdentityStoreId: aws.String("d-123456789"), + GroupId: aws.String(virtualGroupID("NewGroup")), + } + + // Underlying client must NOT be called. + result, err := dryStore.ListGroupMemberships(ctx, input) + + require.NoError(t, err) + assert.Empty(t, result.GroupMemberships) +} + func TestDryIdentityStore_PassThroughMethods(t *testing.T) { mockClient := mocks.NewMockIdentityStoreAPI(t) dryStore := NewDryIdentityStore(mockClient) ctx := context.Background() - // Test IsMemberInGroups passes through to underlying client + // Test IsMemberInGroups passes through for real IDs isMemberInput := &identitystore.IsMemberInGroupsInput{ IdentityStoreId: aws.String("d-123456789"), GroupIds: []string{"group-123"}, diff --git a/internal/aws/virtual_id.go b/internal/aws/virtual_id.go new file mode 100644 index 00000000..2c229177 --- /dev/null +++ b/internal/aws/virtual_id.go @@ -0,0 +1,41 @@ +package aws + +import ( + "strings" + + "github.com/google/uuid" +) + +// virtualIDPrefix is a 10-char lowercase hex string followed by a dash. +// It satisfies the optional prefix group in the AWS Identity Store ID regex: +// +// ([0-9a-f]{10}-|)[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12} +// +// and lets isVirtualID() detect synthetic IDs without shared state between the +// dryClient and DryIdentityStore shims. +const virtualIDPrefix = "deadbeef00-" + +// virtualNamespace is the UUIDv5 namespace used to derive all virtual IDs. +// Seeding it from a project-specific URL makes collisions with real IDs negligible. +var virtualNamespace = uuid.NewSHA1(uuid.NameSpaceURL, []byte("ssosync/dry-run")) + +// virtualUserID returns a deterministic virtual ID for the given email address. +func virtualUserID(email string) string { + return virtualIDPrefix + uuid.NewSHA1(virtualNamespace, []byte("user:"+email)).String() +} + +// virtualGroupID returns a deterministic virtual ID for the given group display name. +func virtualGroupID(displayName string) string { + return virtualIDPrefix + uuid.NewSHA1(virtualNamespace, []byte("group:"+displayName)).String() +} + +// virtualMembershipID returns a deterministic virtual membership ID for a +// (groupID, userID) pair. +func virtualMembershipID(groupID, userID string) string { + return virtualIDPrefix + uuid.NewSHA1(virtualNamespace, []byte("membership:"+groupID+":"+userID)).String() +} + +// isVirtualID reports whether id was generated by this package's virtual ID helpers. +func isVirtualID(id string) bool { + return strings.HasPrefix(id, virtualIDPrefix) +} diff --git a/internal/aws/virtual_id_test.go b/internal/aws/virtual_id_test.go new file mode 100644 index 00000000..65f4be5d --- /dev/null +++ b/internal/aws/virtual_id_test.go @@ -0,0 +1,44 @@ +package aws + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +var awsIDRegex = regexp.MustCompile(`^([0-9a-f]{10}-|)[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$`) + +func TestVirtualUserID_MatchesAWSRegex(t *testing.T) { + id := virtualUserID("alice@example.com") + assert.Regexp(t, awsIDRegex, id) +} + +func TestVirtualGroupID_MatchesAWSRegex(t *testing.T) { + id := virtualGroupID("Engineering") + assert.Regexp(t, awsIDRegex, id) +} + +func TestVirtualMembershipID_MatchesAWSRegex(t *testing.T) { + id := virtualMembershipID(virtualGroupID("Eng"), virtualUserID("alice@example.com")) + assert.Regexp(t, awsIDRegex, id) +} + +func TestVirtualUserID_Deterministic(t *testing.T) { + a := virtualUserID("bob@example.com") + b := virtualUserID("bob@example.com") + assert.Equal(t, a, b, "same input must produce the same virtual ID") +} + +func TestVirtualUserID_Unique(t *testing.T) { + a := virtualUserID("alice@example.com") + b := virtualUserID("bob@example.com") + assert.NotEqual(t, a, b, "different inputs must produce different virtual IDs") +} + +func TestIsVirtualID(t *testing.T) { + assert.True(t, isVirtualID(virtualUserID("x@y.com"))) + assert.True(t, isVirtualID(virtualGroupID("G"))) + assert.False(t, isVirtualID("12345678-1234-1234-1234-123456789012")) + assert.False(t, isVirtualID("")) +} diff --git a/internal/sync_dryrun_test.go b/internal/sync_dryrun_test.go new file mode 100644 index 00000000..5fc497b9 --- /dev/null +++ b/internal/sync_dryrun_test.go @@ -0,0 +1,104 @@ +package internal + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + aws_sdk "github.com/aws/aws-sdk-go-v2/aws" + aws_identitystore "github.com/aws/aws-sdk-go-v2/service/identitystore" + idstypes "github.com/aws/aws-sdk-go-v2/service/identitystore/types" + "github.com/awslabs/ssosync/internal/aws" + "github.com/awslabs/ssosync/internal/config" + "github.com/awslabs/ssosync/internal/mocks" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + admin "google.golang.org/api/admin/directory/v1" +) + +// stubGoogleClient is a minimal Google client stub for dry-run sync tests. +type stubGoogleClient struct { + groups []*admin.Group + groupMembers map[string][]*admin.Member + usersByQuery map[string][]*admin.User +} + +func (s *stubGoogleClient) GetDeletedUsers() ([]*admin.User, error) { return nil, nil } + +func (s *stubGoogleClient) GetGroups(_ string) ([]*admin.Group, error) { return s.groups, nil } + +func (s *stubGoogleClient) GetGroupMembers(g *admin.Group) ([]*admin.Member, error) { + return s.groupMembers[g.Id], nil +} + +func (s *stubGoogleClient) GetUsers(query, _ string) ([]*admin.User, error) { + return s.usersByQuery[query], nil +} + +// TestSyncGroupsUsers_DryRun_NewUserNotInAWS_DoesNotCrash reproduces the crash +// from https://github.com/awslabs/ssosync/issues/281: when DRY_RUN=true, a +// Google user that does not yet exist in AWS Identity Store must not cause a +// ValidationException from an empty memberId.userId. +func TestSyncGroupsUsers_DryRun_NewUserNotInAWS_DoesNotCrash(t *testing.T) { + // SCIM server: alice does not exist in AWS yet — every lookup returns 0 results. + // dryClient.CreateUser doesn't make HTTP calls, so only GET /Users hits the server. + scimServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/scim+json") + fmt.Fprintln(w, `{"totalResults":0,"Resources":[]}`) + })) + defer scimServer.Close() + + awsClient, err := aws.NewDryClient(scimServer.Client(), &aws.Config{ + Endpoint: scimServer.URL, + Token: "test-token", + }) + require.NoError(t, err) + + mockIDS := mocks.NewMockIdentityStoreAPI(t) + dryIDS := aws.NewDryIdentityStore(mockIDS) + + const identityStoreID = "d-test" + const engGroupID = "12345678-1234-1234-1234-123456789012" + + // Engineering exists in AWS; alice does not. + mockIDS.EXPECT().ListGroups(mock.Anything, mock.Anything, mock.Anything). + Return(&aws_identitystore.ListGroupsOutput{ + Groups: []idstypes.Group{ + {GroupId: aws_sdk.String(engGroupID), DisplayName: aws_sdk.String("Engineering")}, + }, + }, nil).Once() + mockIDS.EXPECT().ListUsers(mock.Anything, mock.Anything, mock.Anything). + Return(&aws_identitystore.ListUsersOutput{}, nil).Once() + mockIDS.EXPECT().ListGroupMemberships(mock.Anything, mock.Anything, mock.Anything). + Return(&aws_identitystore.ListGroupMembershipsOutput{}, nil).Once() + // IsMemberInGroups and CreateGroupMembership short-circuit in DryIdentityStore + // when the user ID is virtual — the real AWS client is never called. + + gClient := &stubGoogleClient{ + groups: []*admin.Group{ + {Id: "google-eng-id", Name: "Engineering", Email: "eng@example.com"}, + }, + groupMembers: map[string][]*admin.Member{ + "google-eng-id": { + {Email: "alice@example.com", Type: "USER", Status: "ACTIVE"}, + }, + }, + usersByQuery: map[string][]*admin.User{ + "email=alice@example.com": { + { + Id: "google-alice-id", + PrimaryEmail: "alice@example.com", + Name: &admin.UserName{GivenName: "Alice", FamilyName: "Smith"}, + }, + }, + }, + } + + cfg := &config.Config{ + IdentityStoreID: identityStoreID, + } + + syncer := New(cfg, awsClient, gClient, dryIDS) + require.NoError(t, syncer.SyncGroupsUsers("Engineering", "")) +}