From b51344aeb2687e0160010e47b09181bed2f3e8af Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Thu, 3 Jul 2025 20:52:45 -0400 Subject: [PATCH] Refetch host after VPP install is verified (#30546) > Fixes #29980 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated automated tests - [x] Manual QA for all new/changed functionality ## Summary by CodeRabbit * **New Features** * Improved support for iOS VPP app installations, including enhanced verification and activity logging. * Hosts now automatically request a software data update after successful app installs. * Installation status now includes "Installing" state for better tracking. * **Tests** * Expanded integration tests to cover iOS VPP app installations alongside macOS. * Added checks to verify refetch requests and correct MDM command behavior after app installs. --------- Co-authored-by: Ian Littman --- server/mdm/apple/commander.go | 1 + server/service/apple_mdm.go | 49 ++-- .../service/integration_vpp_install_test.go | 254 +++++++++++++++--- 3 files changed, 245 insertions(+), 59 deletions(-) diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index 73c25e2cfa..6174b0f3a3 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -326,6 +326,7 @@ func (svc *MDMAppleCommander) InstalledApplicationList(ctx context.Context, host Name ShortVersion Identifier + Installing CommandUUID diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index f6c18f3287..dd9dcdc25b 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -3781,13 +3781,8 @@ func NewInstalledApplicationListResultsHandler( installedApps := installedAppResult.AvailableApps() - if len(installedApps) == 0 { - // Nothing to do - return nil - } - - // Get installs that should be verified by this InstalledApplicationList command - installs, err := ds.GetVPPInstallsByVerificationUUID(ctx, installedAppResult.UUID()) + // Get expectedInstalls that should be verified by this InstalledApplicationList command + expectedInstalls, err := ds.GetVPPInstallsByVerificationUUID(ctx, installedAppResult.UUID()) if err != nil { if fleet.IsNotFound(err) { // Then something weird happened, so log it and exit (we can't do anything here in this case). @@ -3797,32 +3792,32 @@ func NewInstalledApplicationListResultsHandler( return ctxerr.Wrap(ctx, err, "InstalledApplicationList handler: getting install record") } - installsByBundleID := map[string]*fleet.HostVPPSoftwareInstall{} - for _, install := range installs { + installsByBundleID := map[string]fleet.Software{} + for _, install := range installedApps { installsByBundleID[install.BundleIdentifier] = install } // We've handled the "no installs found" case above, and this is scoped to a single host via the host // UUID, so this is OK. - hostID := installs[0].HostID + hostID := expectedInstalls[0].HostID - var poll bool - for _, a := range installedApps { - install, ok := installsByBundleID[a.BundleIdentifier] - if !ok { - continue - } + var poll, shouldRefetch bool + for _, expectedInstall := range expectedInstalls { + // If we don't find the app in the result, then we need to poll for it (within the timeout). + // These are not pointers, so no need to check `ok` here. + appFromResult := installsByBundleID[expectedInstall.BundleIdentifier] var terminalStatus string switch { - case a.Installed: - if err := ds.SetVPPInstallAsVerified(ctx, install.HostID, install.InstallCommandUUID); err != nil { + case appFromResult.Installed: + if err := ds.SetVPPInstallAsVerified(ctx, expectedInstall.HostID, expectedInstall.InstallCommandUUID); err != nil { return ctxerr.Wrap(ctx, err, "InstalledApplicationList handler: set vpp install verified") } terminalStatus = fleet.MDMAppleStatusAcknowledged - case install.InstallCommandAckAt != nil && time.Since(*install.InstallCommandAckAt) > verifyTimeout: - if err := ds.SetVPPInstallAsFailed(ctx, install.HostID, install.InstallCommandUUID); err != nil { + shouldRefetch = true + case expectedInstall.InstallCommandAckAt != nil && time.Since(*expectedInstall.InstallCommandAckAt) > verifyTimeout: + if err := ds.SetVPPInstallAsFailed(ctx, expectedInstall.HostID, expectedInstall.InstallCommandUUID); err != nil { return ctxerr.Wrap(ctx, err, "InstalledApplicationList handler: set vpp install failed") } @@ -3838,17 +3833,17 @@ func NewInstalledApplicationListResultsHandler( var fromSetupExperience bool if updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, fleet.SetupExperienceVPPInstallResult{ HostUUID: installedAppResult.HostUUID(), - CommandUUID: install.InstallCommandUUID, + CommandUUID: expectedInstall.InstallCommandUUID, CommandStatus: terminalStatus, }, true); err != nil { return ctxerr.Wrap(ctx, err, "updating setup experience status from VPP install result") } else if updated { fromSetupExperience = true - level.Debug(logger).Log("msg", "setup experience script result updated", "host_uuid", installedAppResult.HostUUID(), "execution_id", install.InstallCommandUUID) + level.Debug(logger).Log("msg", "setup experience script result updated", "host_uuid", installedAppResult.HostUUID(), "execution_id", expectedInstall.InstallCommandUUID) } // create an activity for installing only if we're in a terminal state - user, act, err := ds.GetPastActivityDataForVPPAppInstall(ctx, &mdm.CommandResults{CommandUUID: install.InstallCommandUUID, Status: terminalStatus}) + user, act, err := ds.GetPastActivityDataForVPPAppInstall(ctx, &mdm.CommandResults{CommandUUID: expectedInstall.InstallCommandUUID, Status: terminalStatus}) if err != nil { if fleet.IsNotFound(err) { // Then this isn't a VPP install, so no activity generated @@ -3873,13 +3868,19 @@ func NewInstalledApplicationListResultsHandler( ) } + if shouldRefetch { + // Request host refetch to get the most up to date software data ASAP. + if err := ds.UpdateHostRefetchRequested(ctx, hostID, true); err != nil { + return ctxerr.Wrap(ctx, err, "request refetch for host after vpp install verification") + } + } + // If we get here, we're in a terminal state, so we can remove the verify command. return ctxerr.Wrap( ctx, ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{CommandType: fleet.VerifySoftwareInstallVPPPrefix, HostID: hostID}), "InstalledApplicationList handler: removing host mdm command", ) - } } diff --git a/server/service/integration_vpp_install_test.go b/server/service/integration_vpp_install_test.go index 080841353f..d88c5cff90 100644 --- a/server/service/integration_vpp_install_test.go +++ b/server/service/integration_vpp_install_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "fmt" "net/http" + "strings" "time" "github.com/fleetdm/fleet/v4/pkg/fleetdbase" @@ -63,9 +64,8 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { var resPatchVPP patchVPPTokensTeamsResponse s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", resp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP) - // Insert/deletion flow for macOS app - // Add an app store app to team 1 - addedApp := &fleet.VPPApp{ + // Add macOS and iOS apps to team 1 + macOSApp := &fleet.VPPApp{ VPPAppTeam: fleet.VPPAppTeam{ VPPAppID: fleet.VPPAppID{ AdamID: "1", @@ -77,12 +77,33 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { IconURL: "https://example.com/images/1", LatestVersion: "1.0.0", } - var addAppResp addAppStoreAppResponse - s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: addedApp.AdamID, SelfService: true, AutomaticInstall: true}, http.StatusOK, &addAppResp) - s.lastActivityOfTypeMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), + iOSApp := &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "2", + Platform: fleet.IOSPlatform, + }, + }, + Name: "App 2", + BundleIdentifier: "b-2", + IconURL: "https://example.com/images/2", + LatestVersion: "2.0.0", + } + var addAppResp addAppStoreAppResponse + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: macOSApp.AdamID, SelfService: true}, http.StatusOK, &addAppResp) + + s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "software_title_id": %d, "app_store_id": "%s", "team_id": %d, "platform": "%s", "self_service": true}`, team.Name, - addedApp.Name, getSoftwareTitleIDFromApp(addedApp), addedApp.AdamID, team.ID, addedApp.Platform), 0) + macOSApp.Name, getSoftwareTitleIDFromApp(macOSApp), macOSApp.AdamID, team.ID, macOSApp.Platform), 0) + + // Add iOS app to team + addAppResp = addAppStoreAppResponse{} + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: iOSApp.AdamID, Platform: iOSApp.Platform}, http.StatusOK, &addAppResp) + + s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), + fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "software_title_id": %d, "app_store_id": "%s", "team_id": %d, "platform": "%s", "self_service": false}`, team.Name, + iOSApp.Name, getSoftwareTitleIDFromApp(iOSApp), iOSApp.AdamID, team.ID, iOSApp.Platform), 0) checkInstallFleetdCommandSent := func(mdmDevice *mdmtest.TestAppleMDMClient, wantCommand bool) { foundInstallFleetdCommand := false @@ -102,7 +123,7 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { require.Equal(t, wantCommand, foundInstallFleetdCommand) } - // Create a couple of hosts + // Create hosts for testing orbitHost := createOrbitEnrolledHost(t, "darwin", "nonmdm", s.ds) mdmHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) setOrbitEnrollment(t, mdmHost, s.ds) @@ -113,6 +134,8 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { selfServiceToken := "selfservicetoken" updateDeviceTokenForHost(t, s.ds, selfServiceHost.ID, selfServiceToken) s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, selfServiceDevice.SerialNumber) + + // Create and enroll an iOS device // ensure a valid alternate device token for self-service status access checking later updateDeviceTokenForHost(t, s.ds, mdmHost.ID, "foobar") @@ -134,7 +157,13 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { IconURL: "https://example.com/images/2", LatestVersion: "2.0.0", } - expectedApps := []*fleet.VPPApp{addedApp, errApp} + expectedApps := []*fleet.VPPApp{macOSApp, errApp, iOSApp} + expectedAppsByBundleID := map[string]*fleet.VPPApp{ + macOSApp.BundleIdentifier: macOSApp, + errApp.BundleIdentifier: errApp, + iOSApp.BundleIdentifier: iOSApp, + } + addedApp := expectedApps[0] var listSw listSoftwareTitlesResponse s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw, "team_id", fmt.Sprint(team.ID), @@ -181,34 +210,44 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { }) } - processVPPInstallOnClient := func(failOnInstall, appInstallVerified, appInstallTimeout bool) string { + type vppInstallOpts struct { + failOnInstall bool + appInstallVerified bool + appInstallTimeout bool + bundleID string + } + + processVPPInstallOnClient := func(mdmClient *mdmtest.TestAppleMDMClient, opts vppInstallOpts) string { var installCmdUUID string // Process the InstallApplication command s.runWorker() - cmd, err := mdmDevice.Idle() + cmd, err := mdmClient.Idle() require.NoError(t, err) + + app, ok := expectedAppsByBundleID[opts.bundleID] + require.Truef(t, ok, "unexpected bundle ID: %s", opts.bundleID) for cmd != nil { var fullCmd micromdm.CommandPayload switch cmd.Command.RequestType { case "InstallApplication": require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) installCmdUUID = cmd.CommandUUID - if failOnInstall { + if opts.failOnInstall { t.Logf("Failed command UUID: %s", installCmdUUID) - cmd, err = mdmDevice.Err(cmd.CommandUUID, []mdm.ErrorChain{{ErrorCode: 1234}}) + cmd, err = mdmClient.Err(cmd.CommandUUID, []mdm.ErrorChain{{ErrorCode: 1234}}) require.NoError(t, err) continue } - cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + cmd, err = mdmClient.Acknowledge(cmd.CommandUUID) require.NoError(t, err) case "InstalledApplicationList": // If we are polling to verify the install, we should get an // InstalledApplicationList command instead of an InstallApplication command. require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) - _, err = mdmDevice.AcknowledgeInstalledApplicationList( - mdmDevice.UUID, + _, err = mdmClient.AcknowledgeInstalledApplicationList( + mdmClient.UUID, cmd.CommandUUID, []fleet.Software{ { @@ -218,10 +257,10 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { Installed: false, }, { - Name: addedApp.Name, - BundleIdentifier: addedApp.BundleIdentifier, - Version: addedApp.LatestVersion, - Installed: appInstallVerified, + Name: app.Name, + BundleIdentifier: app.BundleIdentifier, + Version: app.LatestVersion, + Installed: opts.appInstallVerified, }, }, ) @@ -232,11 +271,11 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { } } - if failOnInstall { + if opts.failOnInstall { return installCmdUUID } - if appInstallTimeout { + if opts.appInstallTimeout { mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(context.Background(), "UPDATE nano_command_results SET updated_at = ? WHERE command_uuid = ?", time.Now().Add(-11*time.Minute), installCmdUUID) return err @@ -247,15 +286,15 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { s.runWorker() // Check that there is now a verify command in flight checkCommandsInFlight(1) - cmd, err = mdmDevice.Idle() + cmd, err = mdmClient.Idle() require.NoError(t, err) for cmd != nil { var fullCmd micromdm.CommandPayload switch cmd.Command.RequestType { case "InstalledApplicationList": require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) - cmd, err = mdmDevice.AcknowledgeInstalledApplicationList( - mdmDevice.UUID, + cmd, err = mdmClient.AcknowledgeInstalledApplicationList( + mdmClient.UUID, cmd.CommandUUID, []fleet.Software{ { @@ -265,10 +304,10 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { Installed: false, }, { - Name: addedApp.Name, - BundleIdentifier: addedApp.BundleIdentifier, - Version: addedApp.LatestVersion, - Installed: appInstallVerified, + Name: app.Name, + BundleIdentifier: app.BundleIdentifier, + Version: app.LatestVersion, + Installed: opts.appInstallVerified, }, }, ) @@ -298,6 +337,9 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { // Install command failed // ================================ + // Cancel any pending refetch requests + require.NoError(t, s.ds.UpdateHostRefetchRequested(context.Background(), mdmHost.ID, false)) + // Trigger install to the host installResp := installSoftwareResponse{} s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, errTitleID), &installSoftwareRequest{}, @@ -315,7 +357,13 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { require.Equal(t, 1, countResp.Count) // Simulate failed installation on the host - failedCmdUUID := processVPPInstallOnClient(true, false, false) + opts := vppInstallOpts{ + failOnInstall: true, + appInstallVerified: false, + appInstallTimeout: false, + bundleID: addedApp.BundleIdentifier, + } + failedCmdUUID := processVPPInstallOnClient(mdmDevice, opts) // We should have cleared out upcoming_activies since the install failed mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { @@ -350,6 +398,11 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { 0, ) + // No refetch requested since the install failed + var hostResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", mdmHost.ID), nil, http.StatusOK, &hostResp) + require.False(t, hostResp.Host.RefetchRequested, "RefetchRequested should be false after failed software install") + // ================================================ // Successful install and immediate verification // ================================================ @@ -364,7 +417,10 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { require.Equal(t, 1, countResp.Count) // Simulate successful installation on the host - installCmdUUID := processVPPInstallOnClient(false, true, false) + opts.appInstallTimeout = false + opts.failOnInstall = false + opts.appInstallVerified = true + installCmdUUID := processVPPInstallOnClient(mdmDevice, opts) listResp = listHostsResponse{} s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "installed", "team_id", @@ -389,6 +445,17 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { 0, ) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", mdmHost.ID), nil, http.StatusOK, &hostResp) + require.True(t, hostResp.Host.RefetchRequested, "RefetchRequested should be true after successful software install") + + s.lq.On("QueriesForHost", mdmHost.ID).Return(map[string]string{fmt.Sprintf("%d", mdmHost.ID): "select 1 from osquery;"}, nil) + + req := getDistributedQueriesRequest{NodeKey: *mdmHost.NodeKey} + var dqResp getDistributedQueriesResponse + s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp) + require.Contains(t, dqResp.Queries, "fleet_detail_query_software_macos") + require.NoError(t, s.ds.UpdateHostRefetchRequested(context.Background(), mdmHost.ID, false)) + // Check list host software getHostSw := getHostSoftwareResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost.ID), nil, http.StatusOK, &getHostSw) @@ -413,7 +480,10 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { require.Equal(t, 1, countResp.Count) // Install is ACK, but not verified yet - installCmdUUID = processVPPInstallOnClient(false, false, false) + opts.appInstallTimeout = false + opts.appInstallVerified = false + opts.failOnInstall = false + installCmdUUID = processVPPInstallOnClient(mdmDevice, opts) // We should have 0 installed, because the verification is not done yet listResp = listHostsResponse{} @@ -440,7 +510,7 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { // Install is ACK, but not verified yet // Don't update the command UUID because we didn't trigger a new install command // (the command UUID is the same as the one we got when we triggered the install) - processVPPInstallOnClient(false, false, false) + processVPPInstallOnClient(mdmDevice, opts) // We should have 0 installed, because the verification is not done yet listResp = listHostsResponse{} @@ -467,7 +537,8 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { // Install is ACK, and now it's verified // Don't update the command UUID because we didn't trigger a new install command // (the command UUID is the same as the one we got when we triggered the install) - processVPPInstallOnClient(false, true, false) + opts.appInstallVerified = true + processVPPInstallOnClient(mdmDevice, opts) checkCommandsInFlight(0) @@ -490,6 +561,13 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { 0, ) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", mdmHost.ID), nil, http.StatusOK, &hostResp) + require.True(t, hostResp.Host.RefetchRequested, "RefetchRequested should be true after successful software install") + + s.DoJSON("POST", "/api/osquery/distributed/read", req, http.StatusOK, &dqResp) + require.Contains(t, dqResp.Queries, "fleet_detail_query_software_macos") + require.NoError(t, s.ds.UpdateHostRefetchRequested(context.Background(), mdmHost.ID, false)) + // ======================================================== // Install command succeeds, but verification fails // ======================================================== @@ -503,7 +581,10 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID)) require.Equal(t, 1, countResp.Count) - installCmdUUID = processVPPInstallOnClient(false, false, true) + opts.failOnInstall = false + opts.appInstallVerified = false + opts.appInstallTimeout = true + installCmdUUID = processVPPInstallOnClient(mdmDevice, opts) listResp = listHostsResponse{} s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "installed", "team_id", @@ -529,6 +610,10 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { 0, ) + // No refetch requested since the install failed + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", mdmHost.ID), nil, http.StatusOK, &hostResp) + require.False(t, hostResp.Host.RefetchRequested, "RefetchRequested should be false after failed software install") + // Check list host software getHostSw = getHostSoftwareResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost.ID), nil, http.StatusOK, &getHostSw) @@ -644,4 +729,103 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id", fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID)) require.Equal(t, 0, countResp.Count) + + // Re-enable MDM + s.appleCoreCertsSetup() + + // ======================================================== + // Test iOS VPP app installation + // ======================================================== + + // Enroll iOS device, add serial number to fake Apple server, and transfer to team + iosHost, iosDevice := s.createAppleMobileHostThenEnrollMDM("ios") + s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, iosDevice.SerialNumber) + s.Do("POST", "/api/latest/fleet/hosts/transfer", + &addHostsToTeamRequest{HostIDs: []uint{iosHost.ID}, TeamID: &team.ID}, http.StatusOK) + + var iosTitleID uint + for _, sw := range listSw.SoftwareTitles { + if sw.Name == iOSApp.Name && sw.Source == "apps" { + iosTitleID = sw.ID + break + } + } + require.NotZero(t, iosTitleID) + + // Trigger install to the iOS device + installResp = installSoftwareResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", iosHost.ID, iosTitleID), &installSoftwareRequest{}, + http.StatusAccepted, &installResp) + + // Verify pending status + countResp = countHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id", + fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(iosTitleID)) + require.Equal(t, 1, countResp.Count) + + // Simulate successful installation on iOS device + opts.appInstallTimeout = false + opts.appInstallVerified = true + opts.failOnInstall = false + opts.bundleID = iOSApp.BundleIdentifier + installCmdUUID = processVPPInstallOnClient(iosDevice, opts) + + // Verify successful installation + listResp = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "installed", "team_id", + fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(iosTitleID)) + require.Len(t, listResp.Hosts, 1) + require.Equal(t, iosHost.ID, listResp.Hosts[0].ID) + + // Verify activity log entry + s.lastActivityMatches( + fleet.ActivityInstalledAppStoreApp{}.ActivityName(), + fmt.Sprintf( + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "policy_id": null, "policy_name": null}`, + iosHost.ID, + iosHost.DisplayName(), + iOSApp.Name, + iOSApp.AdamID, + installCmdUUID, + fleet.SoftwareInstalled, + ), + 0, + ) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", iosHost.ID), nil, http.StatusOK, &hostResp) + require.True(t, hostResp.Host.RefetchRequested, "RefetchRequested should be true after successful software install") + + // Verify that an InstalledApplicationList command was sent, but NOT the VPP verify type. + s.runWorker() + cmd, err := iosDevice.Idle() + require.NoError(t, err) + for cmd != nil { + var fullCmd micromdm.CommandPayload + switch cmd.Command.RequestType { + case "InstalledApplicationList": + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + require.True(t, strings.HasPrefix(cmd.CommandUUID, fleet.RefetchDeviceCommandUUIDPrefix)) + cmd, err = iosDevice.AcknowledgeInstalledApplicationList( + iosDevice.UUID, + cmd.CommandUUID, + []fleet.Software{ + { + Name: "RandomApp", + BundleIdentifier: "com.example.randomapp", + Version: "9.9.9", + Installed: false, + }, + { + Name: iOSApp.Name, + BundleIdentifier: iOSApp.BundleIdentifier, + Version: iOSApp.LatestVersion, + Installed: true, + }, + }, + ) + require.NoError(t, err) + default: + require.Fail(t, "unexpected MDM command on client", cmd.Command.RequestType) + } + } }