Files
fleet/server/service/microsoft_mdm_property_test.go
Victor Lyuboslavsky e14ef67b55 Fix Windows Autopilot ESP hang: gate release on user-scope ack (#49134) (#49542)
The ESP release wrote the user-scope ServerHasFinishedProvisioning
Replace and immediately committed awaiting_configuration=None. During
OOBE the device rejects user-scope writes with SyncML 405 until its user
MDM context initializes, so the Account setup phase never received its
completion signal and the device hung until the 3-hour timeout, while
Fleet believed the ESP had completed (and relaxed the DMClient poll,
crippling remediation).

The release path now stays Active until the device acks the user-scope
Replace with a 200: a new resend phase re-sends the Replace once per
session (bounded by the existing 3-hour timeout), and the Active->None
transition commits only on the 200.

Live-validated on Win11 26200 on both a fresh and a re-enrolled device:
the 405 reproduced at release time in both flows, and the retried
Replace acked 200 one session after the user context came up, releasing
the ESP.

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #49134

# 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] QA'd all new/changed functionality manually

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

## Bug Fixes
- Fixed an issue where Windows Autopilot enrollment could intermittently
hang at **“Account setup”** on the Enrollment Status Page.
- Updated Enrollment Status Page release handling so enrollment **stays
active until the device acknowledges** the user-scope completion
command.
- When the user-scope completion is rejected or still unacknowledged, it
is **retried in subsequent management sessions** until successfully
acknowledged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-21 09:03:54 -05:00

439 lines
21 KiB
Go

package service
import (
"context"
"fmt"
"log/slog"
"slices"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
)
// Property-based tests for handleESPRelease. They cover the wait-gate decision and the universal block/warn/release
// command-shape invariants in a single combined property check.
//
// The spec function pbtESPSpec computes the expected (decision, observedHasFailure) from the inputs without
// referencing the production code. We then run getESPCommands against a mock datastore and assert:
//
// - Decision (wait/block/warn/release) matches the spec.
// - Wait → no side effects (no cancel, no persist, no CAS).
// - Block-flavored command shape (hard block AND warn/soft block): AllowCollectLogsButton,
// TimeOutUntilSyncFailure=1, NO ServerHasFinishedProvisioning, NO InstallationState. They differ in
// BlockInStatusPage (block: 1 = Reset PC; warn: 5 = Reset PC + Continue Anyway) and CustomErrorText
// (block: reason-specific static text; warn: dynamic failed-software list).
// - Release path command shape: Device-scope AND User-scope ServerHasFinishedProvisioning plus
// PolicyProviders InstallationState=3; NO CustomErrorText, NO BlockInStatusPage. The user-scope write is
// rejected with 405 until the device's user MDM context initializes, so the release path does NOT CAS to
// None: the enrollment stays Active and the Replace is re-sent until a 200 ack (handleESPUserReleaseRetry).
// - Persisted CommandUUIDs equal inline CmdID.Value (the ack-clearing invariant).
// - Persist runs as a single batched call (a regression that loops single inserts would split CustomErrorText
// and the block flags across multiple TX boundaries).
// - Cancel block fires iff (timedOut || (observedHasFailure && requireAll)); when it fires,
// CancelHostUpcomingActivity is called once per Pending/Running row in input. Cancel-upcoming runs strictly
// before cancel-status; both run strictly before persist; persist runs strictly before CAS on the block
// paths (the release path defers the CAS to the ack). The warn path never cancels: all items already
// reached a terminal state and the user may continue.
//
// Order independence is implicit: pbtESPSpec is a pure function of the multiset of statuses (no positional
// dependency) and rapid samples many orderings, so any introduced order-dependence in production code
// surfaces as a spec mismatch.
//
// Run with more checks:
// go test -run TestPBT_HandleESPRelease ./server/service/ -args -rapid.checks=2000
var pbtESPLogger = slog.New(slog.DiscardHandler)
const (
pbtESPDeviceID = "pbt-esp-device-id"
pbtESPHostUUID = "pbt-esp-host-uuid"
)
// pbtESPTrace captures observable side effects of handleESPRelease so the property can assert ordering and
// counts without inspecting the auto-set FuncInvoked flags individually.
type pbtESPTrace struct {
cancelUpcomingExecIDs []string // execution IDs passed to CancelHostUpcomingActivity, in call order
persistedCmdUUIDs []string
callOrder []string // sequence of "cancel-upcoming", "cancel-status", "persist", "cas"
}
// newPBTESPSvc wires a mock datastore for property-testing handleESPRelease. Stages 1 and 2 (profiles) are
// mocked empty so the property focuses on Stage 3 + finalize. Profile-stage logic is covered by the
// example-based subtests in TestGetESPCommands.
func newPBTESPSvc(
statuses []fleet.SetupExperienceStatusResultStatus, timedOut, requireAll bool,
) (*Service, *fleet.MDMWindowsEnrolledDevice, *pbtESPTrace) {
ds := new(mock.Store)
trace := &pbtESPTrace{}
osqueryHostID := "pbt-esp-osq"
ds.HostLiteByIdentifierFunc = func(ctx context.Context, id string) (*fleet.HostLite, error) {
return &fleet.HostLite{ID: 1, UUID: id, OsqueryHostID: &osqueryHostID, TeamID: nil}, nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
ac := &fleet.AppConfig{}
ac.MDM.WindowsEnabledAndConfigured = true
ac.MDM.MacOSSetup.RequireAllSoftwareWindows = requireAll
return ac, nil
}
// Profiles are out of PBT scope.
ds.GetWindowsMDMHostForReconcileFunc = func(ctx context.Context, hUUID string) (*fleet.WindowsHostReconcileInfo, error) {
return &fleet.WindowsHostReconcileInfo{HostID: 1, UUID: hUUID}, nil
}
ds.ListWindowsProfilesForReconcileByTeamFunc = func(ctx context.Context, teamID uint) ([]*fleet.WindowsProfileForReconcile, error) {
return nil, nil
}
ds.BulkGetHostLabelMembershipsFunc = func(ctx context.Context, hostIDs, labelIDs []uint) (map[uint]map[uint]struct{}, error) {
return nil, nil
}
ds.BulkGetHostMDMWindowsProfilesByUUIDsFunc = func(ctx context.Context, hostUUIDs []string) (map[string][]*fleet.MDMWindowsProfilePayload, error) {
return nil, nil
}
ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hUUID string) ([]fleet.HostMDMWindowsProfile, error) {
return nil, nil
}
// Stage 3 results plus the cancel-block re-list both go through this single mock so they're consistent.
// Each row gets a HostSoftwareInstallsExecutionID so the cancel-upcoming loop has something to cancel on
// non-terminal rows.
results := make([]*fleet.SetupExperienceStatusResult, 0, len(statuses))
for i, s := range statuses {
execID := fmt.Sprintf("pbt-exec-%d", i)
results = append(results, &fleet.SetupExperienceStatusResult{
Name: fmt.Sprintf("item-%d", i),
Status: s,
HostSoftwareInstallsExecutionID: &execID,
})
}
ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) {
return results, nil
}
// The empty-results disambiguation case (empty results + has_items=true → wait) is covered by
// "active waits when results empty but setup experience configured" in TestGetESPCommands; here we always
// say "no items configured" so empty input proceeds to finalize cleanly.
ds.HasWindowsSetupExperienceItemsForTeamFunc = func(ctx context.Context, teamID uint) (bool, error) {
return false, nil
}
// Side-effect hooks. callOrder lets the property assert the cancel-upcoming → cancel-status → persist →
// cas ordering safely.
ds.CancelHostUpcomingActivityFunc = func(ctx context.Context, hostID uint, executionID string) (fleet.ActivityDetails, error) {
trace.cancelUpcomingExecIDs = append(trace.cancelUpcomingExecIDs, executionID)
trace.callOrder = append(trace.callOrder, "cancel-upcoming")
return nil, nil
}
ds.CancelPendingSetupExperienceStepsFunc = func(ctx context.Context, hUUID string) error {
trace.callOrder = append(trace.callOrder, "cancel-status")
return nil
}
ds.MDMWindowsInsertCommandsForHostFunc = func(ctx context.Context, hostUUIDOrDeviceID string, cmds []*fleet.MDMWindowsCommand) error {
trace.callOrder = append(trace.callOrder, "persist")
for _, c := range cmds {
trace.persistedCmdUUIDs = append(trace.persistedCmdUUIDs, c.CommandUUID)
}
return nil
}
ds.SetMDMWindowsAwaitingConfigurationFunc = func(ctx context.Context, mdmDeviceID string, from, to fleet.WindowsMDMAwaitingConfiguration) (bool, error) {
trace.callOrder = append(trace.callOrder, "cas")
return true, nil
}
ds.MDMWindowsGetESPReleaseAckStatusFunc = func(ctx context.Context, enrollmentID uint, targetLocURI, cmdUUIDPrefix string) (*fleet.MDMWindowsESPReleaseAckStatus, error) {
return &fleet.MDMWindowsESPReleaseAckStatus{}, nil
}
svc := &Service{ds: ds, logger: pbtESPLogger}
svc.SetActivityService(&mock.MockActivityService{})
device := &fleet.MDMWindowsEnrolledDevice{
MDMDeviceID: pbtESPDeviceID,
HostUUID: pbtESPHostUUID,
AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationActive,
}
if timedOut {
past := time.Now().Add(-4 * time.Hour)
device.AwaitingConfigurationAt = &past
}
return svc, device, trace
}
type pbtESPDecision string
const (
pbtESPWait pbtESPDecision = "wait"
// pbtESPBlock is the hard block: failure UI with Reset PC only, remaining items cancelled.
pbtESPBlock pbtESPDecision = "block"
// pbtESPWarn is the soft block: failure UI listing failed software with a Continue Anyway option, no cancellation.
pbtESPWarn pbtESPDecision = "warn"
pbtESPRelease pbtESPDecision = "release"
)
// pbtESPSpec computes the expected outcome from the inputs without referencing production code. It returns the
// decision and the hasSoftwareFailure that production would observe.
//
// observedHasFailure equals "the inputs contain a Failure row" on every path except one: a require_all=true timeout.
// When the device times out, production finalizes immediately instead of examining each setup-experience result. The
// require_all=false timeout path still examines the results so it can list the failed software, so observedHasFailure
// reflects the inputs there. The require_all=true timeout path does not re-examine them (a real failure would already
// have hard-blocked on an earlier check-in), so observedHasFailure stays false there even when a failure is present.
func pbtESPSpec(
statuses []fleet.SetupExperienceStatusResultStatus, timedOut, requireAll bool,
) (decision pbtESPDecision, observedHasFailure bool) {
var inputAnyInFlight, inputHasFailure bool
for _, s := range statuses {
switch s {
case fleet.SetupExperienceStatusFailure:
inputHasFailure = true
case fleet.SetupExperienceStatusPending, fleet.SetupExperienceStatusRunning:
inputAnyInFlight = true
}
}
if timedOut {
// Wait gates skipped; finalize directly. require_all=true is not re-scanned for failures (it would have
// hard-blocked earlier), so observedHasFailure stays false and it hard-blocks with the timeout text.
if requireAll {
return pbtESPBlock, false
}
// require_all=false is scanned: it soft-blocks either way (lists failures if any, else timeout text).
// observedHasFailure reflects whether the scan saw a Failure row.
return pbtESPWarn, inputHasFailure
}
// Not timed out: production examined every setup-experience result, so observedHasFailure = inputHasFailure.
if inputAnyInFlight {
if !inputHasFailure {
return pbtESPWait, inputHasFailure
}
if !requireAll {
return pbtESPWait, inputHasFailure
}
return pbtESPBlock, inputHasFailure // short-circuit: failure + require_all + in-flight siblings -> block now
}
if inputHasFailure {
if requireAll {
return pbtESPBlock, inputHasFailure
}
// Software failed but require_all=false: soft block. The user sees the failure UI listing the failed
// software but may continue to the desktop.
return pbtESPWarn, inputHasFailure
}
return pbtESPRelease, inputHasFailure
}
// pbtFindCmdByLocURI returns the first command whose target LocURI contains the given substring.
func pbtFindCmdByLocURI(cmds []*fleet.SyncMLCmd, substr string) *fleet.SyncMLCmd {
for _, c := range cmds {
if c.GetTargetURI() != "" && strings.Contains(c.GetTargetURI(), substr) {
return c
}
}
return nil
}
// TestPBT_HandleESPRelease tests releases due to software failures or timeout. It does not test the happy path (which requires an Ack from device). The happy path is covered by other tests.
func TestPBT_HandleESPRelease(t *testing.T) {
statusGen := rapid.SampledFrom([]fleet.SetupExperienceStatusResultStatus{
fleet.SetupExperienceStatusPending,
fleet.SetupExperienceStatusRunning,
fleet.SetupExperienceStatusSuccess,
fleet.SetupExperienceStatusFailure,
fleet.SetupExperienceStatusCancelled,
})
rapid.Check(t, func(rt *rapid.T) {
statuses := rapid.SliceOfN(statusGen, 0, 8).Draw(rt, "statuses")
timedOut := rapid.Bool().Draw(rt, "timedOut")
requireAll := rapid.Bool().Draw(rt, "requireAll")
expected, observedHasFailure := pbtESPSpec(statuses, timedOut, requireAll)
// expectedFailedNames mirrors the mock fixture naming (row i is "item-i") for the Failure rows, in input
// order. Only the warn path consumes it (dynamic CustomErrorText).
var expectedFailedNames []string
for i, s := range statuses {
if s == fleet.SetupExperienceStatusFailure {
expectedFailedNames = append(expectedFailedNames, fmt.Sprintf("item-%d", i))
}
}
svc, device, trace := newPBTESPSvc(statuses, timedOut, requireAll)
cmds, err := svc.getESPCommands(t.Context(), device, nil)
require.NoErrorf(rt, err, "statuses=%v timedOut=%v requireAll=%v", statuses, timedOut, requireAll)
if expected == pbtESPWait {
require.Nilf(rt, cmds, "expected wait, got cmds=%+v", cmds)
require.Emptyf(rt, trace.callOrder, "wait must not produce side effects; got %v", trace.callOrder)
return
}
// Block or release: must produce non-empty cmds plus a finalized side-effect sequence.
require.NotEmptyf(rt, cmds, "expected %s, got nil/empty", expected)
// Persisted CommandUUIDs equal inline CmdID.Value, 1-to-1. Without this, the device's ack of the
// inline command does not clear the backup row and the server re-sends every subsequent session.
inlineCmdUUIDs := make([]string, 0, len(cmds))
for _, c := range cmds {
inlineCmdUUIDs = append(inlineCmdUUIDs, c.CmdID.Value)
}
assert.ElementsMatchf(rt, inlineCmdUUIDs, trace.persistedCmdUUIDs,
"persisted CommandUUIDs must equal inline CmdID.Value (1-to-1)")
// Persist must be a single batched call. A regression that loops single inserts would split the
// CustomErrorText / BlockInStatusPage / TimeOutUntilSyncFailure flags across multiple transactions and
// expose orphan rows on partial-fail-then-retry.
persistCount := 0
for _, ev := range trace.callOrder {
if ev == "persist" {
persistCount++
}
}
require.Equalf(rt, 1, persistCount, "persist must be a single batched call; callOrder=%v", trace.callOrder)
switch expected {
case pbtESPBlock, pbtESPWarn:
// Block-flavored paths (hard block and warn/soft block) NEVER include ServerHasFinishedProvisioning --
// that command would tell Windows the ESP succeeded and proceed past the failure UI. Also NEVER
// InstallationState alone (VM testing confirmed setting it on the parent PolicyProviders node without
// per-tracker state from #43776 does not escalate the failure UI).
assert.Nilf(rt, pbtFindCmdByLocURI(cmds, "ServerHasFinishedProvisioning"),
"%s path must NOT include ServerHasFinishedProvisioning", expected)
assert.Nilf(rt, pbtFindCmdByLocURI(cmds, "InstallationState"),
"%s path uses the timeout-based trigger, not InstallationState", expected)
assert.Nilf(rt, pbtFindCmdByLocURI(cmds, "WasDeviceSuccessfullyProvisioned"),
"%s path must NOT include WasDeviceSuccessfullyProvisioned (verified on Win11 26200: the "+
"documented path does not render failure UI on non-Sidecar MDM)", expected)
assert.Nilf(rt, pbtFindCmdByLocURI(cmds, "IsSyncDone"),
"%s path must NOT include IsSyncDone (verified on Win11 26200: the documented path does not "+
"render failure UI on non-Sidecar MDM)", expected)
// The hard block offers Reset PC only (1); the warn path adds Continue Anyway (5 = 1|4).
expectedButtons := "1"
expectedErrorText := microsoft_mdm.ESPTimeoutErrorText
switch {
case expected == pbtESPWarn:
expectedButtons = "5"
// Warn lists failed software when any failed; otherwise (require_all=false timeout, nothing failed) it
// shows the timeout text. Both keep Continue Anyway.
if observedHasFailure {
expectedErrorText = microsoft_mdm.ESPSoftwareFailureContinuableErrorText(expectedFailedNames)
}
case observedHasFailure:
expectedErrorText = microsoft_mdm.ESPSoftwareFailureErrorText
}
blockCmd := pbtFindCmdByLocURI(cmds, "BlockInStatusPage")
require.NotNilf(rt, blockCmd, "%s path must include BlockInStatusPage", expected)
require.NotNilf(rt, blockCmd.Items[0].Data, "BlockInStatusPage must have data")
assert.Equalf(rt, expectedButtons, blockCmd.Items[0].Data.Content,
"BlockInStatusPage must match the %s flavor's recovery buttons per DMClient CSP bit flags", expected)
assert.NotNilf(rt, pbtFindCmdByLocURI(cmds, "AllowCollectLogsButton"),
"%s path must include AllowCollectLogsButton", expected)
timeoutCmd := pbtFindCmdByLocURI(cmds, "TimeOutUntilSyncFailure")
require.NotNilf(rt, timeoutCmd, "%s path must include TimeOutUntilSyncFailure", expected)
require.NotNilf(rt, timeoutCmd.Items[0].Data, "TimeOutUntilSyncFailure must have data")
assert.Equalf(rt, "1", timeoutCmd.Items[0].Data.Content,
"TimeOutUntilSyncFailure must be 1 minute (force quick failure)")
errCmd := pbtFindCmdByLocURI(cmds, "CustomErrorText")
require.NotNilf(rt, errCmd, "%s path must include CustomErrorText", expected)
require.NotNilf(rt, errCmd.Items[0].Data, "CustomErrorText must have data")
assert.Equalf(rt, expectedErrorText, errCmd.Items[0].Data.Content,
"%s path must carry the expected error text", expected)
case pbtESPRelease:
// Release path NEVER includes CustomErrorText -- the failure UI never renders on a release, so
// any error text would be dead state on the DMClient node.
assert.Nilf(rt, pbtFindCmdByLocURI(cmds, "CustomErrorText"),
"release path must NOT include CustomErrorText")
// Release writes ServerHasFinishedProvisioning at BOTH Device and User scope. Device scope completes
// the Device setup phase; User scope completes Account setup. The User-scope write is rejected with
// 405 until the user MDM context initializes, which is why its ack gates the Active -> None CAS.
shfpDeviceFound, shfpUserFound := false, false
for _, c := range cmds {
uri := c.GetTargetURI()
if !strings.Contains(uri, "ServerHasFinishedProvisioning") {
continue
}
if strings.Contains(uri, "/Device/") {
shfpDeviceFound = true
} else if strings.Contains(uri, "/User/") {
shfpUserFound = true
}
}
assert.Truef(rt, shfpDeviceFound,
"release path must include Device-scope ServerHasFinishedProvisioning")
assert.Truef(rt, shfpUserFound,
"release path must include User-scope ServerHasFinishedProvisioning so Account setup completes; "+
"omitting it hangs the device on 'Working on it...' indefinitely on Win11 26200 when Account "+
"setup has been displayed")
assert.Nilf(rt, pbtFindCmdByLocURI(cmds, "BlockInStatusPage"),
"release path must NOT include BlockInStatusPage")
}
// Cancel-block invariants. The cancel block fires iff (timedOut || (observedHasFailure && requireAll))
// -- when timedOut, Stage 3 is skipped so observedHasFailure=false and the OR's first operand carries
// the condition.
expectedCancelBlock := timedOut || (observedHasFailure && requireAll)
expectedCancelUpcomingCount := 0
if expectedCancelBlock {
for _, s := range statuses {
if s == fleet.SetupExperienceStatusPending || s == fleet.SetupExperienceStatusRunning {
expectedCancelUpcomingCount++
}
}
}
actualCancelStatus := slices.Contains(trace.callOrder, "cancel-status")
assert.Equalf(rt, expectedCancelBlock, actualCancelStatus,
"cancel-status invocation must match expected cancel-block fire condition")
assert.Lenf(rt, trace.cancelUpcomingExecIDs, expectedCancelUpcomingCount,
"cancel-upcoming count must equal Pending+Running rows in input when cancel block fires")
// Ordering: cancel-upcoming must precede cancel-status (queue cleanup before status table update so
// a mid-loop crash + retry sees the same pending statuses and can re-cancel). Both must precede
// persist (a transient cancel failure aborts the finalize cleanly). Persist must precede CAS (the
// dropped-response retry safety net runs before we commit awaiting=None).
var lastCancelUpcoming, firstCancelStatus, firstPersist, firstCas int = -1, -1, -1, -1
for i, ev := range trace.callOrder {
switch ev {
case "cancel-upcoming":
lastCancelUpcoming = i
case "cancel-status":
if firstCancelStatus == -1 {
firstCancelStatus = i
}
case "persist":
if firstPersist == -1 {
firstPersist = i
}
case "cas":
if firstCas == -1 {
firstCas = i
}
}
}
require.NotEqualf(rt, -1, firstPersist, "persist must run for non-wait outcomes")
// CAS Active -> None runs only on the block-flavored paths. The release path stays Active: the
// transition is deferred until the device acks the user-scope ServerHasFinishedProvisioning Replace
// with a 200 (handleESPUserReleaseRetry on a later checkin).
if expected == pbtESPRelease {
require.Equalf(rt, -1, firstCas,
"release path must NOT CAS to None before the user-scope release ack; callOrder=%v", trace.callOrder)
} else {
require.NotEqualf(rt, -1, firstCas, "CAS must run for block outcomes")
require.Lessf(rt, firstPersist, firstCas, "persist must run before CAS; callOrder=%v", trace.callOrder)
}
if lastCancelUpcoming != -1 && firstCancelStatus != -1 {
require.Lessf(rt, lastCancelUpcoming, firstCancelStatus,
"cancel-upcoming must run before cancel-status; callOrder=%v", trace.callOrder)
}
if firstCancelStatus != -1 {
require.Lessf(rt, firstCancelStatus, firstPersist,
"cancel-status must run before persist; callOrder=%v", trace.callOrder)
}
if lastCancelUpcoming != -1 {
require.Lessf(rt, lastCancelUpcoming, firstPersist,
"cancel-upcoming must run before persist; callOrder=%v", trace.callOrder)
}
})
}