Files
fleet/server/cron/google_workspace_cron_test.go
2026-08-04 11:36:02 -04:00

347 lines
12 KiB
Go

package cron
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"testing"
"unicode/utf8"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeDirectory struct {
users []*fleet.ScimUser
groups []*fleet.GoogleWorkspaceGroup
usersErr error
groupsErr error
}
func (f *fakeDirectory) ListUsers(context.Context) ([]*fleet.ScimUser, error) {
return f.users, f.usersErr
}
func (f *fakeDirectory) ListGroups(context.Context) ([]*fleet.GoogleWorkspaceGroup, error) {
return f.groups, f.groupsErr
}
func fakeFactory(dir fleet.GoogleWorkspaceDirectory, err error) GoogleWorkspaceDirectoryFactory {
return func(context.Context, *fleet.GoogleWorkspaceIntegration, *slog.Logger) (fleet.GoogleWorkspaceDirectory, error) {
return dir, err
}
}
func gwAppConfig() *fleet.AppConfig {
ac := &fleet.AppConfig{}
ac.Integrations.GoogleWorkspace = []*fleet.GoogleWorkspaceIntegration{{
Domain: "example.com",
ImpersonatedUserEmail: "admin@example.com",
}}
return ac
}
// syncRecorder wires a mock.Store with recording stubs for the scim datastore
// methods the sync engine uses, seeded with the given existing state.
type syncRecorder struct {
ds *mock.Store
createdUsers []*fleet.ScimUser
replacedUsers []*fleet.ScimUser
deletedUsers []uint
createdGroups []*fleet.ScimGroup
replacedGroups []*fleet.ScimGroup
deletedGroups []uint
lastRequest *fleet.ScimLastRequest
}
func newSyncRecorder(appConfig *fleet.AppConfig, existingUsers []fleet.ScimUser, existingGroups []fleet.ScimGroup) *syncRecorder {
r := &syncRecorder{ds: new(mock.Store)}
nextID := uint(1000)
r.ds.AppConfigFunc = func(context.Context) (*fleet.AppConfig, error) {
return appConfig, nil
}
r.ds.ListScimUsersFunc = func(_ context.Context, _ fleet.ScimUsersListOptions) ([]fleet.ScimUser, uint, error) {
return existingUsers, uint(len(existingUsers)), nil
}
r.ds.ListScimGroupsFunc = func(_ context.Context, _ fleet.ScimGroupsListOptions) ([]fleet.ScimGroup, uint, error) {
return existingGroups, uint(len(existingGroups)), nil
}
r.ds.CreateScimUserFunc = func(_ context.Context, user *fleet.ScimUser) (uint, error) {
nextID++
user.ID = nextID
r.createdUsers = append(r.createdUsers, user)
return nextID, nil
}
r.ds.ReplaceScimUserFunc = func(_ context.Context, user *fleet.ScimUser) ([]fleet.ActivityTypeResentCertificate, error) {
r.replacedUsers = append(r.replacedUsers, user)
return nil, nil
}
r.ds.DeleteScimUserFunc = func(_ context.Context, id uint) ([]fleet.ActivityTypeResentCertificate, error) {
r.deletedUsers = append(r.deletedUsers, id)
return nil, nil
}
r.ds.CreateScimGroupFunc = func(_ context.Context, group *fleet.ScimGroup) (uint, error) {
nextID++
group.ID = nextID
r.createdGroups = append(r.createdGroups, group)
return nextID, nil
}
r.ds.ReplaceScimGroupFunc = func(_ context.Context, group *fleet.ScimGroup) error {
r.replacedGroups = append(r.replacedGroups, group)
return nil
}
r.ds.DeleteScimGroupFunc = func(_ context.Context, id uint) error {
r.deletedGroups = append(r.deletedGroups, id)
return nil
}
r.ds.UpdateScimLastRequestFunc = func(_ context.Context, lastRequest *fleet.ScimLastRequest) error {
r.lastRequest = lastRequest
return nil
}
return r
}
func scimUser(extID, userName, dept string, active bool) fleet.ScimUser {
return fleet.ScimUser{
ExternalID: new(extID),
UserName: userName,
Department: new(dept),
Active: new(active),
Emails: []fleet.ScimUserEmail{{Email: userName, Primary: new(true)}},
}
}
func gwUser(extID, userName, dept string, active bool) *fleet.ScimUser {
u := scimUser(extID, userName, dept, active)
return &u
}
func runSync(t *testing.T, r *syncRecorder, dir fleet.GoogleWorkspaceDirectory) error {
t.Helper()
return cronGoogleWorkspaceSync(t.Context(), r.ds, fakeFactory(dir, nil), slog.New(slog.DiscardHandler))
}
func TestGoogleWorkspaceSyncCreatesUsersAndGroups(t *testing.T) {
r := newSyncRecorder(gwAppConfig(), nil, nil)
dir := &fakeDirectory{
users: []*fleet.ScimUser{
gwUser("g1", "alice@example.com", "Engineering", true),
gwUser("g2", "bob@example.com", "Sales", true),
},
groups: []*fleet.GoogleWorkspaceGroup{
{ExternalID: "grp1", DisplayName: "Engineering", MemberExternalIDs: []string{"g1", "g2"}},
},
}
require.NoError(t, runSync(t, r, dir))
require.Len(t, r.createdUsers, 2)
assert.Empty(t, r.replacedUsers)
assert.Empty(t, r.deletedUsers)
require.Len(t, r.createdGroups, 1)
// Members resolved to the IDs returned by CreateScimUser.
require.Len(t, r.createdGroups[0].ScimUsers, 2)
assert.ElementsMatch(t, []uint{r.createdUsers[0].ID, r.createdUsers[1].ID}, r.createdGroups[0].ScimUsers)
require.NotNil(t, r.lastRequest)
assert.Equal(t, "success", r.lastRequest.Status)
}
func TestGoogleWorkspaceSyncUpdatesChangedUser(t *testing.T) {
existing := scimUser("g1", "alice@example.com", "Engineering", true)
existing.ID = 1
r := newSyncRecorder(gwAppConfig(), []fleet.ScimUser{existing}, nil)
dir := &fakeDirectory{users: []*fleet.ScimUser{
gwUser("g1", "alice@example.com", "Marketing", true), // department changed
}}
require.NoError(t, runSync(t, r, dir))
assert.Empty(t, r.createdUsers)
require.Len(t, r.replacedUsers, 1)
assert.Equal(t, uint(1), r.replacedUsers[0].ID)
assert.Empty(t, r.deletedUsers)
}
func TestGoogleWorkspaceSyncIsIdempotent(t *testing.T) {
existing := scimUser("g1", "alice@example.com", "Engineering", true)
existing.ID = 1
r := newSyncRecorder(gwAppConfig(), []fleet.ScimUser{existing}, nil)
dir := &fakeDirectory{users: []*fleet.ScimUser{
gwUser("g1", "alice@example.com", "Engineering", true), // identical
}}
require.NoError(t, runSync(t, r, dir))
assert.Empty(t, r.createdUsers)
assert.Empty(t, r.replacedUsers, "unchanged user must not be replaced")
assert.Empty(t, r.deletedUsers)
require.NotNil(t, r.lastRequest)
assert.Equal(t, "success", r.lastRequest.Status)
}
func TestGoogleWorkspaceSyncDeletesRemovedUser(t *testing.T) {
keep := scimUser("g1", "alice@example.com", "Engineering", true)
keep.ID = 1
gone := scimUser("g2", "bob@example.com", "Sales", true)
gone.ID = 2
r := newSyncRecorder(gwAppConfig(), []fleet.ScimUser{keep, gone}, nil)
dir := &fakeDirectory{users: []*fleet.ScimUser{
gwUser("g1", "alice@example.com", "Engineering", true),
}}
require.NoError(t, runSync(t, r, dir))
assert.Empty(t, r.createdUsers)
assert.Empty(t, r.replacedUsers)
require.Equal(t, []uint{2}, r.deletedUsers)
}
func TestGoogleWorkspaceSyncEmptyPullDoesNotDelete(t *testing.T) {
existing := scimUser("g1", "alice@example.com", "Engineering", true)
existing.ID = 1
existingGroup := fleet.ScimGroup{ID: 5, ExternalID: new("grp1"), DisplayName: "Engineering"}
r := newSyncRecorder(gwAppConfig(), []fleet.ScimUser{existing}, []fleet.ScimGroup{existingGroup})
dir := &fakeDirectory{} // empty pull
require.NoError(t, runSync(t, r, dir))
assert.Empty(t, r.deletedUsers, "empty pull must not wipe users")
assert.Empty(t, r.deletedGroups, "empty pull must not wipe groups")
}
// TestGoogleWorkspaceSyncLimitErrorDoesNotDelete pins the invariant the directory
// sync limits rely on: a pull that hits a limit must abort, leaving existing IdP
// data alone. Reconciliation treats the pull as authoritative, so a limit that
// truncated instead of erroring would delete every record past it.
func TestGoogleWorkspaceSyncLimitErrorDoesNotDelete(t *testing.T) {
existingUser := scimUser("g1", "alice@example.com", "Engineering", true)
existingUser.ID = 1
existingGroup := fleet.ScimGroup{ID: 5, ExternalID: new("grp1"), DisplayName: "Engineering"}
for _, tc := range []struct {
name string
dir *fakeDirectory
}{
{
name: "users limit",
dir: &fakeDirectory{usersErr: errors.New("exceeded the limit of 500000 users; raise google_workspace.max_users to sync this domain")},
},
{
name: "groups limit",
dir: &fakeDirectory{
users: []*fleet.ScimUser{gwUser("g1", "alice@example.com", "Engineering", true)},
groupsErr: errors.New("exceeded the limit of 100000 groups; raise google_workspace.max_groups to sync this domain"),
},
},
} {
t.Run(tc.name, func(t *testing.T) {
r := newSyncRecorder(gwAppConfig(), []fleet.ScimUser{existingUser}, []fleet.ScimGroup{existingGroup})
err := runSync(t, r, tc.dir)
require.Error(t, err)
assert.Empty(t, r.deletedUsers, "a limit error must not delete users")
assert.Empty(t, r.deletedGroups, "a limit error must not delete groups")
require.NotNil(t, r.lastRequest)
assert.Equal(t, "error", r.lastRequest.Status)
assert.Contains(t, r.lastRequest.Details, "raise google_workspace.max_")
})
}
}
func TestGoogleWorkspaceSyncDirectoryErrorRecordsStatus(t *testing.T) {
r := newSyncRecorder(gwAppConfig(), nil, nil)
dir := &fakeDirectory{usersErr: errors.New("delegation not authorized")}
err := runSync(t, r, dir)
require.Error(t, err)
require.NotNil(t, r.lastRequest)
assert.Equal(t, "error", r.lastRequest.Status)
assert.Contains(t, r.lastRequest.Details, "delegation not authorized")
}
func TestGoogleWorkspaceSyncLongErrorIsTruncated(t *testing.T) {
r := newSyncRecorder(gwAppConfig(), nil, nil)
// Enforce the real scim_last_request.details VARCHAR(255) constraint so an
// over-length, un-truncated message would fail to record (the original bug).
r.ds.UpdateScimLastRequestFunc = func(_ context.Context, lastRequest *fleet.ScimLastRequest) error {
if utf8.RuneCountInString(lastRequest.Details) > fleet.SCIMMaxFieldLength {
return fmt.Errorf("details exceeds maximum length of %d characters", fleet.SCIMMaxFieldLength)
}
r.lastRequest = lastRequest
return nil
}
longMsg := strings.Repeat("é", fleet.SCIMMaxFieldLength*2) // multi-byte to exercise rune-safe truncation
dir := &fakeDirectory{usersErr: errors.New(longMsg)}
err := runSync(t, r, dir)
require.Error(t, err) // the sync error itself still propagates
require.NotNil(t, r.lastRequest, "status must be recorded even for an over-length error")
assert.Equal(t, "error", r.lastRequest.Status)
assert.LessOrEqual(t, utf8.RuneCountInString(r.lastRequest.Details), fleet.SCIMMaxFieldLength)
assert.True(t, utf8.ValidString(r.lastRequest.Details), "truncation must not split a multi-byte rune")
}
func TestGoogleWorkspaceSyncBestEffortContinuesPastFailedUser(t *testing.T) {
r := newSyncRecorder(gwAppConfig(), nil, nil)
// One user's creation fails (e.g. a unique-constraint conflict); the others
// must still be ingested rather than the whole sync aborting.
r.ds.CreateScimUserFunc = func(_ context.Context, user *fleet.ScimUser) (uint, error) {
if user.UserName == "bad@example.com" {
return 0, errors.New("ScimUser \"bad@example.com\" already exists")
}
r.createdUsers = append(r.createdUsers, user)
return uint(1000 + len(r.createdUsers)), nil
}
dir := &fakeDirectory{users: []*fleet.ScimUser{
gwUser("g1", "good1@example.com", "Eng", true),
gwUser("g2", "bad@example.com", "Eng", true),
gwUser("g3", "good2@example.com", "Eng", true),
}}
err := runSync(t, r, dir)
require.Error(t, err) // partial failure is surfaced...
assert.Contains(t, err.Error(), "partial sync")
// ...but the two good users were still created despite the one failure.
assert.Len(t, r.createdUsers, 2)
require.NotNil(t, r.lastRequest)
assert.Equal(t, "error", r.lastRequest.Status)
assert.Contains(t, r.lastRequest.Details, "partial sync")
}
func TestGoogleWorkspaceSyncNotConfiguredNoOp(t *testing.T) {
r := newSyncRecorder(&fleet.AppConfig{}, nil, nil) // no GoogleWorkspace integration
dir := &fakeDirectory{users: []*fleet.ScimUser{gwUser("g1", "a@example.com", "Eng", true)}}
require.NoError(t, runSync(t, r, dir))
assert.Empty(t, r.createdUsers)
assert.Nil(t, r.lastRequest, "no status recorded when not configured")
}
func TestGoogleWorkspaceSyncGroupMembershipUpdate(t *testing.T) {
user := scimUser("g1", "alice@example.com", "Engineering", true)
user.ID = 1
group := fleet.ScimGroup{ID: 5, ExternalID: new("grp1"), DisplayName: "Engineering", ScimUsers: []uint{}}
r := newSyncRecorder(gwAppConfig(), []fleet.ScimUser{user}, []fleet.ScimGroup{group})
dir := &fakeDirectory{
users: []*fleet.ScimUser{gwUser("g1", "alice@example.com", "Engineering", true)},
groups: []*fleet.GoogleWorkspaceGroup{
{ExternalID: "grp1", DisplayName: "Engineering", MemberExternalIDs: []string{"g1"}}, // alice added
},
}
require.NoError(t, runSync(t, r, dir))
require.Len(t, r.replacedGroups, 1)
assert.Equal(t, []uint{1}, r.replacedGroups[0].ScimUsers)
}