fleet/server/service/integration_vpp_install_test.go
Ian Littman 0d29f2bfc0
Add custom software icons (#32652)
For #29478, sans GitOps.

---------

Co-authored-by: RachelElysia <71795832+RachelElysia@users.noreply.github.com>
Co-authored-by: Konstantin Sykulev <konst@sykulev.com>
2025-09-05 17:31:03 -05:00

901 lines
37 KiB
Go

package service
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleetdbase"
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/jmoiron/sqlx"
micromdm "github.com/micromdm/micromdm/mdm/mdm"
"github.com/micromdm/plist"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func (s *integrationMDMTestSuite) setVPPTokenForTeam(teamID uint) {
t := s.T()
// Valid token
orgName := "Fleet Device Management Inc."
token := "mycooltoken"
expTime := time.Now().Add(200 * time.Hour).UTC().Round(time.Second)
expDate := expTime.Format(fleet.VPPTimeFormat)
tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName)
t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL)
var validToken uploadVPPTokenResponse
s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "", &validToken)
s.lastActivityMatches(fleet.ActivityEnabledVPP{}.ActivityName(), "", 0)
// Get the token
var resp getVPPTokensResponse
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &resp)
require.NoError(t, resp.Err)
// Associate team to the VPP token.
var resPatchVPP patchVPPTokensTeamsResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", resp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{teamID}}, http.StatusOK, &resPatchVPP)
}
func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() {
// ===============================
// Initial setup
// ===============================
t := s.T()
s.setSkipWorkerJobs(t)
// Create a team
var newTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1")}}, http.StatusOK, &newTeamResp)
team := newTeamResp.Team
s.setVPPTokenForTeam(team.ID)
getSoftwareTitleIDFromApp := func(app *fleet.VPPApp) uint {
var titleID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
ctx := context.Background()
return sqlx.GetContext(ctx, q, &titleID, `SELECT title_id FROM vpp_apps WHERE adam_id = ? AND platform = ?`, app.AdamID, app.Platform)
})
return titleID
}
// Add macOS and iOS apps to team 1
macOSApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "1",
Platform: fleet.MacOSPlatform,
},
},
Name: "App 1",
BundleIdentifier: "a-1",
IconURL: "https://example.com/images/1",
LatestVersion: "1.0.0",
}
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,
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
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
if manifest := fullCmd.Command.InstallEnterpriseApplication.ManifestURL; manifest != nil {
foundInstallFleetdCommand = true
require.Equal(t, "InstallEnterpriseApplication", cmd.Command.RequestType)
require.Contains(t, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL, fleetdbase.GetPKGManifestURL())
}
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
require.Equal(t, wantCommand, foundInstallFleetdCommand)
}
// 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)
s.runWorker()
checkInstallFleetdCommandSent(mdmDevice, true)
selfServiceHost, selfServiceDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
s.runWorker()
setOrbitEnrollment(t, selfServiceHost, s.ds)
selfServiceToken := "selfservicetoken"
checkInstallFleetdCommandSent(selfServiceDevice, true)
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")
// Add serial number to our fake Apple server
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, mdmHost.HardwareSerial)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{mdmHost.ID, orbitHost.ID, selfServiceHost.ID}, TeamID: &team.ID}, http.StatusOK)
// Add all apps to the team
errApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "2",
Platform: fleet.MacOSPlatform,
},
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
LatestVersion: "2.0.0",
}
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),
"available_for_install", "true")
// Add remaining as non-self-service
for _, app := range expectedApps {
addAppResp = addAppStoreAppResponse{}
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps",
&addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: app.AdamID, Platform: app.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,
app.Name, getSoftwareTitleIDFromApp(app), app.AdamID, team.ID, app.Platform),
0,
)
}
listSw = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw, "team_id", fmt.Sprint(team.ID), "available_for_install", "true")
require.Len(t, listSw.SoftwareTitles, len(expectedApps))
var errTitleID, macOSTitleID uint
for _, sw := range listSw.SoftwareTitles {
require.NotNil(t, sw.AppStoreApp)
switch {
case sw.Name == addedApp.Name && sw.Source == "apps":
macOSTitleID = sw.ID
case sw.Name == errApp.Name && sw.Source == "apps":
errTitleID = sw.ID
}
}
// ================================
// Install attempts
// ================================
checkCommandsInFlight := func(expectedCount int) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var count int
err := sqlx.GetContext(context.Background(), q, &count, "SELECT COUNT(*) FROM host_mdm_commands WHERE command_type = ?", fleet.VerifySoftwareInstallVPPPrefix)
require.NoError(t, err)
require.Equal(t, expectedCount, count)
return nil
})
}
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 := 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 opts.failOnInstall {
t.Logf("Failed command UUID: %s", installCmdUUID)
cmd, err = mdmClient.Err(cmd.CommandUUID, []mdm.ErrorChain{{ErrorCode: 1234}})
require.NoError(t, err)
continue
}
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 = mdmClient.AcknowledgeInstalledApplicationList(
mdmClient.UUID,
cmd.CommandUUID,
[]fleet.Software{
{
Name: "RandomApp",
BundleIdentifier: "com.example.randomapp",
Version: "9.9.9",
Installed: false,
},
{
Name: app.Name,
BundleIdentifier: app.BundleIdentifier,
Version: app.LatestVersion,
Installed: opts.appInstallVerified,
},
},
)
require.NoError(t, err)
return ""
default:
require.Fail(t, "unexpected MDM command on client", cmd.Command.RequestType)
}
}
if opts.failOnInstall {
return installCmdUUID
}
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
})
}
// Process the verification command (InstalledApplicationList)
s.runWorker()
// Check that there is now a verify command in flight
checkCommandsInFlight(1)
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 = mdmClient.AcknowledgeInstalledApplicationList(
mdmClient.UUID,
cmd.CommandUUID,
[]fleet.Software{
{
Name: "RandomApp",
BundleIdentifier: "com.example.randomapp",
Version: "9.9.9",
Installed: false,
},
{
Name: app.Name,
BundleIdentifier: app.BundleIdentifier,
Version: app.LatestVersion,
Installed: opts.appInstallVerified,
},
},
)
require.NoError(t, err)
default:
require.Fail(t, "unexpected MDM command on client", cmd.Command.RequestType)
}
}
return installCmdUUID
}
checkVPPApp := func(got *fleet.HostSoftwareWithInstaller, expected *fleet.VPPApp, expectedCmdUUID string, expectedStatus fleet.SoftwareInstallerStatus) {
require.Equal(t, expected.Name, got.Name)
require.NotNil(t, got.AppStoreApp)
require.Equal(t, expected.AdamID, got.AppStoreApp.AppStoreID)
require.Equal(t, ptr.String(expected.IconURL), got.IconUrl)
require.Empty(t, got.AppStoreApp.Name) // Name is only present for installer packages
require.Equal(t, expected.LatestVersion, got.AppStoreApp.Version)
require.NotNil(t, got.Status)
require.Equal(t, expectedStatus, *got.Status)
require.Equal(t, expectedCmdUUID, got.AppStoreApp.LastInstall.CommandUUID)
require.NotNil(t, got.AppStoreApp.LastInstall.InstalledAt)
}
// ================================
// 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{},
http.StatusAccepted, &installResp)
// Check if the host is listed as pending
var listResp listHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "pending", "team_id", fmt.Sprint(team.ID),
"software_title_id", fmt.Sprint(errTitleID))
require.Len(t, listResp.Hosts, 1)
require.Equal(t, listResp.Hosts[0].ID, mdmHost.ID)
var 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(errTitleID))
require.Equal(t, 1, countResp.Count)
// Simulate failed installation on the host
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 {
var count uint
err := sqlx.GetContext(context.Background(), q, &count, "SELECT COUNT(*) FROM upcoming_activities WHERE host_id = ?", mdmHost.ID)
require.NoError(t, err)
require.Zero(t, count)
return nil
})
listResp = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "failed", "team_id", fmt.Sprint(team.ID),
"software_title_id", fmt.Sprint(errTitleID))
require.Len(t, listResp.Hosts, 1)
require.Equal(t, listResp.Hosts[0].ID, mdmHost.ID)
countResp = countHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "failed", "team_id",
fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(errTitleID))
require.Equal(t, 1, countResp.Count)
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}`,
mdmHost.ID,
mdmHost.DisplayName(),
errApp.Name,
errApp.AdamID,
failedCmdUUID,
fleet.SoftwareInstallFailed,
),
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
// ================================================
// Trigger install to the host
installResp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, macOSTitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
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(macOSTitleID))
require.Equal(t, 1, countResp.Count)
// Simulate successful installation on the host
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",
fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID))
require.Len(t, listResp.Hosts, 1)
countResp = countHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "installed", "team_id",
fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID))
require.Equal(t, 1, countResp.Count)
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}`,
mdmHost.ID,
mdmHost.DisplayName(),
addedApp.Name,
addedApp.AdamID,
installCmdUUID,
fleet.SoftwareInstalled,
),
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)
gotSW := getHostSw.Software
require.Len(t, gotSW, 2) // App 1 and App 2
got1, got2 := gotSW[0], gotSW[1]
checkVPPApp(got1, addedApp, installCmdUUID, fleet.SoftwareInstalled)
checkVPPApp(got2, errApp, failedCmdUUID, fleet.SoftwareInstallFailed)
// ================================================
// Successful install and delayed verification
// ================================================
// Trigger install to the host
installResp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, macOSTitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
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(macOSTitleID))
require.Equal(t, 1, countResp.Count)
// Install is ACK, but not verified yet
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{}
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(macOSTitleID))
require.Len(t, listResp.Hosts, 0)
countResp = countHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "installed", "team_id",
fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID))
require.Equal(t, 0, countResp.Count)
// We should instead have 1 pending
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost.ID), nil, http.StatusOK, &getHostSw)
gotSW = getHostSw.Software
require.Len(t, gotSW, 2) // App 1 and App 2
checkVPPApp(gotSW[0], addedApp, installCmdUUID, fleet.SoftwareInstallPending)
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, 1, countResp.Count)
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "pending", "team_id",
fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID))
require.Len(t, listResp.Hosts, 1)
// 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(mdmDevice, opts)
// We should have 0 installed, because the verification is not done yet
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(macOSTitleID))
require.Len(t, listResp.Hosts, 0)
countResp = countHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "installed", "team_id",
fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID))
require.Equal(t, 0, countResp.Count)
// We should instead have 1 pending
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost.ID), nil, http.StatusOK, &getHostSw)
gotSW = getHostSw.Software
require.Len(t, gotSW, 2) // App 1 and App 2
checkVPPApp(gotSW[0], addedApp, installCmdUUID, fleet.SoftwareInstallPending)
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, 1, countResp.Count)
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "pending", "team_id",
fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID))
require.Len(t, listResp.Hosts, 1)
// 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)
opts.appInstallVerified = true
processVPPInstallOnClient(mdmDevice, opts)
checkCommandsInFlight(0)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost.ID), nil, http.StatusOK, &getHostSw)
gotSW = getHostSw.Software
require.Len(t, gotSW, 2) // App 1 and App 2
checkVPPApp(gotSW[0], addedApp, installCmdUUID, fleet.SoftwareInstalled)
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}`,
mdmHost.ID,
mdmHost.DisplayName(),
addedApp.Name,
addedApp.AdamID,
installCmdUUID,
fleet.SoftwareInstalled,
),
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
// ========================================================
// Trigger install to the host
installResp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, macOSTitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
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(macOSTitleID))
require.Equal(t, 1, countResp.Count)
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",
fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID))
require.Empty(t, listResp.Hosts)
countResp = countHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "failed", "team_id",
fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(macOSTitleID))
require.Equal(t, 1, countResp.Count)
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}`,
mdmHost.ID,
mdmHost.DisplayName(),
addedApp.Name,
addedApp.AdamID,
installCmdUUID,
fleet.SoftwareInstallFailed,
),
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)
gotSW = getHostSw.Software
require.Len(t, gotSW, 2) // App 1 and App 2
got1, got2 = gotSW[0], gotSW[1]
checkVPPApp(got1, addedApp, installCmdUUID, fleet.SoftwareInstallFailed)
checkVPPApp(got2, errApp, failedCmdUUID, fleet.SoftwareInstallFailed)
// ========================================================
// Mark installs as failed when MDM turned off on host
// ========================================================
// Trigger install to the host
installResp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, macOSTitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
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(macOSTitleID))
require.Equal(t, 1, countResp.Count)
// Trigger install to the self-service device (its data shouldn't be changed)
installResp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", selfServiceHost.ID, macOSTitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
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(macOSTitleID))
require.Equal(t, 2, countResp.Count)
// Trigger verification on other host
opts.failOnInstall = false
opts.appInstallVerified = false
opts.appInstallTimeout = false
processVPPInstallOnClient(selfServiceDevice, opts)
s.runWorker()
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", mdmHost.ID), nil, http.StatusNoContent)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
// We should have cleared out upcoming_activies when disabling MDM
var count int
err := sqlx.GetContext(context.Background(), q, &count, "SELECT COUNT(*) FROM upcoming_activities WHERE host_id = ?", mdmHost.ID)
require.NoError(t, err)
require.Zero(t, count)
installCmdUUID = ""
// Get the UUID for the latest install
err = sqlx.GetContext(
context.Background(),
q,
&installCmdUUID,
"SELECT command_uuid FROM host_vpp_software_installs WHERE host_id = ? AND adam_id = ? ORDER BY verification_failed_at DESC",
mdmHost.ID,
addedApp.AdamID,
)
require.NotEmpty(t, installCmdUUID)
require.NoError(t, err)
count = 99999
// We also should have cleared out host_mdm_commands to avoid a deadlocked state
err = sqlx.GetContext(context.Background(), q, &count, "SELECT COUNT(*) FROM host_mdm_commands WHERE host_id = ? AND command_type = ?", mdmHost.ID, fleet.VerifySoftwareInstallVPPPrefix)
require.NoError(t, err)
require.Zero(t, count)
// The other host should have a verification command pending
err = sqlx.GetContext(context.Background(), q, &count, "SELECT COUNT(*) FROM host_mdm_commands WHERE host_id = ? AND command_type = ?", selfServiceHost.ID, fleet.VerifySoftwareInstallVPPPrefix)
require.NoError(t, err)
require.Equal(t, 1, count)
return nil
})
// Cancel the install for the other host, we don't need it anymore
var listUpcomingAct listHostUpcomingActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", selfServiceHost.ID), nil, http.StatusOK, &listUpcomingAct)
require.Len(t, listUpcomingAct.Activities, 1)
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming/%s", selfServiceHost.ID, listUpcomingAct.Activities[0].UUID), nil, http.StatusNoContent)
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost.ID), nil, http.StatusOK, &getHostSw)
gotSW = getHostSw.Software
require.Len(t, gotSW, 2) // App 1 and App 2
got1, got2 = gotSW[0], gotSW[1]
checkVPPApp(got1, addedApp, installCmdUUID, fleet.SoftwareInstallFailed)
checkVPPApp(got2, errApp, failedCmdUUID, fleet.SoftwareInstallFailed)
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)
// ========================================================
// Mark installs as failed when MDM turned off globally
// ========================================================
// Re-enroll host in MDM
mdmDevice = enrollMacOSHostInMDM(t, mdmHost, s.ds, s.server.URL)
s.runWorker()
checkInstallFleetdCommandSent(mdmDevice, true)
// Trigger install to the host
installResp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, macOSTitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
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(macOSTitleID))
require.Equal(t, 1, countResp.Count)
s.Do("DELETE", "/api/latest/fleet/mdm/apple/apns_certificate", nil, http.StatusOK)
t.Cleanup(s.appleCoreCertsSetup)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
// We should have cleared out upcoming_activies when disabling MDM
var count uint
err := sqlx.GetContext(context.Background(), q, &count, "SELECT COUNT(*) FROM upcoming_activities WHERE host_id = ?", mdmHost.ID)
require.NoError(t, err)
require.Zero(t, count)
installCmdUUID = ""
// Get the UUID for the latest install
err = sqlx.GetContext(
context.Background(),
q,
&installCmdUUID,
"SELECT command_uuid FROM host_vpp_software_installs WHERE host_id = ? AND adam_id = ? ORDER BY verification_failed_at DESC",
mdmHost.ID,
addedApp.AdamID,
)
require.NoError(t, err)
require.NotEmpty(t, installCmdUUID)
return nil
})
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost.ID), nil, http.StatusOK, &getHostSw)
gotSW = getHostSw.Software
require.Len(t, gotSW, 2) // App 1 and App 2
got1, got2 = gotSW[0], gotSW[1]
checkVPPApp(got1, addedApp, installCmdUUID, fleet.SoftwareInstallFailed)
checkVPPApp(got2, errApp, failedCmdUUID, fleet.SoftwareInstallFailed)
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 == "ios_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)
// Before installation, we should have 0 refetch commands
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var count int
err := sqlx.GetContext(context.Background(), q, &count, "SELECT COUNT(*) FROM host_mdm_commands WHERE host_id = ? AND command_type = ?", iosHost.ID, fleet.RefetchAppsCommandUUIDPrefix)
require.NoError(t, err)
require.Zero(t, count)
return nil
})
// 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))
assert.Len(t, listResp.Hosts, 1)
assert.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.False(t, hostResp.Host.RefetchRequested, "RefetchRequested should be false after successful software install for iDevice")
// Now we have a refetch apps command in flight to update the host software inventory
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var count int
err := sqlx.GetContext(context.Background(), q, &count, "SELECT COUNT(*) FROM host_mdm_commands WHERE host_id = ?", iosHost.ID)
require.NoError(t, err)
require.Equal(t, count, 1)
return nil
})
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.RefetchAppsCommandUUIDPrefix))
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)
}
}
// we should also have the installed version, because we update host software inventory on verification
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", iosHost.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true")
assert.Len(t, getHostSw.Software, 1)
assert.Equal(t, iosTitleID, getHostSw.Software[0].ID)
assert.NotNil(t, getHostSw.Software[0].AppStoreApp)
assert.Len(t, getHostSw.Software[0].InstalledVersions, 1)
assert.Equal(t, iOSApp.LatestVersion, getHostSw.Software[0].InstalledVersions[0].Version)
}