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.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [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


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ian Littman <iansltx@gmail.com>
This commit is contained in:
Jahziel Villasana-Espinoza 2025-07-03 20:52:45 -04:00 committed by GitHub
parent e24b412e31
commit b51344aeb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 245 additions and 59 deletions

View file

@ -326,6 +326,7 @@ func (svc *MDMAppleCommander) InstalledApplicationList(ctx context.Context, host
<string>Name</string>
<string>ShortVersion</string>
<string>Identifier</string>
<string>Installing</string>
</array>
</dict>
<key>CommandUUID</key>

View file

@ -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",
)
}
}

View file

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