diff --git a/changes/49777-mdm-enrolled-host-id-apple.md b/changes/49777-mdm-enrolled-host-id-apple.md new file mode 100644 index 0000000000..3e36511e45 --- /dev/null +++ b/changes/49777-mdm-enrolled-host-id-apple.md @@ -0,0 +1 @@ +- Added `host_id` and `host_serial` to the `mdm_enrolled` activity for Apple (macOS, iOS, iPadOS) enrollments, and the activity now appears on the host's activity timeline. diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index c01b3d39a1..bc40e3fa33 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -215,6 +215,7 @@ export type IHostPastActivityType = | ActivityType.WipedHost | ActivityType.FailedWipe | ActivityType.MdmUnenrolled + | ActivityType.MdmEnrolled | ActivityType.ReadHostDiskEncryptionKey | ActivityType.RetrievedHostMyDeviceURL | ActivityType.ViewedHostRecoveryLockPassword diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx index 17978fcb2f..d7f31090c2 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx @@ -35,6 +35,7 @@ import RotatedManagedLocalAccountPasswordActivityItem from "./ActivityItems/Rota import FailedToRotateManagedLocalAccountPasswordActivityItem from "./ActivityItems/FailedToRotateManagedLocalAccountPassword"; import FailedEnrollmentProfileRenewalActivityItem from "./ActivityItems/FailedEnrollmentProfileRenewalActivityItem"; import MdmUnenrolledActivityItem from "./ActivityItems/MdmUnenrolledActivityItem"; +import MdmEnrolledActivityItem from "./ActivityItems/MdmEnrolledActivityItem"; import RanCustomMdmCommandActivityItem from "./ActivityItems/RanCustomMdmCommandActivityItem"; import EditedCustomHostVitalValueActivityItem from "./ActivityItems/EditedCustomHostVitalValueActivityItem"; import PolicyAutomationActivityItem from "./ActivityItems/PolicyAutomationActivityItem"; @@ -94,6 +95,7 @@ export const pastActivityComponentMap: Record< [ActivityType.FailedToRotateManagedLocalAccountPassword]: FailedToRotateManagedLocalAccountPasswordActivityItem, [ActivityType.FailedEnrollmentProfileRenewal]: FailedEnrollmentProfileRenewalActivityItem, [ActivityType.MdmUnenrolled]: MdmUnenrolledActivityItem, + [ActivityType.MdmEnrolled]: MdmEnrolledActivityItem, [ActivityType.RanCustomMdmCommand]: RanCustomMdmCommandActivityItem, [ActivityType.EditedCustomHostVitalValue]: EditedCustomHostVitalValueActivityItem, [ActivityType.RanAutomationWebhook]: PolicyAutomationActivityItem, diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/MdmEnrolledActivityItem/MdmEnrolledActivityItem.tests.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/MdmEnrolledActivityItem/MdmEnrolledActivityItem.tests.tsx new file mode 100644 index 0000000000..b945a84bf7 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/MdmEnrolledActivityItem/MdmEnrolledActivityItem.tests.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { createMockHostPastActivity } from "__mocks__/activityMock"; + +import { ActivityType } from "interfaces/activity"; +import { Platform } from "interfaces/platform"; + +import MdmEnrolledActivityItem from "./MdmEnrolledActivityItem"; + +const renderItem = (platform: Platform, actor: string) => + render( + + ); + +describe("MdmEnrolledActivityItem", () => { + const cases: Array<[Platform, string, RegExp]> = [ + ["ios", "Admin User", /told Fleet to enroll this host/i], + ["android", "Admin User", /told Fleet to enroll this host/i], + ["android", "", /This host enrolled to Fleet/i], + [ + "darwin", + "Admin User", + /told Fleet to turn on mobile device management \(MDM\) for this host/i, + ], + [ + "darwin", + "", + /Mobile device management \(MDM\) was turned on for this host/i, + ], + ]; + + it.each(cases)("renders %s copy (actor=%j)", (platform, actor, expected) => { + renderItem(platform, actor); + if (actor) expect(screen.getByText(actor)).toBeVisible(); + expect(screen.getByText(expected)).toBeVisible(); + }); + + it("does not render the cancel or show-details icons", () => { + renderItem("darwin", "Admin User"); + expect(screen.queryByTestId("close-icon")).not.toBeInTheDocument(); + expect(screen.queryByTestId("info-outline-icon")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/MdmEnrolledActivityItem/MdmEnrolledActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/MdmEnrolledActivityItem/MdmEnrolledActivityItem.tsx new file mode 100644 index 0000000000..9733277f78 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/MdmEnrolledActivityItem/MdmEnrolledActivityItem.tsx @@ -0,0 +1,49 @@ +import React from "react"; + +import { isAndroid, isIPadOrIPhone } from "interfaces/platform"; + +import ActivityItem from "components/ActivityItem"; + +import { IHostActivityItemComponentProps } from "../../ActivityConfig"; + +const baseClass = "mdm-enrolled-activity-item"; + +const MdmEnrolledActivityItem = ({ + activity, +}: IHostActivityItemComponentProps) => { + const { actor_full_name } = activity; + const platform = activity.details?.platform ?? ""; + + let content: React.ReactNode; + if (isAndroid(platform) || isIPadOrIPhone(platform)) { + content = actor_full_name ? ( + <> + {actor_full_name} told Fleet to enroll this host. + + ) : ( + <>This host enrolled to Fleet. + ); + } else { + content = actor_full_name ? ( + <> + {actor_full_name} told Fleet to turn on mobile device management + (MDM) for this host. + + ) : ( + <>Mobile device management (MDM) was turned on for this host. + ); + } + + return ( + + {content} + + ); +}; + +export default MdmEnrolledActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/MdmEnrolledActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/MdmEnrolledActivityItem/index.ts new file mode 100644 index 0000000000..948b139b14 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/MdmEnrolledActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./MdmEnrolledActivityItem"; diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 8569a7cfc4..3fe09e2338 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -421,6 +421,10 @@ func (a ActivityTypeFleetEnrolled) ActivityName() string { } type ActivityTypeMDMEnrolled struct { + // HostID is omitted when zero so Windows enrollments (which don't set it; + // see #47874) keep their existing activity payload. It is always set for + // Apple enrollments. + HostID uint `json:"host_id,omitempty"` HostSerial *string `json:"host_serial"` HostDisplayName string `json:"host_display_name"` InstalledFromDEP bool `json:"installed_from_dep"` @@ -434,6 +438,16 @@ func (a ActivityTypeMDMEnrolled) ActivityName() string { return "mdm_enrolled" } +// HostIDs links this activity to the host on the host details timeline. Returns nil when the host +// is unknown (eg the enrollment is being processed before the host record exists) so the global +// activity is still recorded but no activity_host_past row is inserted. +func (a ActivityTypeMDMEnrolled) HostIDs() []uint { + if a.HostID == 0 { + return nil + } + return []uint{a.HostID} +} + // TODO(BMAA): Should we add enrollment_id for BYOD unenrollments? type ActivityTypeMDMUnenrolled struct { HostID uint `json:"host_id"` diff --git a/server/mdm/lifecycle/lifecycle.go b/server/mdm/lifecycle/lifecycle.go index 060f07451c..410c42f5f8 100644 --- a/server/mdm/lifecycle/lifecycle.go +++ b/server/mdm/lifecycle/lifecycle.go @@ -249,13 +249,18 @@ func (t *HostLifecycle) turnOnApple(ctx context.Context, opts HostOptions) error // create MDM enrolled activity if not in the middle of a SCEP renewal if !info.SCEPRenewalInProgress { mdmEnrolledActivity := &fleet.ActivityTypeMDMEnrolled{ + HostID: info.HostID, HostDisplayName: info.DisplayName, InstalledFromDEP: info.DEPAssignedToFleet, MDMPlatform: fleet.MDMPlatformApple, Platform: info.Platform, } if nanoEnroll.Type == userEnrollmentDeviceType { - mdmEnrolledActivity.EnrollmentID = ptr.String(opts.UserEnrollmentID) + // Account-driven user (BYOD) enrollments have no hardware serial, so + // report the enrollment ID as the serial too, keeping host_serial + // populated for automations regardless of enrollment type. + mdmEnrolledActivity.EnrollmentID = new(opts.UserEnrollmentID) + mdmEnrolledActivity.HostSerial = new(opts.UserEnrollmentID) } else { mdmEnrolledActivity.HostSerial = ptr.String(info.HardwareSerial) } diff --git a/server/mdm/lifecycle/lifecycle_test.go b/server/mdm/lifecycle/lifecycle_test.go index c6f8a02745..fbeb897171 100644 --- a/server/mdm/lifecycle/lifecycle_test.go +++ b/server/mdm/lifecycle/lifecycle_test.go @@ -7,6 +7,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mock" "github.com/stretchr/testify/require" ) @@ -173,6 +174,65 @@ func TestReconcileHostNameEnforcementOnEnrollment(t *testing.T) { // duplicate DEP hosts (same serial) does not recreate a pending "ghost" host // when another DEP-assigned host with that serial still exists, while a host // with no duplicate is still restored as before. +// TestMDMEnrolledActivityHostIDAndSerial verifies the Apple mdm_enrolled activity +// carries host_id (so it lands on the host's activity timeline via HostIDs) and a +// populated host_serial for both device and account-driven user (BYOD) +// enrollments. Regression coverage for #49777. +func TestMDMEnrolledActivityHostIDAndSerial(t *testing.T) { + ctx := license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierPremium}) + const hostID = uint(99) + + // runTurnOn drives an Apple turn-on enrollment of the given nano enrollment + // type and returns the mdm_enrolled activity that was recorded. + runTurnOn := func(t *testing.T, enrollType, hardwareSerial string, opts HostOptions) *fleet.ActivityTypeMDMEnrolled { + ds := new(mock.Store) + ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, uuid string) (*fleet.NanoEnrollment, error) { + return &fleet.NanoEnrollment{Enabled: true, Type: enrollType, TokenUpdateTally: 1}, nil + } + ds.GetHostMDMCheckinInfoFunc = func(ctx context.Context, uuid string) (*fleet.HostMDMCheckinInfo, error) { + return &fleet.HostMDMCheckinInfo{HostID: hostID, Platform: opts.Platform, HardwareSerial: hardwareSerial}, nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil } + ds.ReconcileHostDeviceNamesForHostsFunc = func(ctx context.Context, hostIDs []uint) error { return nil } + + var captured *fleet.ActivityTypeMDMEnrolled + newAct := func(ctx context.Context, user *fleet.User, details fleet.ActivityDetails) error { + if a, ok := details.(*fleet.ActivityTypeMDMEnrolled); ok { + captured = a + } + return nil + } + + lc := New(ds, slog.New(slog.DiscardHandler), newAct) + require.NoError(t, lc.Do(ctx, opts)) + require.NotNil(t, captured, "an mdm_enrolled activity should have been recorded") + return captured + } + + t.Run("device enrollment: host_id set, host_serial is the hardware serial", func(t *testing.T) { + a := runTurnOn(t, mdm.EnrollType(mdm.Device).String(), "C08VQ2AXHT96", + HostOptions{Action: HostActionTurnOn, Platform: "darwin", UUID: "host-uuid"}) + + require.Equal(t, hostID, a.HostID) + require.Equal(t, []uint{hostID}, a.HostIDs()) + require.NotNil(t, a.HostSerial) + require.Equal(t, "C08VQ2AXHT96", *a.HostSerial) + require.Nil(t, a.EnrollmentID) + }) + + t.Run("account-driven user enrollment: host_id set, host_serial is the enrollment id", func(t *testing.T) { + a := runTurnOn(t, mdm.EnrollType(mdm.UserEnrollmentDevice).String(), "", + HostOptions{Action: HostActionTurnOn, Platform: "ios", UUID: "ADUE-ENROLL-ID", UserEnrollmentID: "ADUE-ENROLL-ID"}) + + require.Equal(t, hostID, a.HostID) + require.Equal(t, []uint{hostID}, a.HostIDs()) + require.NotNil(t, a.EnrollmentID) + require.Equal(t, "ADUE-ENROLL-ID", *a.EnrollmentID) + require.NotNil(t, a.HostSerial) + require.Equal(t, "ADUE-ENROLL-ID", *a.HostSerial) + }) +} + func TestDeleteAppleDuplicateDEPHost(t *testing.T) { ctx := license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierPremium}) diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go index ec4ddcc92d..d0c41f224c 100644 --- a/server/service/integration_mdm_dep_test.go +++ b/server/service/integration_mdm_dep_test.go @@ -1175,11 +1175,13 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { found = true require.Nil(t, activity.ActorID) require.Nil(t, activity.ActorFullName) + depHost, err := s.ds.HostByIdentifier(context.Background(), devices[0].SerialNumber) + require.NoError(t, err) require.JSONEq( t, fmt.Sprintf( - `{"host_serial": "%s", "enrollment_id": null, "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple", "platform": "darwin"}`, - devices[0].SerialNumber, devices[0].Model, devices[0].SerialNumber, + `{"host_id": %d, "host_serial": "%s", "enrollment_id": null, "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple", "platform": "darwin"}`, + depHost.ID, devices[0].SerialNumber, devices[0].Model, devices[0].SerialNumber, ), string(*activity.Details), ) @@ -1408,11 +1410,13 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { s.awaitRunAppleMDMWorkerSchedule() // The last activity should have `installed_from_dep=true`. + depReenrollHost, err := s.ds.HostByIdentifier(context.Background(), mdmDevice.SerialNumber) + require.NoError(t, err) s.lastActivityMatches( "mdm_enrolled", fmt.Sprintf( - `{"host_serial": "%s", "enrollment_id": null, "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple", "platform": "darwin"}`, - mdmDevice.SerialNumber, mdmDevice.Model, mdmDevice.SerialNumber, + `{"host_id": %d, "host_serial": "%s", "enrollment_id": null, "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple", "platform": "darwin"}`, + depReenrollHost.ID, mdmDevice.SerialNumber, mdmDevice.Model, mdmDevice.SerialNumber, ), 0, ) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index e63727c446..bfc608a086 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -1855,13 +1855,17 @@ func (s *integrationMDMTestSuite) TestAppleMDMDeviceEnrollment() { mdmDeviceA := mdmtest.NewTestMDMClientAppleDirect(mdmEnrollInfo, "MacBookPro16,1") err := mdmDeviceA.Enroll() require.NoError(t, err) + hostA, err := s.ds.HostByIdentifier(context.Background(), mdmDeviceA.SerialNumber) + require.NoError(t, err) s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMEnrolled{}.ActivityName(), - fmt.Sprintf(`{"host_serial": "%s", "enrollment_id": null, "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple", "platform": "darwin"}`, mdmDeviceA.SerialNumber, mdmDeviceA.Model, mdmDeviceA.SerialNumber), 0) + fmt.Sprintf(`{"host_id": %d, "host_serial": "%s", "enrollment_id": null, "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple", "platform": "darwin"}`, hostA.ID, mdmDeviceA.SerialNumber, mdmDeviceA.Model, mdmDeviceA.SerialNumber), 0) mdmDeviceB := mdmtest.NewTestMDMClientAppleDirect(mdmEnrollInfo, "MacBookPro16,1") err = mdmDeviceB.Enroll() require.NoError(t, err) + hostB, err := s.ds.HostByIdentifier(context.Background(), mdmDeviceB.SerialNumber) + require.NoError(t, err) s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMEnrolled{}.ActivityName(), - fmt.Sprintf(`{"host_serial": "%s", "enrollment_id": null, "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple", "platform": "darwin"}`, mdmDeviceB.SerialNumber, mdmDeviceB.Model, mdmDeviceB.SerialNumber), 0) + fmt.Sprintf(`{"host_id": %d, "host_serial": "%s", "enrollment_id": null, "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple", "platform": "darwin"}`, hostB.ID, mdmDeviceB.SerialNumber, mdmDeviceB.Model, mdmDeviceB.SerialNumber), 0) // Find the ID of Fleet's MDM solution var mdmID uint mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { @@ -16235,8 +16239,10 @@ func (s *integrationMDMTestSuite) TestAppleMDMAccountDrivenUserEnrollment() { require.NoError(t, iPhoneMdmDevice.Enroll()) assert.Equal(t, "sso_user@example.com", iPhoneMdmDevice.EnrollInfo.AssignedManagedAppleID) + iPhoneHost, err := s.ds.HostByIdentifier(context.Background(), iPhoneMdmDevice.EnrollmentID()) + require.NoError(t, err) s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMEnrolled{}.ActivityName(), - fmt.Sprintf(`{"host_serial": null, "enrollment_id": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple", "platform": "ios"}`, iPhoneMdmDevice.EnrollmentID(), iPhoneMdmDevice.Model, iPhoneMdmDevice.EnrollmentID()), 0) + fmt.Sprintf(`{"host_id": %d, "host_serial": "%s", "enrollment_id": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple", "platform": "ios"}`, iPhoneHost.ID, iPhoneMdmDevice.EnrollmentID(), iPhoneMdmDevice.EnrollmentID(), iPhoneMdmDevice.Model, iPhoneMdmDevice.EnrollmentID()), 0) linkedIDPAccount, err := s.ds.GetMDMIdPAccountByHostUUID(context.Background(), iPhoneMdmDevice.EnrollmentID()) require.NoError(t, err) require.NotNil(t, linkedIDPAccount) @@ -16270,8 +16276,10 @@ func (s *integrationMDMTestSuite) TestAppleMDMAccountDrivenUserEnrollment() { require.NoError(t, iPadMdmDevice.Enroll()) assert.Equal(t, "sso_user2@example.com", iPadMdmDevice.EnrollInfo.AssignedManagedAppleID) + iPadHost, err := s.ds.HostByIdentifier(context.Background(), iPadMdmDevice.EnrollmentID()) + require.NoError(t, err) s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMEnrolled{}.ActivityName(), - fmt.Sprintf(`{"host_serial": null, "enrollment_id": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple", "platform": "ipados"}`, iPadMdmDevice.EnrollmentID(), iPadMdmDevice.Model, iPadMdmDevice.EnrollmentID()), 0) + fmt.Sprintf(`{"host_id": %d, "host_serial": "%s", "enrollment_id": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple", "platform": "ipados"}`, iPadHost.ID, iPadMdmDevice.EnrollmentID(), iPadMdmDevice.EnrollmentID(), iPadMdmDevice.Model, iPadMdmDevice.EnrollmentID()), 0) linkedIDPAccount, err = s.ds.GetMDMIdPAccountByHostUUID(context.Background(), iPadMdmDevice.EnrollmentID()) require.NoError(t, err) require.NotNil(t, linkedIDPAccount) @@ -16285,8 +16293,10 @@ func (s *integrationMDMTestSuite) TestAppleMDMAccountDrivenUserEnrollment() { require.NoError(t, oldUrlIphoneMdmDevice.Enroll()) assert.Equal(t, "sso_user2@example.com", oldUrlIphoneMdmDevice.EnrollInfo.AssignedManagedAppleID) + oldUrlIphoneHost, err := s.ds.HostByIdentifier(context.Background(), oldUrlIphoneMdmDevice.EnrollmentID()) + require.NoError(t, err) s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMEnrolled{}.ActivityName(), - fmt.Sprintf(`{"host_serial": null, "enrollment_id": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple", "platform": "ios"}`, oldUrlIphoneMdmDevice.EnrollmentID(), oldUrlIphoneMdmDevice.Model, oldUrlIphoneMdmDevice.EnrollmentID()), 0) + fmt.Sprintf(`{"host_id": %d, "host_serial": "%s", "enrollment_id": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple", "platform": "ios"}`, oldUrlIphoneHost.ID, oldUrlIphoneMdmDevice.EnrollmentID(), oldUrlIphoneMdmDevice.EnrollmentID(), oldUrlIphoneMdmDevice.Model, oldUrlIphoneMdmDevice.EnrollmentID()), 0) linkedIDPAccount, err = s.ds.GetMDMIdPAccountByHostUUID(context.Background(), oldUrlIphoneMdmDevice.EnrollmentID()) require.NoError(t, err) require.NotNil(t, linkedIDPAccount) diff --git a/server/service/integration_vpp_install_test.go b/server/service/integration_vpp_install_test.go index acae1dc712..636161367b 100644 --- a/server/service/integration_vpp_install_test.go +++ b/server/service/integration_vpp_install_test.go @@ -1395,14 +1395,21 @@ func (s *integrationMDMTestSuite) TestVPPAppActivitiesOnCancelInstall() { listPastResp = listActivitiesResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", mdmHost2.ID), nil, http.StatusOK, &listPastResp) require.GreaterOrEqual(t, len(listPastResp.Activities), 2) + // mdm_unenrolled is emitted last in the unenroll flow, so it heads the (descending) feed. require.Equal(t, fleet.ActivityTypeMDMUnenrolled{}.ActivityName(), listPastResp.Activities[0].Type) - require.Equal(t, fleet.ActivityInstalledAppStoreApp{}.ActivityName(), listPastResp.Activities[1].Type) - require.Contains(t, string(*listPastResp.Activities[1].Details), fmt.Sprintf(`"app_store_id": %q`, app1.AdamID)) - require.Contains(t, string(*listPastResp.Activities[1].Details), `"status": "failed_install"`) - if len(listPastResp.Activities) > 2 { - // the third activity should not be the cancellation of the second app - require.Equal(t, fleet.ActivityInstalledAppStoreApp{}.ActivityName(), listPastResp.Activities[2].Type) + // Only the first VPP app was activated, so exactly one installed_app_store_app cancellation + // should appear (the second app was never activated). Filter by type, since the feed also + // includes the host's mdm_enrolled activity. + appStoreType := fleet.ActivityInstalledAppStoreApp{}.ActivityName() + var appStoreActs []*fleet.Activity + for _, act := range listPastResp.Activities { + if act.Type == appStoreType { + appStoreActs = append(appStoreActs, act) + } } + require.Len(t, appStoreActs, 1) + require.Contains(t, string(*appStoreActs[0].Details), fmt.Sprintf(`"app_store_id": %q`, app1.AdamID)) + require.Contains(t, string(*appStoreActs[0].Details), `"status": "failed_install"`) // listing the host's software available for install shows the cancelled app as failed getHostSw = getHostSoftwareResponse{} @@ -1890,10 +1897,18 @@ func (s *integrationMDMTestSuite) TestInHouseAppSelfInstall() { s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", iosHost.ID), nil, http.StatusOK, &listUpcomingAct) require.Len(t, listUpcomingAct.Activities, 0) - // host has the past activity for the installed app + // host has the past activity for the installed app (the feed also includes the host's + // mdm_enrolled activity, so filter by type). var listPastResp listActivitiesResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", iosHost.ID), nil, http.StatusOK, &listPastResp) - require.Len(t, listPastResp.Activities, 1) + installedSoftwareType := fleet.ActivityTypeInstalledSoftware{}.ActivityName() + installedCount := 0 + for _, act := range listPastResp.Activities { + if act.Type == installedSoftwareType { + installedCount++ + } + } + require.Equal(t, 1, installedCount) // update the app to have a label condition clr := fleet.CreateLabelResponse{}