Files
fleet/server/cron/google_workspace_cron_test.go
T
Lucas Manuel Rodriguez ef0a051482 Google Workspace IdP [2/6]: backend (cron + directory sync) (#48165)
### 🥞 Stack (review/merge bottom-up)

1. #48164 — Activity types (FE+BE)
2. **#48165 — Backend (cron + directory sync) ⬅ this PR**
3. #48166 — Usage statistics
4. #48167 — fleetctl generate-gitops
5. #48168 — Settings UI

📄 Documentation is tracked separately in #48169 (targets
`docs-v4.89.0`).

---

## Summary

**PR 2 of 6.** Core **backend** for the Google Workspace IdP
integration:
- Directory sync client (`ee/server/googleworkspace/`) and cron job
(`server/cron/google_workspace_cron.go`) reusing the `scim_*` tables
(Google Workspace and SCIM are mutually exclusive).
- Config types + validation (`server/fleet/google_workspace.go`,
`app.go`, `integrations.go`), appconfig handling + activity emission
(`server/service/appconfig.go`), cron registration and schedule.
- SCIM is ignored while Google Workspace is configured
(`ee/server/scim/scim.go`).

> 🥞 **Stacked PR.** Base: `42915-gw-idp-vitals-1-activities` (PR 1) —
review/merge that first.

**Related issue:** Resolves #42915

# Checklist for submitter

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements).
- [ ] Timeouts are implemented and retries are limited to avoid infinite
loops.

## Testing

- [ ] Added/updated automated tests
- [ ] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added Google Workspace integration support for syncing users, groups,
and host-related identity data.
* Added a scheduled sync that keeps directory data up to date
automatically.
* Added support for configuring Google Workspace in app settings, with
validation and masking of sensitive credentials.

* **Bug Fixes**
* Prevented SCIM provisioning from overwriting data when Google
Workspace sync is configured.
* Preserved existing Google Workspace credentials when an update omits
masked API key values.
* Added handling for deleted users and group membership changes during
sync.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-29 12:28:10 -03:00

308 lines
11 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) error {
r.replacedUsers = append(r.replacedUsers, user)
return nil
}
r.ds.DeleteScimUserFunc = func(_ context.Context, id uint) error {
r.deletedUsers = append(r.deletedUsers, id)
return 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")
}
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)
}