diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index df96238869..e2cf0b47ed 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -548,6 +548,8 @@ func (ds *Datastore) MDMWindowsSaveResponse(ctx context.Context, deviceID string var result *fleet.MDMWindowsSaveResponseResult if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + result = nil + // store the full response const saveFullRespStmt = `INSERT INTO windows_mdm_responses (enrollment_id, raw_response) VALUES (?, ?)` sqlResult, err := tx.ExecContext(ctx, saveFullRespStmt, enrolledDevice.ID, enrichedSyncML.Raw) diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 4d210e45ea..3c3a1848c5 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -1021,6 +1021,10 @@ func (a ActivityTypeWipeFailedHost) HostIDs() []uint { return []uint{a.HostID} } +func (a ActivityTypeWipeFailedHost) WasFromAutomation() bool { + return true +} + // ActivityTypeRotatedHostRecoveryLockPassword is for password rotation. // Can be user-initiated (manual) or Fleet-initiated (auto-rotation after password viewed). type ActivityTypeRotatedHostRecoveryLockPassword struct { diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 088b5fd4a5..730610dcc3 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -10,6 +10,7 @@ import ( "database/sql" "encoding/base64" "encoding/json" + "encoding/xml" "errors" "fmt" "math/big" @@ -2857,3 +2858,122 @@ func TestNewMDMProfilePremiumOnlyAndroid(t *testing.T) { }) } } + +func TestProcessIncomingMDMCmdsWipeFailedActivity(t *testing.T) { + ds := new(mock.Store) + opts := &TestServerOpts{} + svc, ctx := newTestService(t, ds, nil, nil, opts) + + var svcImpl *Service + switch v := svc.(type) { + case validationMiddleware: + svcImpl = v.Service.(*Service) + case *Service: + svcImpl = v + } + + testHostUUID := "test-host-uuid" + testHostID := uint(42) + testDeviceID := "test-device-id" + + // MDMWindowsSaveResponse returns a WipeFailed result. + ds.MDMWindowsSaveResponseFunc = func(ctx context.Context, deviceID string, enrichedSyncML fleet.EnrichedSyncML, commandIDsBeingResent []string) (*fleet.MDMWindowsSaveResponseResult, error) { + return &fleet.MDMWindowsSaveResponseResult{ + WipeFailed: &fleet.MDMWindowsWipeResult{ + HostUUID: testHostUUID, + }, + }, nil + } + + // Stub for the resending flow (no 418 commands in our test). + ds.GetWindowsMDMCommandsForResendingFunc = func(ctx context.Context, deviceID string, cmdUUIDs []string) ([]*fleet.MDMWindowsCommand, error) { + return nil, nil + } + + // HostByIdentifier returns a test host. + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + require.Equal(t, testHostUUID, identifier) + return &fleet.Host{ + ID: testHostID, + ComputerName: "DESKTOP-TEST", + UUID: testHostUUID, + }, nil + } + + // Track activity creation. + var createdActivity activity_api.ActivityDetails + opts.ActivityMock.NewActivityFunc = func(_ context.Context, user *activity_api.User, activity activity_api.ActivityDetails) error { + assert.Nil(t, user, "wipe_failed_host activity should have nil user") + createdActivity = activity + return nil + } + + // Build a minimal valid SyncML message with a entry so that + // NewEnrichedSyncML produces a non-empty CmdRefUUIDs and HasCommands() + // returns true, which is needed for saveResponse to call MDMWindowsSaveResponse. + fakeCmdUUID := uuid.NewString() + rawSyncML := fmt.Sprintf(` + + 1.2 + DM/1.2 + 1 + 1 + %s + + + + 1 + 1 + %s + Exec + 500 + + + + `, testDeviceID, fakeCmdUUID) + + reqMsg := &fleet.SyncML{} + require.NoError(t, xml.Unmarshal([]byte(rawSyncML), reqMsg)) + reqMsg.Raw = []byte(rawSyncML) + + _, err := svcImpl.processIncomingMDMCmds(ctx, testDeviceID, reqMsg, RequestAuthStateTrusted) + require.NoError(t, err) + + // Verify the activity was created. + require.NotNil(t, createdActivity) + wipeFailed, ok := createdActivity.(fleet.ActivityTypeWipeFailedHost) + require.True(t, ok, "expected ActivityTypeWipeFailedHost, got %T", createdActivity) + assert.Equal(t, testHostID, wipeFailed.HostID) + assert.Equal(t, "DESKTOP-TEST", wipeFailed.HostDisplayName) + assert.True(t, opts.ActivityMock.NewActivityFuncInvoked) + + t.Run("no activity when WipeFailed is nil", func(t *testing.T) { + ds.MDMWindowsSaveResponseFunc = func(ctx context.Context, deviceID string, enrichedSyncML fleet.EnrichedSyncML, commandIDsBeingResent []string) (*fleet.MDMWindowsSaveResponseResult, error) { + return nil, nil + } + opts.ActivityMock.NewActivityFuncInvoked = false + + _, err := svcImpl.processIncomingMDMCmds(ctx, testDeviceID, reqMsg, RequestAuthStateTrusted) + require.NoError(t, err) + assert.False(t, opts.ActivityMock.NewActivityFuncInvoked) + }) + + t.Run("activity skipped when host lookup fails", func(t *testing.T) { + ds.MDMWindowsSaveResponseFunc = func(ctx context.Context, deviceID string, enrichedSyncML fleet.EnrichedSyncML, commandIDsBeingResent []string) (*fleet.MDMWindowsSaveResponseResult, error) { + return &fleet.MDMWindowsSaveResponseResult{ + WipeFailed: &fleet.MDMWindowsWipeResult{ + HostUUID: testHostUUID, + }, + }, nil + } + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + return nil, errors.New("host not found") + } + opts.ActivityMock.NewActivityFuncInvoked = false + + _, err := svcImpl.processIncomingMDMCmds(ctx, testDeviceID, reqMsg, RequestAuthStateTrusted) + require.NoError(t, err) + // Activity should NOT be created since host lookup failed. + assert.False(t, opts.ActivityMock.NewActivityFuncInvoked) + }) +}