Files
fleet/orbit/pkg/update/notifications_test.go
Victor Lyuboslavsky e20cedc8a0 fleetd Windows MDM wake (push vs poll) (#46594)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #46567 and Resolves #46737 

Solution for the agressive polling:
- no WNS (although we could add it later as another avenue for
notifications)
- fleetd advertises a sync capability, persisted as
`mdm_windows_enrollments.fleetd_sync_capable`
- The management session relaxes the DMClient poll
(`poll_schedule_relaxed`)
- When an MDM command is queued, `has_pending_commands` flips, the next
orbit check-in returns `WindowsMDMSyncRequest`, and fleetd runs
`deviceenroller` to deliver it immediately
- older fleetd versions keep the 1-minute poll

Docs: https://github.com/fleetdm/fleet/pull/46780

Changes to osquery_perf and any additional changes after loadtesting
will be done in a separate PR.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Timeouts are implemented and retries are limited to avoid infinite
loops

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).

## fleetd/orbit/Fleet Desktop

- [x] Verified compatibility with the latest released version of Fleet
(see [Must
rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md))
- [x] If the change applies to only one platform, confirmed that
`runtime.GOOS` is used as needed to isolate changes
- [x] Verified that fleetd runs on macOS, Linux and Windows
- [x] Verified auto-update works from the released version of component
to the new version (see [tools/tuf/test](../tools/tuf/test/README.md))


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

* **New Features**
* On-demand Windows MDM sync: servers can request immediate delivery of
queued MDM commands to Windows clients; Orbit triggers client-side sync
on Windows.

* **Enhancements**
  * Orbit throttles per-device on-demand sync to avoid excessive runs.
* Server reconciles and persists device poll schedule (fast vs relaxed)
and exposes consolidated host MDM state (awaiting-configuration +
has-pending-commands).

* **Tests**
* Added tests covering host config state, pending-command flows,
poll-schedule toggling, and on-demand sync behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-03 17:38:14 -05:00

922 lines
32 KiB
Go

