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)
+ }
+ }
}