fleet/orbit/pkg/useraction/mdm_migration_darwin_test.go
Magnus Jensen a187842260
always send webhook while device is unmanaged for MDM migration (#39416)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #38322 

This PR utilizes the ping/status ticker that sees if the device is
Unmanaged (aka. not enrolled from a Fleet server perspective), if the
Migrate to Fleet flow before had set the `mdm_migration.txt` file, but
somehow not successfully unenrolled the device, we now keep sending it
if you trigger the modal again.

We wait 90seconds after start, so at most the user can go through the
flow every 90s, but the server has a hard limit on at most one webhook
every 3m, but still it means the user can wait a bit and retry and still
see the webhook gets sent now.

_PS: Updated the old migration test to go from 1,5m to ~2s execution
time with parallel and configurable waitForUnenrollment time (to allow
test to set lower values)

# 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.


## Testing

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

## 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))

---------

Co-authored-by: Jordan Montgomery <elijah.jordan.montgomery@gmail.com>
2026-02-09 14:08:54 -05:00

264 lines
7 KiB
Go

package useraction
import (
"errors"
"testing"
"time"
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
"github.com/stretchr/testify/require"
)
// mockDialog is a mock implementation of the dialog interface for testing.
type mockDialog struct {
exitCh chan int // exit code
}
func (d *mockDialog) CanRun() bool {
return true
}
func (d *mockDialog) Exit() {
select {
case d.exitCh <- unknownExitCode:
default:
}
}
func (d *mockDialog) exitWithCode(code int) {
select {
case d.exitCh <- code:
default:
}
}
func (d *mockDialog) render(flags ...string) (chan swiftDialogExitCode, chan error) {
exitCodeCh := make(chan swiftDialogExitCode, 1)
errCh := make(chan error, 1)
go func() {
select {
case code := <-d.exitCh:
exitCodeCh <- swiftDialogExitCode(code)
case <-time.After(15 * time.Second):
errCh <- errors.New("timeout waiting for mock dialog to exit")
}
}()
return exitCodeCh, errCh
}
// mockReadWriter is a mock implementation of the readWriter interface for testing.
type mockReadWriter struct {
migrationType string
}
func (rw *mockReadWriter) GetMigrationType() (string, error) {
return rw.migrationType, nil
}
func (rw *mockReadWriter) SetMigrationFile(typ string) error {
rw.migrationType = typ
return nil
}
func (rw *mockReadWriter) RemoveFile() error {
rw.migrationType = ""
return nil
}
type dummyHandler struct {
TimeCalled int
}
func (d *dummyHandler) NotifyRemote() error {
d.TimeCalled++
return nil
}
func (d dummyHandler) ShowInstructions() error { return nil }
func TestWaitForUnenrollment(t *testing.T) {
getMigratorInstance := func() *swiftDialogMDMMigrator {
return &swiftDialogMDMMigrator{
handler: &dummyHandler{},
baseDialog: newBaseDialog("foo/bar"),
frequency: 15 * time.Minute,
unenrollmentRetryInterval: 1 * time.Millisecond,
maxUnenrollmentWaitTime: 1 * time.Second,
}
}
cases := []struct {
name string
enrollErr error
unenrollAfterNTries int
wantErr bool
}{
{"unenroll after 3 tries", nil, 3, false},
{"unenroll after one try", nil, 1, false},
{"error after max number of tries is exceeded", nil, 1000, true},
{"always error calling profiles func", errors.New("test"), 1, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
m := getMigratorInstance()
tries := 0
m.testEnrollmentCheckFileFn = func() (bool, error) {
if tries >= c.unenrollAfterNTries {
return false, c.enrollErr
}
tries++
return true, c.enrollErr
}
m.testEnrollmentCheckStatusFn = func() (bool, string, error) {
return true, "example.com", nil
}
outErr := m.waitForUnenrollment(true)
if c.wantErr {
require.Error(t, outErr)
} else {
require.NoError(t, outErr)
require.Equal(t, c.unenrollAfterNTries, tries)
}
})
}
t.Run("fallback to enrollment check file", func(t *testing.T) {
t.Parallel()
m := getMigratorInstance()
m.testEnrollmentCheckFileFn = func() (bool, error) {
return true, nil
}
m.testEnrollmentCheckStatusFn = func() (bool, string, error) {
return false, "", nil
}
outErr := m.waitForUnenrollment(true)
require.NoError(t, outErr)
})
t.Run("only check file during ADE enrollment", func(t *testing.T) {
t.Parallel()
m := getMigratorInstance()
var fileWasChecked bool
m.testEnrollmentCheckFileFn = func() (bool, error) {
fileWasChecked = true
return true, nil
}
m.testEnrollmentCheckStatusFn = func() (bool, string, error) {
return false, "", nil
}
err := m.waitForUnenrollment(false)
require.NoError(t, err)
require.False(t, fileWasChecked)
err = m.waitForUnenrollment(true)
require.NoError(t, err)
require.True(t, fileWasChecked)
})
}
func TestShouldSendWebhookUntilUnmanaged(t *testing.T) {
for _, typ := range []string{constant.MDMMigrationTypeADE, constant.MDMMigrationTypeManual, constant.MDMMigrationTypePreSonoma} {
t.Run(typ, func(t *testing.T) {
t.Parallel()
handler := &dummyHandler{}
mockDialog := &mockDialog{exitCh: make(chan int, 10)}
m := &swiftDialogMDMMigrator{
handler: handler,
mrw: &mockReadWriter{},
baseDialog: mockDialog,
frequency: 15 * time.Minute,
unenrollmentRetryInterval: 50 * time.Millisecond,
maxUnenrollmentWaitTime: 100 * time.Millisecond,
props: MDMMigratorProps{
IsUnmanaged: false,
},
}
// Set up enrollment check functions - device stays enrolled throughout
m.testEnrollmentCheckFileFn = func() (bool, error) {
return true, nil // Always enrolled (file exists)
}
m.testEnrollmentCheckStatusFn = func() (bool, string, error) {
return true, "example.com", nil // Always enrolled
}
// First migration attempt - should call webhook and see device never unenrolls for unenrollment
mockDialog.exitWithCode(0) // Start button clicked
mockDialog.exitWithCode(0) // Error ok? clicked
err := m.renderMigration()
// Should get host is still enrolled error
require.Error(t, err)
require.Contains(t, err.Error(), "host didn't unenroll from MDM") // This is okay
require.Equal(t, 1, handler.TimeCalled)
// We fake the migration file being set even though an error returned to simulate this weird state
err = m.mrw.SetMigrationFile(typ)
require.NoError(t, err)
// Second migration attempt - device is still managed, should call webhook again
mockDialog.exitWithCode(0)
mockDialog.exitWithCode(0)
err = m.renderMigration()
// Should still get not unenrolled error
require.Error(t, err)
require.Contains(t, err.Error(), "host didn't unenroll from MDM")
require.Equal(t, 2, handler.TimeCalled) // webhook was still called
// Now we let it unenroll the device, and then simulate the ping for IsUnmanaged
fileTries := 0
statusTries := 0
m.testEnrollmentCheckFileFn = func() (bool, error) {
fileTries++
if fileTries > 1 { // Unenroll after 2nd try
return false, nil
}
return true, nil
}
m.testEnrollmentCheckStatusFn = func() (bool, string, error) {
statusTries++
if statusTries > 1 {
return false, "", nil // Not enrolled
}
return true, "example.com", nil
}
go func() {
// start button click
time.Sleep(10 * time.Millisecond)
mockDialog.exitWithCode(0)
// There is a loading spinner that takes over the exit call, so we need to call it ourselves again.
time.Sleep(100 * time.Millisecond)
mockDialog.Exit()
}()
err = m.renderMigration()
// Now it successfully unenrolls
require.NoError(t, err)
require.Equal(t, 3, handler.TimeCalled) // webhook was called again.
// Device is now seen as unmanaged by Fleet server
m.props.IsUnmanaged = true
// This simulates our runner that periodically shows the window - should NOT call webhook since device is unmanaged, it will hit the early exit
mockDialog.exitWithCode(0)
err = m.renderMigration()
// Should succeed without error
require.NoError(t, err)
require.Equal(t, 3, handler.TimeCalled) // webhook was not called
})
}
}