package update
import (
"bytes"
"errors"
"fmt"
"io"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/fleetdm/fleet/v4/orbit/pkg/bitlocker"
"github.com/fleetdm/fleet/v4/orbit/pkg/scripts"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
func TestRenewEnrollmentProfile(t *testing.T) {
var logBuf bytes.Buffer
oldLog := log.Logger
log.Logger = log.Output(&logBuf)
t.Cleanup(func() { log.Logger = oldLog })
cases := []struct {
desc string
renewFlag bool
cmdErr error
wantCmdCalled bool
wantLog string
}{
{"renew=false", false, nil, false, ""},
{"renew=true; success", true, nil, true, "successfully called /usr/bin/profiles to renew enrollment profile"},
{"renew=true; fail", true, io.ErrUnexpectedEOF, true, "calling /usr/bin/profiles to renew enrollment profile failed"},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
logBuf.Reset()
testConfig := &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{RenewEnrollmentProfile: c.renewFlag}}
var cmdGotCalled bool
var depAssignedCheckGotCalled bool
renewReceiver := &renewEnrollmentProfileConfigReceiver{
Frequency: time.Hour, // doesn't matter for this test
runCmdFn: func() error {
cmdGotCalled = true
return c.cmdErr
},
checkEnrollmentFn: func() (bool, string, error) {
return false, "", nil
},
checkAssignedEnrollmentProfileFn: func(url string) error {
depAssignedCheckGotCalled = true
return nil
},
}
err := renewReceiver.Run(testConfig)
require.NoError(t, err) // the dummy receiver never returns an error
require.Equal(t, c.wantCmdCalled, cmdGotCalled)
require.Equal(t, c.wantCmdCalled, depAssignedCheckGotCalled)
require.Contains(t, logBuf.String(), c.wantLog)
})
}
}
func TestRenewEnrollmentProfilePrevented(t *testing.T) {
var logBuf bytes.Buffer
oldLog := log.Logger
log.Logger = log.Output(&logBuf)
t.Cleanup(func() { log.Logger = oldLog })
testConfig := &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{RenewEnrollmentProfile: true}}
var cmdCallCount int
isEnrolled := false
isAssigned := true
chProceed := make(chan struct{})
renewReceiver := &renewEnrollmentProfileConfigReceiver{
Frequency: 2 * time.Second, // just to be safe with slow environments (CI)
runCmdFn: func() error {
cmdCallCount++ // no need for sync, single-threaded call of this func is guaranteed by the receiver's mutex
return nil
},
checkEnrollmentFn: func() (bool, string, error) {
<-chProceed // will be unblocked only when allowed
return isEnrolled, "", nil
},
checkAssignedEnrollmentProfileFn: func(url string) error {
<-chProceed // will be unblocked only when allowed
if !isAssigned {
return errors.New("not assigned")
}
return nil
},
}
// One of the calls to renewReceiver.Run() will run first and get blocked in checkEnrollmentFn. The
// second won't call the command (won't be able to lock the mutex). So, it will still complete successfully
// without being blocked by the other call in progress. Whichever one exits first then needs to close
// chProceed so the other one is unblocked.
var shouldCloseChProceed atomic.Bool
shouldCloseChProceed.Store(true)
started := make(chan struct{})
frequencyMu := sync.Mutex{}
go func() {
frequencyMu.Lock()
defer frequencyMu.Unlock()
close(started)
err := renewReceiver.Run(testConfig)
require.NoError(t, err)
if shouldCloseChProceed.CompareAndSwap(true, false) {
close(chProceed)
t.Logf("%v unblock the first call from the goroutine", time.Now())
}
}()
<-started
t.Logf("%v started", time.Now())
err := renewReceiver.Run(testConfig)
require.NoError(t, err)
if shouldCloseChProceed.CompareAndSwap(true, false) {
// unblock the first call
close(chProceed)
t.Logf("%v unblock the first call", time.Now())
}
// this next call won't execute the command because of the frequency
// restriction (it got called less than N seconds ago)
err = renewReceiver.Run(testConfig)
require.NoError(t, err)
t.Logf("%v frequency restriction check done", time.Now())
frequencyMu.Lock()
renewReceiver.Frequency = 200 * time.Millisecond
frequencyMu.Unlock()
// wait for the receiver's frequency to pass
time.Sleep(renewReceiver.Frequency)
// this call executes the command
err = renewReceiver.Run(testConfig)
require.NoError(t, err)
// wait for the receiver's frequency to pass
time.Sleep(renewReceiver.Frequency)
// this call doesn't execute the command since the host is already
// enrolled
isEnrolled = true
err = renewReceiver.Run(testConfig)
require.NoError(t, err)
require.Equal(t, 2, cmdCallCount) // the initial call and the one after sleep
// wait for the receiver's frequency to pass
time.Sleep(renewReceiver.Frequency)
// this call doesn't execute the command since the assigned profile check fails
isAssigned = false
isEnrolled = false
err = renewReceiver.Run(testConfig)
require.NoError(t, err)
require.Equal(t, 2, cmdCallCount) // the initial call and the one after sleep
// wait for the receiver's frequency to pass
time.Sleep(renewReceiver.Frequency)
// this next call won't execute the command because the backoff
// for a failed assigned check is always 2 minutes
err = renewReceiver.Run(testConfig)
require.NoError(t, err)
}
type mockNodeKeyGetter struct{}
func (m mockNodeKeyGetter) GetNodeKey() (string, error) {
return "nodekey-test", nil
}
func TestWindowsMDMEnrollment(t *testing.T) {
var logBuf bytes.Buffer
oldLog := log.Logger
log.Logger = log.Output(&logBuf)
t.Cleanup(func() { log.Logger = oldLog })
cases := []struct {
desc string
enrollFlag *bool
unenrollFlag *bool
migrateFlag *bool
discoveryURL string
apiErr error
wantAPICalled bool
wantLog string
}{
{"enroll=false", ptr.Bool(false), nil, nil, "", nil, false, ""},
{"enroll=true,discovery=''", ptr.Bool(true), nil, nil, "", nil, false, "discovery endpoint is empty"},
{"enroll=true,discovery!='',success", ptr.Bool(true), nil, nil, "http://example.com", nil, true, "successfully called RegisterDeviceWithManagement"},
{"enroll=true,discovery!='',fail", ptr.Bool(true), nil, nil, "http://example.com", io.ErrUnexpectedEOF, true, "enroll Windows device failed"},
{"enroll=true,discovery!='',server", ptr.Bool(true), nil, nil, "http://example.com", errIsWindowsServer, true, "device is a Windows Server, skipping enrollment"},
{"unenroll=false", nil, ptr.Bool(false), nil, "", nil, false, ""},
{"unenroll=true,success", nil, ptr.Bool(true), nil, "", nil, true, "successfully called UnregisterDeviceWithManagement to unenroll"},
{"unenroll=true,fail", nil, ptr.Bool(true), nil, "", io.ErrUnexpectedEOF, true, "unenroll Windows device failed"},
{"unenroll=true,server", nil, ptr.Bool(true), nil, "", errIsWindowsServer, true, "device is a Windows Server, skipping unenroll"},
{"migrate=false", nil, nil, ptr.Bool(false), "", nil, false, ""},
{"migrate=true,success", nil, nil, ptr.Bool(true), "", nil, true, "successfully called UnregisterDeviceWithManagement to migrate"},
{"migrate=true,fail", nil, nil, ptr.Bool(true), "", io.ErrUnexpectedEOF, true, "migrate Windows device failed"},
{"migrate=true,server", nil, nil, ptr.Bool(true), "", errIsWindowsServer, true, "device is a Windows Server, skipping migrate"},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
logBuf.Reset()
var (
enroll = c.enrollFlag != nil && *c.enrollFlag
unenroll = c.unenrollFlag != nil && *c.unenrollFlag
migrate = c.migrateFlag != nil && *c.migrateFlag
isUnenroll = c.unenrollFlag != nil
)
testConfig := &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
NeedsProgrammaticWindowsMDMEnrollment: enroll,
NeedsProgrammaticWindowsMDMUnenrollment: unenroll,
NeedsMDMMigration: migrate,
WindowsMDMDiscoveryEndpoint: c.discoveryURL,
}}
var enrollGotCalled, unenrollGotCalled bool
enrollReceiver := &windowsMDMEnrollmentConfigReceiver{
Frequency: time.Hour, // doesn't matter for this test
execEnrollFn: func(args WindowsMDMEnrollmentArgs) error {
enrollGotCalled = true
return c.apiErr
},
execUnenrollFn: func(args WindowsMDMEnrollmentArgs) error {
unenrollGotCalled = true
return c.apiErr
},
nodeKeyGetter: mockNodeKeyGetter{},
}
err := enrollReceiver.Run(testConfig)
require.NoError(t, err) // the dummy receiver never returns an error
if isUnenroll || migrate {
require.Equal(t, c.wantAPICalled, unenrollGotCalled)
require.False(t, enrollGotCalled)
} else {
require.Equal(t, c.wantAPICalled, enrollGotCalled)
require.False(t, unenrollGotCalled)
}
require.Contains(t, logBuf.String(), c.wantLog)
})
}
}
func TestWindowsMDMEnrollmentPrevented(t *testing.T) {
var logBuf bytes.Buffer
oldLog := log.Logger
log.Logger = log.Output(&logBuf)
t.Cleanup(func() { log.Logger = oldLog })
cfgs := []fleet.OrbitConfigNotifications{
{
NeedsProgrammaticWindowsMDMEnrollment: true,
WindowsMDMDiscoveryEndpoint: "http://example.com",
},
{
NeedsProgrammaticWindowsMDMUnenrollment: true,
},
}
for _, cfg := range cfgs {
t.Run(fmt.Sprintf("%+v", cfg), func(t *testing.T) {
testConfig := &fleet.OrbitConfig{Notifications: cfg}
var (
apiCallCount int
apiErr error
)
chProceed := make(chan struct{})
receiver := &windowsMDMEnrollmentConfigReceiver{
Frequency: 2 * time.Second, // just to be safe with slow environments (CI)
nodeKeyGetter: mockNodeKeyGetter{},
}
if cfg.NeedsProgrammaticWindowsMDMEnrollment {
receiver.execEnrollFn = func(args WindowsMDMEnrollmentArgs) error {
<-chProceed // will be unblocked only when allowed
apiCallCount++ // no need for sync, single-threaded call of this func is guaranteed by the receiver's mutex
return apiErr
}
receiver.execUnenrollFn = func(args WindowsMDMEnrollmentArgs) error {
panic("should not be called")
}
} else {
receiver.execUnenrollFn = func(args WindowsMDMEnrollmentArgs) error {
<-chProceed // will be unblocked only when allowed
apiCallCount++ // no need for sync, single-threaded call of this func is guaranteed by the receiver's mutex
return apiErr
}
receiver.execEnrollFn = func(args WindowsMDMEnrollmentArgs) error {
panic("should not be called")
}
}
go func() {
// the first call will block in enroll/unenroll func
err := receiver.Run(testConfig)
require.NoError(t, err)
}()
// wait a little bit to ensure the first `receiver.Run` call runs first.
time.Sleep(100 * time.Millisecond)
// this call will happen while the first call is blocked in
// enroll/unenrollfn, so it won't call the API (won't be able to lock the
// mutex). However it will still complete successfully without being
// blocked by the other call in progress.
err := receiver.Run(testConfig)
require.NoError(t, err)
// unblock the first call and wait for it to complete
close(chProceed)
time.Sleep(100 * time.Millisecond)
// this next call won't execute the command because of the frequency
// restriction (it got called less than N seconds ago)
err = receiver.Run(testConfig)
require.NoError(t, err)
// wait for the receiver's frequency to pass
time.Sleep(receiver.Frequency)
// this call executes the command, and it returns the Is Windows Server error
apiErr = errIsWindowsServer
err = receiver.Run(testConfig)
require.NoError(t, err)
// this next call won't execute the command (both due to frequency and the
// detection of windows server)
err = receiver.Run(testConfig)
require.NoError(t, err)
// wait for the receiver's frequency to pass
time.Sleep(receiver.Frequency)
// this next call still won't execute the command (due to the detection of
// windows server)
err = receiver.Run(testConfig)
require.NoError(t, err)
require.Equal(t, 2, apiCallCount) // the initial call and the one that returned errIsWindowsServer after first sleep
})
}
}
func TestRunScripts(t *testing.T) {
var logBuf bytes.Buffer
oldLog := log.Logger
log.Logger = log.Output(&logBuf)
t.Cleanup(func() { log.Logger = oldLog })
var (
callsCount atomic.Int64
runFailure error
blockRun chan struct{}
)
mockRun := func(r *scripts.Runner, ids []string) error {
callsCount.Add(1)
if blockRun != nil {
<-blockRun
}
return runFailure
}
waitForRun := func(t *testing.T, r *runScriptsConfigReceiver) {
var ok bool
for start := time.Now(); !ok && time.Since(start) < time.Second; {
ok = r.mu.TryLock()
}
require.True(t, ok, "timed out waiting for the lock to become available")
r.mu.Unlock()
}
t.Run("no pending scripts", func(t *testing.T) {
t.Cleanup(func() { callsCount.Store(0); logBuf.Reset() })
testConfig := &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
PendingScriptExecutionIDs: nil,
}}
runner := &runScriptsConfigReceiver{
runScriptsFn: mockRun,
}
err := runner.Run(testConfig)
require.NoError(t, err) // the dummy receiver never returns an error
// the lock should be available because no goroutine was started
require.True(t, runner.mu.TryLock())
require.Zero(t, callsCount.Load()) // no calls to execute scripts
require.Empty(t, logBuf.String()) // no logs written
})
t.Run("pending scripts succeed", func(t *testing.T) {
t.Cleanup(func() { callsCount.Store(0); logBuf.Reset() })
testConfig := &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
PendingScriptExecutionIDs: []string{"a", "b", "c"},
}}
runner := &runScriptsConfigReceiver{
runScriptsFn: mockRun,
}
err := runner.Run(testConfig)
require.NoError(t, err) // the dummy receiver never returns an error
waitForRun(t, runner)
require.Equal(t, int64(1), callsCount.Load()) // all scripts executed in a single run
require.Contains(t, logBuf.String(), "received notification to run scripts [a b c]")
require.Contains(t, logBuf.String(), "running scripts [a b c] succeeded")
})
t.Run("pending scripts failed", func(t *testing.T) {
t.Cleanup(func() { callsCount.Store(0); logBuf.Reset(); runFailure = nil })
testConfig := &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
PendingScriptExecutionIDs: []string{"a", "b", "c"},
}}
runFailure = io.ErrUnexpectedEOF
runner := &runScriptsConfigReceiver{
runScriptsFn: mockRun,
}
err := runner.Run(testConfig)
require.NoError(t, err) // the dummy receiver never returns an error
waitForRun(t, runner)
require.Equal(t, int64(1), callsCount.Load()) // all scripts executed in a single run
require.Contains(t, logBuf.String(), "received notification to run scripts [a b c]")
require.Contains(t, logBuf.String(), "running scripts failed")
require.Contains(t, logBuf.String(), io.ErrUnexpectedEOF.Error())
})
t.Run("concurrent run prevented", func(t *testing.T) {
t.Cleanup(func() { callsCount.Store(0); logBuf.Reset(); blockRun = nil })
testConfig := &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
PendingScriptExecutionIDs: []string{"a", "b", "c"},
}}
blockRun = make(chan struct{})
runner := &runScriptsConfigReceiver{
runScriptsFn: mockRun,
}
err := runner.Run(testConfig)
require.NoError(t, err) // the dummy receiver never returns an error
// call it again, while the previous run is still running
err = runner.Run(testConfig)
require.NoError(t, err) // the dummy receiver never returns an error
// unblock the initial run
close(blockRun)
waitForRun(t, runner)
require.Equal(t, int64(1), callsCount.Load()) // only called once because of mutex
require.Contains(t, logBuf.String(), "received notification to run scripts [a b c]")
require.Contains(t, logBuf.String(), "running scripts [a b c] succeeded")
})
t.Run("dynamic enabling of scripts", func(t *testing.T) {
t.Cleanup(logBuf.Reset)
testConfig := &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{
PendingScriptExecutionIDs: []string{"a"},
}}
var (
scriptsEnabledCalls []bool
dynamicEnabled atomic.Bool
dynamicInterval = 300 * time.Millisecond
)
runner := &runScriptsConfigReceiver{
ScriptsExecutionEnabled: false,
runScriptsFn: func(r *scripts.Runner, s []string) error {
scriptsEnabledCalls = append(scriptsEnabledCalls, r.ScriptExecutionEnabled)
return nil
},
testGetFleetdConfig: func() (*fleet.MDMAppleFleetdConfig, error) {
return &fleet.MDMAppleFleetdConfig{
EnableScripts: dynamicEnabled.Load(),
}, nil
},
dynamicScriptsEnabledCheckInterval: dynamicInterval,
}
// the static Scripts Enabled flag is false, so it relies on the dynamic check
runner.runDynamicScriptsEnabledCheck()
// first call, scripts are disabled
err := runner.Run(testConfig)
require.NoError(t, err) // the dummy receiver never returns an error
waitForRun(t, runner)
// swap scripts execution to true and wait to ensure the dynamic check
// did run.
dynamicEnabled.Store(true)
time.Sleep(dynamicInterval + 100*time.Millisecond)
// second call, scripts are enabled (change exec ID to "b")
testConfig.Notifications.PendingScriptExecutionIDs[0] = "b"
err = runner.Run(testConfig)
require.NoError(t, err) // the dummy receiver never returns an error
waitForRun(t, runner)
// swap scripts execution back to false and wait to ensure the dynamic
// check did run.
dynamicEnabled.Store(false)
time.Sleep(dynamicInterval + 100*time.Millisecond)
// third call, scripts are disabled (change exec ID to "c")
testConfig.Notifications.PendingScriptExecutionIDs[0] = "c"
err = runner.Run(testConfig)
require.NoError(t, err) // the dummy receiver never returns an error
waitForRun(t, runner)
// validate the Scripts Enabled flags that were passed to the runScriptsFn
require.Equal(t, []bool{false, true, false}, scriptsEnabledCalls)
require.Contains(t, logBuf.String(), "received notification to run scripts [a]")
require.Contains(t, logBuf.String(), "running scripts [a] succeeded")
require.Contains(t, logBuf.String(), "received notification to run scripts [b]")
require.Contains(t, logBuf.String(), "running scripts [b] succeeded")
require.Contains(t, logBuf.String(), "received notification to run scripts [c]")
require.Contains(t, logBuf.String(), "running scripts [c] succeeded")
})
}
type mockDiskEncryptionKeySetter struct {
SetOrUpdateDiskEncryptionKeyImpl func(diskEncryptionStatus fleet.OrbitHostDiskEncryptionKeyPayload) error
SetOrUpdateDiskEncryptionKeyInvoked bool
}
func (m *mockDiskEncryptionKeySetter) SetOrUpdateDiskEncryptionKey(diskEncryptionStatus fleet.OrbitHostDiskEncryptionKeyPayload) error {
m.SetOrUpdateDiskEncryptionKeyInvoked = true
return m.SetOrUpdateDiskEncryptionKeyImpl(diskEncryptionStatus)
}
func TestBitlockerOperations(t *testing.T) {
var logBuf bytes.Buffer
oldLog := log.Logger
log.Logger = log.Output(&logBuf)
t.Cleanup(func() { log.Logger = oldLog })
var (
shouldEncrypt = true
shouldFailEncryption = false
shouldFailServerUpdate = false
encryptFnCalled = false
)
clientMock := &mockDiskEncryptionKeySetter{}
clientMock.SetOrUpdateDiskEncryptionKeyImpl = func(diskEncryptionStatus fleet.OrbitHostDiskEncryptionKeyPayload) error {
if shouldFailServerUpdate {
return errors.New("server error")
}
return nil
}
var rotateKeyFnCalled bool
var shouldFailKeyRotation bool
var enrollReceiver *windowsMDMBitlockerConfigReceiver
setupTest := func() {
enrollReceiver = &windowsMDMBitlockerConfigReceiver{
Frequency: time.Hour, // doesn't matter for this test
lastRun: time.Now().Add(-2 * time.Hour),
EncryptionResult: clientMock,
execGetEncryptionStatusFn: func() ([]bitlocker.VolumeStatus, error) {
return []bitlocker.VolumeStatus{}, nil
},
execEncryptVolumeFn: func(string) (string, error) {
encryptFnCalled = true
if shouldFailEncryption {
return "", errors.New("error encrypting")
}
return "123456", nil
},
execRotateRecoveryKeyFn: func(string) (string, error) {
rotateKeyFnCalled = true
if shouldFailKeyRotation {
return "", errors.New("error rotating key")
}
return "rotated-key-789", nil
},
}
shouldEncrypt = true
shouldFailEncryption = false
shouldFailKeyRotation = false
shouldFailServerUpdate = false
encryptFnCalled = false
rotateKeyFnCalled = false
clientMock.SetOrUpdateDiskEncryptionKeyInvoked = false
logBuf.Reset()
}
makeConfig := func() *fleet.OrbitConfig {
return &fleet.OrbitConfig{
Notifications: fleet.OrbitConfigNotifications{
EnforceBitLockerEncryption: shouldEncrypt,
},
}
}
t.Run("bitlocker encryption is performed", func(t *testing.T) {
setupTest()
// shouldEncrypt defaults to true from setupTest
err := enrollReceiver.Run(makeConfig())
require.NoError(t, err) // the dummy receiver never returns an error
})
t.Run("bitlocker encryption is not performed when not enforced", func(t *testing.T) {
setupTest()
shouldEncrypt = false
err := enrollReceiver.Run(makeConfig())
require.NoError(t, err)
require.False(t, encryptFnCalled, "encryption function should not be called when not enforced")
require.False(t, rotateKeyFnCalled, "rotate key function should not be called when not enforced")
})
t.Run("bitlocker encryption returns an error", func(t *testing.T) {
setupTest()
shouldFailEncryption = true
err := enrollReceiver.Run(makeConfig())
require.NoError(t, err) // the dummy receiver never returns an error
require.True(t, encryptFnCalled, "encryption function should have been called")
})
t.Run("encryption skipped based on various current statuses", func(t *testing.T) {
setupTest()
statusesToTest := []int32{
bitlocker.ConversionStatusDecryptionInProgress,
bitlocker.ConversionStatusDecryptionPaused,
bitlocker.ConversionStatusEncryptionInProgress,
bitlocker.ConversionStatusEncryptionPaused,
}
for _, status := range statusesToTest {
t.Run(fmt.Sprintf("status %d", status), func(t *testing.T) {
mockStatus := &bitlocker.EncryptionStatus{ConversionStatus: status}
enrollReceiver.execGetEncryptionStatusFn = func() ([]bitlocker.VolumeStatus, error) {
return []bitlocker.VolumeStatus{{DriveVolume: "C:", Status: mockStatus}}, nil
}
err := enrollReceiver.Run(makeConfig())
require.NoError(t, err)
require.Contains(t, logBuf.String(), "skipping encryption as the disk is not available")
require.False(t, encryptFnCalled, "encryption function should not be called")
logBuf.Reset() // Reset the log buffer for the next iteration
})
}
})
t.Run("handle misreported decryption error", func(t *testing.T) {
setupTest()
mockStatus := &bitlocker.EncryptionStatus{ConversionStatus: bitlocker.ConversionStatusFullyDecrypted}
enrollReceiver.execGetEncryptionStatusFn = func() ([]bitlocker.VolumeStatus, error) {
return []bitlocker.VolumeStatus{{DriveVolume: "C:", Status: mockStatus}}, nil
}
enrollReceiver.execEncryptVolumeFn = func(string) (string, error) {
return "", bitlocker.NewEncryptionError("", bitlocker.ErrorCodeNotDecrypted)
}
err := enrollReceiver.Run(makeConfig())
require.NoError(t, err)
require.Contains(t, logBuf.String(), "disk encryption failed due to previous unsuccessful attempt, user action required")
require.False(t, encryptFnCalled, "encryption function should not be called")
})
t.Run("rotates recovery key if disk already encrypted", func(t *testing.T) {
setupTest()
mockStatus := &bitlocker.EncryptionStatus{ConversionStatus: bitlocker.ConversionStatusFullyEncrypted}
enrollReceiver.execGetEncryptionStatusFn = func() ([]bitlocker.VolumeStatus, error) {
return []bitlocker.VolumeStatus{{DriveVolume: "C:", Status: mockStatus}}, nil
}
err := enrollReceiver.Run(makeConfig())
require.NoError(t, err)
require.Contains(t, logBuf.String(), "disk is already encrypted, rotating recovery key")
require.True(t, clientMock.SetOrUpdateDiskEncryptionKeyInvoked, "should escrow the rotated key")
require.True(t, rotateKeyFnCalled, "rotate key function should have been called")
require.False(t, encryptFnCalled, "encryption function should not be called")
})
t.Run("reports to the server if key rotation fails", func(t *testing.T) {
setupTest()
shouldFailKeyRotation = true
mockStatus := &bitlocker.EncryptionStatus{ConversionStatus: bitlocker.ConversionStatusFullyEncrypted}
enrollReceiver.execGetEncryptionStatusFn = func() ([]bitlocker.VolumeStatus, error) {
return []bitlocker.VolumeStatus{{DriveVolume: "C:", Status: mockStatus}}, nil
}
err := enrollReceiver.Run(makeConfig())
require.NoError(t, err)
require.Contains(t, logBuf.String(), "disk is already encrypted, rotating recovery key")
require.Contains(t, logBuf.String(), "recovery key rotation failed")
require.True(t, clientMock.SetOrUpdateDiskEncryptionKeyInvoked)
require.True(t, rotateKeyFnCalled, "rotate key function should have been called")
require.False(t, encryptFnCalled, "encryption function should not be called")
})
t.Run("encryption skipped if last run too recent", func(t *testing.T) {
setupTest()
enrollReceiver.lastRun = time.Now().Add(-30 * time.Minute)
enrollReceiver.Frequency = 1 * time.Hour
err := enrollReceiver.Run(makeConfig())
require.NoError(t, err)
require.Contains(t, logBuf.String(), "skipped encryption process, last run was too recent")
require.False(t, encryptFnCalled, "encryption function should not be called")
})
t.Run("successful fleet server update", func(t *testing.T) {
setupTest()
shouldFailEncryption = false
mockStatus := &bitlocker.EncryptionStatus{ConversionStatus: bitlocker.ConversionStatusFullyDecrypted}
enrollReceiver.execGetEncryptionStatusFn = func() ([]bitlocker.VolumeStatus, error) {
return []bitlocker.VolumeStatus{{DriveVolume: "C:", Status: mockStatus}}, nil
}
err := enrollReceiver.Run(makeConfig())
require.NoError(t, err)
require.True(t, clientMock.SetOrUpdateDiskEncryptionKeyInvoked)
require.True(t, encryptFnCalled, "encryption function should have been called")
})
t.Run("failed fleet server update", func(t *testing.T) {
setupTest()
shouldFailEncryption = false
shouldFailServerUpdate = true
mockStatus := &bitlocker.EncryptionStatus{ConversionStatus: bitlocker.ConversionStatusFullyDecrypted}
enrollReceiver.execGetEncryptionStatusFn = func() ([]bitlocker.VolumeStatus, error) {
return []bitlocker.VolumeStatus{{DriveVolume: "C:", Status: mockStatus}}, nil
}
err := enrollReceiver.Run(makeConfig())
require.NoError(t, err)
require.Contains(t, logBuf.String(), "failed to send encryption result to Fleet Server")
require.True(t, clientMock.SetOrUpdateDiskEncryptionKeyInvoked)
require.True(t, encryptFnCalled, "encryption function should have been called")
})
t.Run("failed escrow caches key for retry", func(t *testing.T) {
setupTest()
shouldFailServerUpdate = true
lastRunBefore := enrollReceiver.lastRun
mockStatus := &bitlocker.EncryptionStatus{ConversionStatus: bitlocker.ConversionStatusFullyEncrypted}
enrollReceiver.execGetEncryptionStatusFn = func() ([]bitlocker.VolumeStatus, error) {
return []bitlocker.VolumeStatus{{DriveVolume: "C:", Status: mockStatus}}, nil
}
// First run: rotation succeeds but escrow fails, key should be cached
err := enrollReceiver.Run(makeConfig())
require.NoError(t, err)
require.True(t, rotateKeyFnCalled, "rotate key function should have been called")
require.Equal(t, "rotated-key-789", enrollReceiver.pendingRecoveryKey, "key should be cached after failed escrow")
require.Equal(t, lastRunBefore, enrollReceiver.lastRun, "lastRun should not advance when escrow fails")
})
t.Run("cached key retried without re-rotating", func(t *testing.T) {
setupTest()
shouldFailServerUpdate = true
mockStatus := &bitlocker.EncryptionStatus{ConversionStatus: bitlocker.ConversionStatusFullyEncrypted}
enrollReceiver.execGetEncryptionStatusFn = func() ([]bitlocker.VolumeStatus, error) {
return []bitlocker.VolumeStatus{{DriveVolume: "C:", Status: mockStatus}}, nil
}
// First run: rotation succeeds, escrow fails, key cached
err := enrollReceiver.Run(makeConfig())
require.NoError(t, err)
require.True(t, rotateKeyFnCalled)
require.Equal(t, "rotated-key-789", enrollReceiver.pendingRecoveryKey)
// Second run: escrow succeeds, key cleared, no re-rotation
rotateKeyFnCalled = false
encryptFnCalled = false
shouldFailServerUpdate = false
logBuf.Reset()
err = enrollReceiver.Run(makeConfig())
require.NoError(t, err)
require.Contains(t, logBuf.String(), "retrying escrow of previously rotated recovery key")
require.False(t, rotateKeyFnCalled, "should NOT rotate again")
require.False(t, encryptFnCalled, "should NOT encrypt again")
require.Empty(t, enrollReceiver.pendingRecoveryKey, "cached key should be cleared after successful escrow")
require.False(t, enrollReceiver.lastRun.IsZero(), "lastRun should be set after successful escrow")
})
}
func TestWindowsMDMSync(t *testing.T) {
var logBuf bytes.Buffer
oldLog := log.Logger
log.Logger = log.Output(&logBuf)
t.Cleanup(func() { log.Logger = oldLog })
syncCfg := func(req bool) *fleet.OrbitConfig {
return &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{WindowsMDMSyncRequest: req}}
}
// newCounting builds a receiver whose sync increments calls and returns err: the common shape for most subtests below.
newCounting := func(freq time.Duration, err error) (*windowsMDMSyncConfigReceiver, *atomic.Int32) {
var calls atomic.Int32
r := &windowsMDMSyncConfigReceiver{Frequency: freq, execSyncFn: func() error { calls.Add(1); return err }}
return r, &calls
}
// The sync runs in a background goroutine that holds w.mu until it finishes. waitIdle blocks until that goroutine has released the
// lock, so afterwards the test can read the call counter, lastRun, and the shared log buffer without racing the goroutine's writes.
// Subtests must stay sequential (no t.Parallel): they share logBuf and the global log.Logger.
waitIdle := func(t *testing.T, r *windowsMDMSyncConfigReceiver) {
t.Helper()
require.Eventually(t, func() bool {
if r.mu.TryLock() {
r.mu.Unlock()
return true
}
return false
}, 2*time.Second, time.Millisecond)
}
// runSync delivers cfg and blocks until any spawned sync goroutine has finished.
runSync := func(t *testing.T, r *windowsMDMSyncConfigReceiver, req bool) {
t.Helper()
require.NoError(t, r.Run(syncCfg(req)))
waitIdle(t, r)
}
t.Run("no sync request does not trigger", func(t *testing.T) {
logBuf.Reset()
r, calls := newCounting(time.Hour, nil)
runSync(t, r, false)
require.Equal(t, int32(0), calls.Load())
})
t.Run("sync request triggers once and records the run", func(t *testing.T) {
logBuf.Reset()
r, calls := newCounting(time.Hour, nil)
runSync(t, r, true)
require.Equal(t, int32(1), calls.Load())
require.False(t, r.lastRun.IsZero(), "a successful sync must record lastRun")
})
t.Run("second run within frequency is throttled", func(t *testing.T) {
logBuf.Reset()
r, calls := newCounting(time.Hour, nil)
runSync(t, r, true)
runSync(t, r, true)
require.Equal(t, int32(1), calls.Load(), "second run within Frequency should be throttled")
})
t.Run("not throttled once frequency elapsed", func(t *testing.T) {
logBuf.Reset()
r, calls := newCounting(0, nil)
runSync(t, r, true)
time.Sleep(time.Millisecond)
runSync(t, r, true)
require.Equal(t, int32(2), calls.Load())
})
t.Run("sync already in flight is dropped", func(t *testing.T) {
logBuf.Reset()
var calls atomic.Int32
release, started := make(chan struct{}), make(chan struct{})
r := &windowsMDMSyncConfigReceiver{Frequency: 0, execSyncFn: func() error {
calls.Add(1)
close(started)
<-release // hold w.mu so a concurrent Run observes TryLock failing
return nil
}}
require.NoError(t, r.Run(syncCfg(true))) // returns immediately; the sync goroutine now holds w.mu
<-started
require.NoError(t, r.Run(syncCfg(true))) // a sync is in flight, so this attempt must be dropped, not queued
require.Equal(t, int32(1), calls.Load(), "a second sync must not start while one is in flight")
close(release)
waitIdle(t, r)
require.Equal(t, int32(1), calls.Load())
})
t.Run("failure does not set lastRun and retries on next run", func(t *testing.T) {
logBuf.Reset()
r, calls := newCounting(time.Hour, io.ErrUnexpectedEOF)
runSync(t, r, true)
runSync(t, r, true)
require.Equal(t, int32(2), calls.Load(), "failed sync should not be throttled on the next run")
require.True(t, r.lastRun.IsZero(), "lastRun must remain unset after failures")
require.Contains(t, logBuf.String(), "triggering on-demand Windows MDM sync failed")
})
}