fleet/server/service/integration_vpp_install_test.go
Eliott Hauteclair 3f9bd7c2d5
Add support for iPods under iOS devices (#27432)
Currently in Fleet Device Management, there is no support for Apple
iPods.

Eventhough iPods are considered vintage by Apple already, we still use
them and I know that in various companies they are still used as a low
cost device within the company. (eg. shops/warehouses to look up stock
levels)

Currently, enrolling an iPod through ABM, results in the device being
recognised as a Mac device.

With this PR, I'd like to add support for iPods, similar functionality
as iPhones to Fleet, simply as iOS device, which works fine. Considering
that all commands are the same (if available) and considering iPods
aren't updated anymore, I don't think we need to explicitly mention it,
perhaps just in docs, and add them to a separate category than iPhones.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.
- [ ] Added/updated automated tests
- I have not added automated tests since it'd basically be a 1:1 copy of
iPhone tests
- [x] Manual QA for all new/changed functionality

> Follows up on discussion from #27263 with @noahtalerman 

Manual QA:

- adding an iPod in ABM results in the device being recognised as iOS
<img width="1754" alt="overview"
src="https://github.com/user-attachments/assets/7681c613-2b34-489a-8b94-10eff8977e19"
/>
<img width="1766" alt="detail-abm"
src="https://github.com/user-attachments/assets/f88c8e84-e55f-4c5f-8998-8b6697b57abc"
/>

- after enrolling the iPod through setup, it is correctly synced with
Fleet and all commands are possible. (tried Restart, Rename device, push
apps)
<img width="1766" alt="ipod-post-sync"
src="https://github.com/user-attachments/assets/7668942e-b110-4c38-a448-b6027419507c"
/>

- enrollment video (can be uploaded if needed)

- manual enrollment works fine too (using Enroll url)

![image](https://github.com/user-attachments/assets/db3073b7-25e0-4ba6-b6a8-e7cbbb56d5d6)

---------

Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com>
2026-01-29 13:54:37 -05:00

2853 lines
120 KiB
Go

package service
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/apple_apps"
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
"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)
dev_mode.SetOverride("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL, t)
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 vpp 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/512x512.png",
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/512x512.png",
LatestVersion: "2.0.0",
}
iPadOSApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "3",
Platform: fleet.IPadOSPlatform,
},
},
Name: "App 3",
BundleIdentifier: "c-3",
IconURL: "https://example.com/images/3/512x512.png",
LatestVersion: "3.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)
// Add iPadOS app to team
addAppResp = addAppStoreAppResponse{}
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: iPadOSApp.AdamID, SelfService: false, Platform: iPadOSApp.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,
iPadOSApp.Name, getSoftwareTitleIDFromApp(iPadOSApp), iPadOSApp.AdamID, team.ID, iPadOSApp.Platform), 0)
// 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(t, mdmDevice, true)
selfServiceHost, selfServiceDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
s.runWorker()
setOrbitEnrollment(t, selfServiceHost, s.ds)
selfServiceToken := "selfservicetoken"
checkInstallFleetdCommandSent(t, 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/512x512.png",
LatestVersion: "2.0.1", // macOS has different version than iOS
}
expectedApps := []*fleet.VPPApp{macOSApp, errApp, iOSApp, iPadOSApp}
expectedAppsByBundleID := map[string]*fleet.VPPApp{
macOSApp.BundleIdentifier: macOSApp,
errApp.BundleIdentifier: errApp,
iOSApp.BundleIdentifier: iOSApp,
iPadOSApp.BundleIdentifier: iPadOSApp,
}
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, 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, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`,
mdmHost.ID,
mdmHost.DisplayName(),
errApp.Name,
errApp.AdamID,
failedCmdUUID,
fleet.SoftwareInstallFailed,
mdmHost.Platform,
),
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, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`,
mdmHost.ID,
mdmHost.DisplayName(),
addedApp.Name,
addedApp.AdamID,
installCmdUUID,
fleet.SoftwareInstalled,
mdmHost.Platform,
),
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, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`,
mdmHost.ID,
mdmHost.DisplayName(),
addedApp.Name,
addedApp.AdamID,
installCmdUUID,
fleet.SoftwareInstalled,
mdmHost.Platform,
),
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, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`,
mdmHost.ID,
mdmHost.DisplayName(),
addedApp.Name,
addedApp.AdamID,
installCmdUUID,
fleet.SoftwareInstallFailed,
mdmHost.Platform,
),
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 = enrollMacOSHostInMDMManually(t, mdmHost, s.ds, s.server.URL)
s.runWorker()
checkInstallFleetdCommandSent(t, 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 and ipod device, add serial number to fake Apple server, and transfer to team
iosHost, iosDevice := s.createAppleMobileHostThenEnrollMDM("ios")
ipodHost, ipodDevice := s.createIpodHostThenEnrollMDM()
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, iosDevice.SerialNumber, ipodDevice.SerialNumber)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{iosHost.ID, ipodHost.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)
var ipadosTitleID uint
for _, sw := range listSw.SoftwareTitles {
if sw.Name == iPadOSApp.Name && sw.Source == "ipados_apps" {
ipadosTitleID = sw.ID
break
}
}
require.NotZero(t, ipadosTitleID)
// 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, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`,
iosHost.ID,
iosHost.DisplayName(),
iOSApp.Name,
iOSApp.AdamID,
installCmdUUID,
fleet.SoftwareInstalled,
iosHost.Platform,
),
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)
// ========================================================
// Test iOS VPP app installation for ipod device
// ========================================================
// Trigger install to the iOS device
installResp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", ipodHost.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 = ?", ipodHost.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(ipodDevice, 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, 2) // iosHost and ipodHost
assert.Equal(t, ipodHost.ID, listResp.Hosts[1].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, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`,
ipodHost.ID,
ipodHost.DisplayName(),
iOSApp.Name,
iOSApp.AdamID,
installCmdUUID,
fleet.SoftwareInstalled,
ipodHost.Platform,
),
0,
)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", ipodHost.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 = ?", ipodHost.ID)
require.NoError(t, err)
require.Equal(t, count, 1)
return nil
})
s.runWorker()
cmdIpod, err := ipodDevice.Idle()
require.NoError(t, err)
for cmdIpod != nil {
var fullCmd micromdm.CommandPayload
switch cmdIpod.Command.RequestType {
case "InstalledApplicationList":
require.NoError(t, plist.Unmarshal(cmdIpod.Raw, &fullCmd))
require.True(t, strings.HasPrefix(cmdIpod.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix))
cmdIpod, err = ipodDevice.AcknowledgeInstalledApplicationList(
ipodDevice.UUID,
cmdIpod.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", cmdIpod.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", ipodHost.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)
// ========================================================
// Test iOS VPP app self service installation
// ========================================================
type SSVPPTestData struct {
host *fleet.Host
app *fleet.VPPApp
device *mdmtest.TestAppleMDMClient
platform string
titleID uint
certSerial uint64
expectedHostCount int
}
// Edit iOS app to enable self service
require.NotZero(t, iosTitleID)
require.NotZero(t, ipadosTitleID)
ssVppData := []SSVPPTestData{
{platform: "ios", titleID: iosTitleID, app: iOSApp, certSerial: uint64(1111), expectedHostCount: 3}, // expectHostCount is from iosHost, ipodHost, and the new ios device
{platform: "ipados", titleID: ipadosTitleID, app: iPadOSApp, certSerial: uint64(2222), expectedHostCount: 1}, // no ipad has installed an app, so we expect 1 only for this device
}
for _, data := range ssVppData {
// Enroll device, add serial number to fake Apple server, and transfer to team
data.host, data.device = s.createAppleMobileHostThenEnrollMDM(data.platform)
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, data.device.SerialNumber)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{data.host.ID}, TeamID: &team.ID}, http.StatusOK)
// Refresh host to get UUID
data.host, err = s.ds.Host(context.Background(), data.host.ID)
require.NoError(t, err)
// Use certificate authentication
headers := map[string]string{
"X-Client-Cert-Serial": fmt.Sprintf("%d", data.certSerial),
}
s.addHostIdentityCertificate(data.host.UUID, data.certSerial)
// self-install without cert header (UUID auth fallback for iOS/iPadOS)
// With fallback auth, UUID auth succeeds for iOS/iPadOS devices, so we get 400 (bad title) instead of 401
res := s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", data.host.UUID, 999), nil, http.StatusBadRequest)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Software title is not available for install.")
// self-install a non-existing title (with cert header - same result)
res = s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", data.host.UUID, 999), nil, http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Software title is not available for install.")
// self-install an existing title not available for self-install
res = s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", data.host.UUID, data.titleID), nil, http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Software title is not available through self-service")
// Enable self-service for vpp app
updateAppResp := updateAppStoreAppResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", data.titleID),
&updateAppStoreAppRequest{TitleID: data.titleID, TeamID: &team.ID, SelfService: ptr.Bool(true)}, http.StatusOK, &updateAppResp)
// Install self-service app correctly
s.DoRawWithHeaders("POST", fmt.Sprintf("/api/latest/fleet/device/%s/software/install/%d", data.host.UUID, data.titleID), nil, http.StatusAccepted, headers)
// 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(data.titleID))
require.Equal(t, 1, countResp.Count)
// Simulate successful installation on device
opts := vppInstallOpts{
appInstallTimeout: false,
appInstallVerified: true,
failOnInstall: false,
bundleID: data.app.BundleIdentifier,
}
installCmdUUID = processVPPInstallOnClient(data.device, 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(data.titleID))
assert.Len(t, listResp.Hosts, data.expectedHostCount)
assert.Equal(t, data.host.ID, listResp.Hosts[len(listResp.Hosts)-1].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": true, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_auto_update": false}`,
data.host.ID,
data.host.DisplayName(),
data.app.Name,
data.app.AdamID,
installCmdUUID,
fleet.SoftwareInstalled,
data.host.Platform,
),
0,
)
}
}
// for https://github.com/fleetdm/fleet/issues/31083
func (s *integrationMDMTestSuite) TestVPPAppActivitiesOnCancelInstall() {
t := s.T()
s.setSkipWorkerJobs(t)
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)
// Add app 1 and 2 targeting macOS to the team
app1 := &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/512x512.png",
LatestVersion: "1.0.0",
}
app2 := &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/512x512.png",
LatestVersion: "2.0.0",
}
var addAppResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: app1.AdamID, SelfService: true}, http.StatusOK, &addAppResp)
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: app2.AdamID, SelfService: false}, http.StatusOK, &addAppResp)
// list the software titles to get the title IDs
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")
var app1TitleID, app2TitleID uint
for _, sw := range listSw.SoftwareTitles {
require.NotNil(t, sw.AppStoreApp)
switch sw.Name {
case app1.Name:
app1TitleID = sw.ID
case app2.Name:
app2TitleID = sw.ID
}
}
// create a control host that will not be used in the test, should be unaffected
controlHost, controlDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setOrbitEnrollment(t, controlHost, s.ds)
s.runWorker()
checkInstallFleetdCommandSent(t, controlDevice, true)
// Add serial number to our fake Apple server
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, controlHost.HardwareSerial)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{controlHost.ID}, TeamID: &team.ID}, http.StatusOK)
// trigger a VPP app install on the control host, will stay there until the end
var installResp installSoftwareResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", controlHost.ID, app1TitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
// create a host that will receive the VPP install commands AFTER a script execution request
// (so the VPP installs are not activated when they are cancelled)
mdmHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setOrbitEnrollment(t, mdmHost, s.ds)
s.runWorker()
checkInstallFleetdCommandSent(t, mdmDevice, true)
// 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}, TeamID: &team.ID}, http.StatusOK)
// enqueue a script run, so the VPP app installs are pending in the unified
// queue
var runResp runScriptResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: mdmHost.ID, ScriptContents: "echo"}, http.StatusAccepted, &runResp)
// trigger install of both apps on the host
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, app1TitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, app2TitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
// confirm the state of this host's upcoming activities
var listResp listHostUpcomingActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", mdmHost.ID), nil, http.StatusOK, &listResp)
require.Len(t, listResp.Activities, 3)
require.Equal(t, fleet.ActivityTypeRanScript{}.ActivityName(), listResp.Activities[0].Type)
require.Equal(t, fleet.ActivityInstalledAppStoreApp{}.ActivityName(), listResp.Activities[1].Type)
require.Contains(t, string(*listResp.Activities[1].Details), fmt.Sprintf(`"app_store_id": %q`, app1.AdamID))
require.Equal(t, fleet.ActivityInstalledAppStoreApp{}.ActivityName(), listResp.Activities[2].Type)
require.Contains(t, string(*listResp.Activities[2].Details), fmt.Sprintf(`"app_store_id": %q`, app2.AdamID))
// listing the host's software shows them as pending install
var getHostSw getHostSoftwareResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true")
require.Len(t, getHostSw.Software, 2)
require.NotNil(t, getHostSw.Software[0].Status)
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[0].Status)
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
require.NotNil(t, getHostSw.Software[1].Status)
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[1].Status)
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
// turn off MDM for the host
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", mdmHost.ID), nil, http.StatusNoContent)
s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMUnenrolled{}.ActivityName(), fmt.Sprintf(`{"enrollment_id": null, "host_display_name":%q, "host_serial":%q, "installed_from_dep":false, "platform": "darwin"}`, mdmHost.DisplayName(), mdmHost.HardwareSerial), 0)
// upcoming activities now have only the script
listResp = listHostUpcomingActivitiesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", mdmHost.ID), nil, http.StatusOK, &listResp)
require.Len(t, listResp.Activities, 1)
require.Equal(t, fleet.ActivityTypeRanScript{}.ActivityName(), listResp.Activities[0].Type)
// host's past activities do not have the VPP apps cancellation because those app installs
// were not activated
var listPastResp listActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", mdmHost.ID), nil, http.StatusOK, &listPastResp)
require.GreaterOrEqual(t, len(listPastResp.Activities), 0)
// listing the host's software available for install shows none as MDM is now disabled
// and no failure was recorded for the attempts (because the apps were not activated)
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true")
require.Len(t, getHostSw.Software, 0)
// create another host that will receive the VPP install commands without any
// other activity in front (so the first VPP install will be activated when
// they are cancelled)
mdmHost2, mdmDevice2 := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setOrbitEnrollment(t, mdmHost2, s.ds)
s.runWorker()
checkInstallFleetdCommandSent(t, mdmDevice2, true)
// Add serial number to our fake Apple server
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, mdmHost2.HardwareSerial)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{mdmHost2.ID}, TeamID: &team.ID}, http.StatusOK)
// trigger install of both apps on the host
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost2.ID, app1TitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost2.ID, app2TitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
// confirm the state of this host's upcoming activities
listResp = listHostUpcomingActivitiesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", mdmHost2.ID), nil, http.StatusOK, &listResp)
require.Len(t, listResp.Activities, 2)
require.Equal(t, fleet.ActivityInstalledAppStoreApp{}.ActivityName(), listResp.Activities[0].Type)
require.Contains(t, string(*listResp.Activities[0].Details), fmt.Sprintf(`"app_store_id": %q`, app1.AdamID))
require.Equal(t, fleet.ActivityInstalledAppStoreApp{}.ActivityName(), listResp.Activities[1].Type)
require.Contains(t, string(*listResp.Activities[1].Details), fmt.Sprintf(`"app_store_id": %q`, app2.AdamID))
// listing the host's software shows them as pending install
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost2.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true")
require.Len(t, getHostSw.Software, 2)
require.NotNil(t, getHostSw.Software[0].Status)
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[0].Status)
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
require.NotNil(t, getHostSw.Software[1].Status)
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[1].Status)
require.NotNil(t, getHostSw.Software[1].AppStoreApp)
require.Equal(t, app2.AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID)
// turn off MDM for the host
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", mdmHost2.ID), nil, http.StatusNoContent)
s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMUnenrolled{}.ActivityName(), fmt.Sprintf(`{"enrollment_id": null, "host_display_name":%q, "host_serial":%q, "installed_from_dep":false, "platform": "darwin"}`, mdmHost2.DisplayName(), mdmHost2.HardwareSerial), 0)
// upcoming activities are now empty
listResp = listHostUpcomingActivitiesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", mdmHost2.ID), nil, http.StatusOK, &listResp)
require.Len(t, listResp.Activities, 0)
// host's past activities should have the first VPP app cancellation because it was activated
listPastResp = listActivitiesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", mdmHost2.ID), nil, http.StatusOK, &listPastResp)
require.GreaterOrEqual(t, len(listPastResp.Activities), 1)
require.Equal(t, fleet.ActivityInstalledAppStoreApp{}.ActivityName(), listPastResp.Activities[0].Type)
require.Contains(t, string(*listPastResp.Activities[0].Details), fmt.Sprintf(`"app_store_id": %q`, app1.AdamID))
require.Contains(t, string(*listPastResp.Activities[0].Details), `"status": "failed_install"`)
if len(listPastResp.Activities) > 1 {
// the second activity should not be the cancellation of the second app
require.Equal(t, fleet.ActivityInstalledAppStoreApp{}.ActivityName(), listPastResp.Activities[1].Type)
}
// listing the host's software available for install shows the cancelled app as failed
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost2.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true")
require.Len(t, getHostSw.Software, 1)
require.NotNil(t, getHostSw.Software[0].Status)
require.Equal(t, fleet.SoftwareInstallFailed, *getHostSw.Software[0].Status)
require.NotNil(t, getHostSw.Software[0].AppStoreApp)
require.Equal(t, app1.AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID)
// upcoming activities on the control host are unaffected
listResp = listHostUpcomingActivitiesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", controlHost.ID), nil, http.StatusOK, &listResp)
require.Len(t, listResp.Activities, 1)
require.Equal(t, fleet.ActivityInstalledAppStoreApp{}.ActivityName(), listResp.Activities[0].Type)
require.Contains(t, string(*listResp.Activities[0].Details), fmt.Sprintf(`"app_store_id": %q`, app1.AdamID))
}
// for https://github.com/fleetdm/fleet/issues/32082
func (s *integrationMDMTestSuite) TestSoftwareTitleVPPAppSoftwarePackageConflict() {
t := s.T()
s.setSkipWorkerJobs(t)
s.registerResetVPPProxyData(t)
s.appleVPPProxySrvData = map[string]string{
"1": `{"id": "1", "attributes": {"name": "DummyApp", "platformAttributes": {"osx": {"bundleId": "com.example.dummy", "artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "1.0.0"}}}, "deviceFamilies": ["mac"]}}`,
"2": `{"id": "2", "attributes": {"name": "NoVersion", "platformAttributes": {"osx": {"bundleId": "com.example.noversion", "artwork": {"url": "https://example.com/images/2/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "2.0.0"}}}, "deviceFamilies": ["mac"]}}`,
}
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)
// Add VPP app 1 with bundle ID com.example.dummy (conflicts with DummyApp below)
vppApp1 := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "1",
Platform: fleet.MacOSPlatform,
},
},
}
var addAppResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: vppApp1.AdamID, SelfService: true}, http.StatusOK, &addAppResp)
// add the NoVersion installer with bundle id com.example.noversion (conflicts with VPP app 2 below)
pkgNoVersion := &fleet.UploadSoftwareInstallerPayload{
Filename: "no_version.pkg",
Title: "NoVersion",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, pkgNoVersion, http.StatusOK, "")
// the Dummy installer has bundle id com.example.dummy, it should fail with a
// conflict with VPP app 1
pkgDummy := &fleet.UploadSoftwareInstallerPayload{
Filename: "dummy_installer.pkg",
Title: "DummyApp",
TeamID: &team.ID,
}
s.uploadSoftwareInstaller(t, pkgDummy, http.StatusConflict, "DummyApp already has an installer available for the Team 1 team.")
// Add VPP app 2 with bundle ID com.example.noversion (conflicts with NoVersion)
vppApp2 := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "2",
Platform: fleet.MacOSPlatform,
},
},
}
res := s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: vppApp2.AdamID, SelfService: true}, http.StatusConflict)
txt := extractServerErrorText(res.Body)
require.Contains(t, txt, "NoVersion already has an installer available for the Team 1 team.")
// --- test with batch-set (gitops) ---
// start the HTTP server to serve package installers
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/no_version.pkg":
http.ServeFile(w, r, filepath.Join("testdata", "software-installers", "no_version.pkg"))
case "/dummy_installer.pkg":
http.ServeFile(w, r, filepath.Join("testdata", "software-installers", "dummy_installer.pkg"))
default:
w.WriteHeader(http.StatusNotFound)
return
}
}))
t.Cleanup(srv.Close)
// try to set DummyApp and NoVersion installers, but DummyApp conflicts with VPP app 1
var batchResponse batchSetSoftwareInstallersResponse
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{
Software: []*fleet.SoftwareInstallerPayload{
{URL: srv.URL + "/no_version.pkg", SHA256: "4ba383be20c1020e416958ab10e3b472a4d5532a8cd94ed720d495a9c81958fe"},
{URL: srv.URL + "/dummy_installer.pkg", SHA256: "7f679541ccfdb56094ca76117fd7cf75071c9d8f43bfd2a6c0871077734ca7c8"},
},
}, http.StatusAccepted, &batchResponse, "team_name", team.Name)
batchResp := waitBatchSetSoftwareInstallers(t, &s.withServer, team.Name, batchResponse.RequestUUID)
require.Equal(t, fleet.BatchSetSoftwareInstallersStatusFailed, batchResp.Status)
require.Contains(t, batchResp.Message, "DummyApp already has an installer available for the Team 1 team.")
// batch-set the VPP apps, including one in conflict
res = s.Do("POST", "/api/latest/fleet/software/app_store_apps/batch", batchAssociateAppStoreAppsRequest{Apps: []fleet.VPPBatchPayload{
{AppStoreID: "1"},
{AppStoreID: "2"},
}}, http.StatusConflict, "team_name", team.Name)
txt = extractServerErrorText(res.Body)
require.Contains(t, txt, "NoVersion already has an installer available for the Team 1 team.")
// listing software available to install only lists the dummy app and noversion installer
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")
require.Len(t, listSw.SoftwareTitles, 2)
require.Equal(t, "DummyApp", listSw.SoftwareTitles[0].Name)
require.NotNil(t, listSw.SoftwareTitles[0].AppStoreApp)
require.Equal(t, "1", listSw.SoftwareTitles[0].AppStoreApp.AppStoreID)
require.Equal(t, "NoVersion", listSw.SoftwareTitles[1].Name)
require.NotNil(t, listSw.SoftwareTitles[1].SoftwarePackage)
require.Equal(t, "no_version.pkg", listSw.SoftwareTitles[1].SoftwarePackage.Name)
// a different team can batch-add the two installers without conflict
newTeamResp = teamResponse{}
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 2")}}, http.StatusOK, &newTeamResp)
team2 := newTeamResp.Team
batchResponse = batchSetSoftwareInstallersResponse{}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{
Software: []*fleet.SoftwareInstallerPayload{
{URL: srv.URL + "/no_version.pkg", SHA256: "4ba383be20c1020e416958ab10e3b472a4d5532a8cd94ed720d495a9c81958fe"},
{URL: srv.URL + "/dummy_installer.pkg", SHA256: "7f679541ccfdb56094ca76117fd7cf75071c9d8f43bfd2a6c0871077734ca7c8"},
},
}, http.StatusAccepted, &batchResponse, "team_name", team2.Name)
batchResp = waitBatchSetSoftwareInstallers(t, &s.withServer, team2.Name, batchResponse.RequestUUID)
require.Equal(t, fleet.BatchSetSoftwareInstallersStatusCompleted, batchResp.Status)
require.Empty(t, batchResp.Message)
require.Len(t, batchResp.Packages, 2)
listSw = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw, "team_id", fmt.Sprint(team2.ID), "available_for_install", "true")
require.Len(t, listSw.SoftwareTitles, 2)
// both are software packages
require.Equal(t, "DummyApp", listSw.SoftwareTitles[0].Name)
require.NotNil(t, listSw.SoftwareTitles[0].SoftwarePackage)
require.Equal(t, "dummy_installer.pkg", listSw.SoftwareTitles[0].SoftwarePackage.Name)
require.Equal(t, "NoVersion", listSw.SoftwareTitles[1].Name)
require.NotNil(t, listSw.SoftwareTitles[1].SoftwarePackage)
require.Equal(t, "no_version.pkg", listSw.SoftwareTitles[1].SoftwarePackage.Name)
// a different team can batch-add the two VPP apps without conflict
newTeamResp = teamResponse{}
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 3")}}, http.StatusOK, &newTeamResp)
team3 := newTeamResp.Team
var tokenResp getVPPTokensResponse
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &tokenResp)
var resPatchVPP patchVPPTokensTeamsResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", tokenResp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID, team3.ID}}, http.StatusOK, &resPatchVPP)
var batchAppResp batchAssociateAppStoreAppsResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch", batchAssociateAppStoreAppsRequest{Apps: []fleet.VPPBatchPayload{
{AppStoreID: "1"},
{AppStoreID: "2"},
}}, http.StatusOK, &batchAppResp, "team_name", team3.Name)
require.Len(t, batchAppResp.Apps, 2)
listSw = listSoftwareTitlesResponse{}
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw, "team_id", fmt.Sprint(team3.ID), "available_for_install", "true")
require.Len(t, listSw.SoftwareTitles, 2)
// both are VPP apps
require.Equal(t, "DummyApp", listSw.SoftwareTitles[0].Name)
require.NotNil(t, listSw.SoftwareTitles[0].AppStoreApp)
require.Equal(t, "1", listSw.SoftwareTitles[0].AppStoreApp.AppStoreID)
require.Equal(t, "NoVersion", listSw.SoftwareTitles[1].Name)
require.NotNil(t, listSw.SoftwareTitles[1].AppStoreApp)
require.Equal(t, "2", listSw.SoftwareTitles[1].AppStoreApp.AppStoreID)
}
func (s *integrationMDMTestSuite) TestInHouseAppInstall() {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := context.Background()
// Enroll iPhone
iosHost, iosDevice := s.createAppleMobileHostThenEnrollMDM("ios")
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, iosDevice.SerialNumber)
// Create a label
clr := createLabelResponse{}
s.DoJSON("POST", "/api/latest/fleet/labels", createLabelRequest{
LabelPayload: fleet.LabelPayload{
Name: "foo",
HostIDs: []uint{iosHost.ID},
},
}, http.StatusOK, &clr)
// Upload in-house app for iOS, with the label as "exclude any"
s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{Filename: "ipa_test.ipa", LabelsExcludeAny: []string{"foo"}}, http.StatusOK, "")
// Get title ID
var titleID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &titleID, "SELECT title_id FROM in_house_apps WHERE filename = 'ipa_test.ipa'")
})
var resp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "team_id", "0")
assert.Len(t, resp.SoftwareTitles, 2)
assert.Equal(t, "ipa_test", resp.SoftwareTitles[0].Name)
titleID = resp.SoftwareTitles[0].ID
// Attempt installation on non-scoped app, should fail
var installResp installSoftwareResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install",
iosHost.ID, titleID), nil, http.StatusBadRequest, &installResp)
// Update label to be include any, install should succeed
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{TitleID: titleID, Filename: "ipa_test.ipa", LabelsIncludeAny: []string{"foo"}}, http.StatusOK, "")
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install",
iosHost.ID, titleID), nil, http.StatusAccepted, &installResp)
var installCmdUUID string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &installCmdUUID, "SELECT command_uuid FROM host_in_house_software_installs WHERE host_id = ?", iosHost.ID)
})
require.NotEmpty(t, installCmdUUID)
var listResp listHostUpcomingActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", iosHost.ID), nil, http.StatusOK, &listResp)
require.Len(t, listResp.Activities, 1)
require.Equal(t, fleet.ActivityTypeInstalledSoftware{}.ActivityName(), listResp.Activities[0].Type)
// Process the InstallApplication command
s.runWorker()
cmd, err := iosDevice.Idle()
require.NoError(t, err)
for cmd != nil {
var fullCmd micromdm.CommandPayload
if cmd.Command.RequestType == "InstallApplication" {
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
assert.Equal(t, installCmdUUID, cmd.CommandUUID)
// Points at the expected manifest URL
expectedManifestURL := fmt.Sprintf("%s/api/latest/fleet/software/titles/%d/in_house_app/manifest?team_id=%d", s.server.URL, titleID, 0)
assert.Contains(t, string(cmd.Raw), expectedManifestURL)
cmd, err = iosDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
}
// Install verification command should be sent
// Simulate a verification command not finding the app (maybe it takes a little while to install)
s.runWorker()
cmd, err = iosDevice.Idle()
var cmd1 string
require.NoError(t, err)
assert.NotNil(t, cmd)
for cmd != nil {
var fullCmd micromdm.CommandPayload
switch cmd.Command.RequestType {
case "InstalledApplicationList":
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
cmd1 = cmd.CommandUUID
require.Contains(t, cmd.CommandUUID, fleet.VerifySoftwareInstallVPPPrefix)
cmd, err = iosDevice.AcknowledgeInstalledApplicationList(
iosDevice.UUID,
cmd.CommandUUID,
[]fleet.Software{},
)
require.NoError(t, err)
default:
require.Fail(t, "unexpected MDM command on client", cmd.Command.RequestType)
}
}
s.runWorker()
cmd, err = iosDevice.Idle()
require.NoError(t, err)
assert.NotNil(t, cmd)
var verificationCmdUUID string
for cmd != nil {
var fullCmd micromdm.CommandPayload
switch cmd.Command.RequestType {
case "InstalledApplicationList":
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
assert.NotEqual(t, cmd1, cmd.CommandUUID)
verificationCmdUUID = cmd.CommandUUID
require.Contains(t, cmd.CommandUUID, fleet.VerifySoftwareInstallVPPPrefix)
cmd, err = iosDevice.AcknowledgeInstalledApplicationList(
iosDevice.UUID,
cmd.CommandUUID,
[]fleet.Software{
{
Name: "test",
BundleIdentifier: "com.ipa-test.ipa-test",
Version: "1.0",
Installed: true,
},
},
)
require.NoError(t, err)
default:
require.Fail(t, "unexpected MDM command on client", cmd.Command.RequestType)
}
}
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var install struct {
CommandUUID string `db:"command_uuid"`
VerificationCmdUUID string `db:"verification_command_uuid"`
VerificationAt *time.Time `db:"verification_at"`
}
err = sqlx.GetContext(
context.Background(),
q,
&install,
"SELECT command_uuid, verification_command_uuid, verification_at FROM host_in_house_software_installs WHERE host_id = ?",
iosHost.ID,
)
require.NoError(t, err)
assert.Equal(t, installCmdUUID, install.CommandUUID)
assert.Equal(t, verificationCmdUUID, install.VerificationCmdUUID)
assert.NotNil(t, install.VerificationAt)
return nil
})
// Get title and software package details
var st getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID),
nil, http.StatusOK, &st)
require.Equal(t, "ipa_test", st.SoftwareTitle.Name)
require.Equal(t, "ipa_test.ipa", st.SoftwareTitle.SoftwarePackage.Name)
require.Equal(t, "ios", st.SoftwareTitle.SoftwarePackage.Platform)
require.WithinDuration(t, time.Now(), st.SoftwareTitle.SoftwarePackage.UploadedAt, time.Hour)
}
func (s *integrationMDMTestSuite) TestInHouseAppSelfInstall() {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := context.Background()
// Enroll iPhone
iosHost, iosDevice := s.createAppleMobileHostThenEnrollMDM("ios")
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, iosDevice.SerialNumber)
// Upload in-house app for iOS, not available in self-service for now
s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{Filename: "ipa_test.ipa"}, http.StatusOK, "")
// get its title ID
var resp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{
SoftwareTitleListOptions: fleet.SoftwareTitleListOptions{Platform: "ios"},
}, http.StatusOK, &resp, "team_id", "0")
require.Len(t, resp.SoftwareTitles, 1)
require.Equal(t, "ipa_test", resp.SoftwareTitles[0].Name)
titleID := resp.SoftwareTitles[0].ID
activityData := fmt.Sprintf(`{"software_title": "ipa_test", "software_package": "ipa_test.ipa", "team_name": null,
"team_id": null, "self_service": false, "software_title_id": %d}`, titleID)
s.lastActivityMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0)
// Add certificate authentication for iPhone
iosHost, err := s.ds.Host(ctx, iosHost.ID)
require.NoError(t, err)
certSerial := uint64(123456789)
headers := map[string]string{
"X-Client-Cert-Serial": fmt.Sprintf("%d", certSerial),
}
s.addHostIdentityCertificate(iosHost.UUID, certSerial)
// self-install without cert header (UUID auth fallback for iOS)
// With fallback auth, UUID auth succeeds for iOS devices, so we get 400 (bad title) instead of 401
res := s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", iosHost.UUID, 999), nil, http.StatusBadRequest)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Software title is not available for install.")
// self-install a non-existing title (with cert header - same result)
res = s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", iosHost.UUID, 999), nil, http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Software title is not available for install.")
// self-install an existing title not available for self-install
res = s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", iosHost.UUID, titleID), nil, http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Software title is not available through self-service")
// update the in-house app to make it self-service
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{SelfService: ptr.Bool(true), TitleID: titleID, TeamID: nil},
http.StatusOK, "")
activityData = fmt.Sprintf(`{"software_title": "ipa_test", "software_package": "ipa_test.ipa", "software_display_name": "", "software_icon_url": null, "team_name": null,
"team_id": null, "self_service": true, "software_title_id": %d}`, titleID)
s.lastActivityMatches(fleet.ActivityTypeEditedSoftware{}.ActivityName(), activityData, 0)
// self-install request is accepted
s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", iosHost.UUID, titleID), nil, http.StatusAccepted, headers)
var installCmdUUID string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &installCmdUUID, "SELECT command_uuid FROM host_in_house_software_installs WHERE host_id = ?", iosHost.ID)
})
require.NotEmpty(t, installCmdUUID)
// last activity is still "edited software" as the installed activity is created only when
// the install is verified
s.lastActivityMatches(fleet.ActivityTypeEditedSoftware{}.ActivityName(), "", 0)
// Process the InstallApplication command
s.runWorker()
cmd, err := iosDevice.Idle()
require.NoError(t, err)
for cmd != nil {
var fullCmd micromdm.CommandPayload
if cmd.Command.RequestType == "InstallApplication" {
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
assert.Equal(t, installCmdUUID, cmd.CommandUUID)
// Points at the expected manifest URL
expectedManifestURL := fmt.Sprintf("%s/api/latest/fleet/software/titles/%d/in_house_app/manifest?team_id=%d", s.server.URL, titleID, 0)
assert.Contains(t, string(cmd.Raw), expectedManifestURL)
cmd, err = iosDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
}
// Install verification command should be sent, acknowledge it
cmd, err = iosDevice.Idle()
require.NoError(t, err)
assert.NotNil(t, cmd)
for cmd != nil {
var fullCmd micromdm.CommandPayload
switch cmd.Command.RequestType {
case "InstalledApplicationList":
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
require.Contains(t, cmd.CommandUUID, fleet.VerifySoftwareInstallVPPPrefix)
cmd, err = iosDevice.AcknowledgeInstalledApplicationList(
iosDevice.UUID,
cmd.CommandUUID,
[]fleet.Software{
{Name: "test", BundleIdentifier: "com.ipa-test.ipa-test", Version: "1.0", Installed: true},
},
)
require.NoError(t, err)
default:
require.Fail(t, "unexpected MDM command on client", cmd.Command.RequestType)
}
}
// installed activity is now created
activityData = fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "command_uuid": %q, "install_uuid": "",
"software_title": "ipa_test", "software_package": "", "self_service": true, "status": "installed",
"policy_id": null, "policy_name": null}`, iosHost.ID, iosHost.DisplayName(), installCmdUUID)
s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), activityData, 0)
// host has no more upcoming activities
var listUpcomingAct listHostUpcomingActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", iosHost.ID), nil, http.StatusOK, &listUpcomingAct)
require.Len(t, listUpcomingAct.Activities, 0)
// host has the past activity for the installed app
var listPastResp listActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", iosHost.ID), nil, http.StatusOK, &listPastResp)
require.Len(t, listPastResp.Activities, 1)
// update the app to have a label condition
clr := createLabelResponse{}
s.DoJSON("POST", "/api/latest/fleet/labels", createLabelRequest{
LabelPayload: fleet.LabelPayload{Name: "L1", HostIDs: []uint{}},
}, http.StatusOK, &clr)
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
TitleID: titleID, TeamID: nil, LabelsIncludeAny: []string{"L1"},
}, http.StatusOK, "")
// self-install request is rejected
res = s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", iosHost.UUID, titleID), nil, http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "This software is not available for this host.")
// add the label to the host, so it can be installed
var addLabelsToHostResp addLabelsToHostResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/labels", iosHost.ID), addLabelsToHostRequest{
Labels: []string{"L1"},
}, http.StatusOK, &addLabelsToHostResp)
// self-install request is now accepted
s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", iosHost.UUID, titleID), nil, http.StatusAccepted, headers)
}
func (s *integrationMDMTestSuite) TestGetInHouseAppManifestUnsignedURL() {
// Test that the Fleet URL is used if cloudfrontsigner is nil
t := s.T()
s.setSkipWorkerJobs(t)
teamID := ptr.Uint(0)
s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{Filename: "ipa_test.ipa"}, http.StatusOK, "")
var titleResp listSoftwareTitlesResponse
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{
SoftwareTitleListOptions: fleet.SoftwareTitleListOptions{Platform: "ios"},
}, http.StatusOK, &titleResp, "team_id", "0")
require.Len(t, titleResp.SoftwareTitles, 1)
require.Equal(t, "ipa_test", titleResp.SoftwareTitles[0].Name)
titleID := titleResp.SoftwareTitles[0].ID
readManifest := func(res *http.Response) []byte {
buf, err := io.ReadAll(res.Body)
require.NoError(t, err)
res.Body.Close()
return buf
}
res := s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/in_house_app/manifest?team_id=%d", titleID, *teamID),
jsonMustMarshal(t, getInHouseAppManifestRequest{TitleID: titleID, TeamID: teamID}), http.StatusOK)
manifest := readManifest(res)
require.NotNil(t, manifest)
require.Contains(t, string(manifest), fmt.Sprintf("/%d/in_house_app?team_id=%d", titleID, *teamID))
}
func (s *integrationMDMTestSuite) addHostIdentityCertificate(hostUUID string, certSerial uint64) {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := context.Background()
// Generate a real certificate for the device with proper SHA256 hash
certPEM, certHash, _ := generateTestCertForDeviceAuth(t, certSerial, hostUUID)
// Insert certificate data using the new nanomdm tables
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
// Insert serial number
_, err := db.ExecContext(ctx, `INSERT INTO scep_serials (serial) VALUES (?)`, certSerial)
if err != nil {
return err
}
// Insert certificate
_, err = db.ExecContext(ctx, `
INSERT INTO scep_certificates
(serial, name, not_valid_before, not_valid_after, certificate_pem, revoked)
VALUES (?, ?, ?, ?, ?, ?)
`,
certSerial,
hostUUID,
time.Now().Add(-24*time.Hour),
time.Now().Add(365*24*time.Hour),
certPEM,
false,
)
if err != nil {
return err
}
// Insert certificate association for device authentication
_, err = db.ExecContext(ctx, `
INSERT INTO nano_cert_auth_associations (id, sha256)
VALUES (?, ?)
`, hostUUID, certHash)
return err
})
}
// TestInHouseAppVPPConflict tests that IPA (in-house apps) and VPP iOS/iPadOS apps
// with the same bundle identifier cannot coexist on the same team.
func (s *integrationMDMTestSuite) TestInHouseAppVPPConflict() {
t := s.T()
s.setSkipWorkerJobs(t)
s.registerResetVPPProxyData(t)
s.appleVPPProxySrvData = map[string]string{
"100": `{"id": "100", "attributes": {"name": "IPA Test App", "platformAttributes": {"ios": {"bundleId": "com.ipa-test.ipa-test", "artwork": {"url": "https://example.com/images/100/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "1.0.0"}}}, "deviceFamilies": ["iphone"]}}`,
"101": `{"id": "101", "attributes": {"name": "IPA Test App iPad", "platformAttributes": {"ios": {"bundleId": "com.ipa-test.ipa-test", "artwork": {"url": "https://example.com/images/101/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "1.0.0"}}}, "deviceFamilies": ["ipad"]}}`,
"102": `{"id": "102", "attributes": {"name": "Different App", "platformAttributes": {"ios": {"bundleId": "com.example.different", "artwork": {"url": "https://example.com/images/102/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "1.0.0"}}}, "deviceFamilies": ["iphone"]}}`,
}
originalAssets := s.appleVPPConfigSrvConfig.Assets
t.Cleanup(func() { s.appleVPPConfigSrvConfig.Assets = originalAssets })
s.appleVPPConfigSrvConfig.Assets = append(s.appleVPPConfigSrvConfig.Assets, vpp.Asset{
AdamID: "100",
PricingParam: "STDQ",
AvailableCount: 10,
}, vpp.Asset{
AdamID: "101",
PricingParam: "STDQ",
AvailableCount: 10,
}, vpp.Asset{
AdamID: "102",
PricingParam: "STDQ",
AvailableCount: 10,
})
var newTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("IPA Conflict Team")}}, http.StatusOK, &newTeamResp)
team := newTeamResp.Team
s.setVPPTokenForTeam(team.ID)
// Test Case 1: Upload IPA first, then try to add VPP iOS app with same bundle ID
s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{
Filename: "ipa_test.ipa",
TeamID: &team.ID,
}, http.StatusOK, "")
res := s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID,
AppStoreID: "100",
Platform: "ios",
}, http.StatusConflict)
txt := extractServerErrorText(res.Body)
require.Contains(t, txt, "already has an installer available for the IPA Conflict Team team.")
res = s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID,
AppStoreID: "101",
Platform: "ipados",
}, http.StatusConflict)
txt = extractServerErrorText(res.Body)
require.Contains(t, txt, "already has an installer available for the IPA Conflict Team team.")
var addAppResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID,
AppStoreID: "102",
Platform: "ios",
}, http.StatusOK, &addAppResp)
// Test Case 2: Add VPP iOS app first, then try to upload IPA with same bundle ID
var newTeamResp2 teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("IPA Conflict Team 2")}}, http.StatusOK, &newTeamResp2)
team2 := newTeamResp2.Team
var tokenResp getVPPTokensResponse
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &tokenResp)
var resPatchVPP patchVPPTokensTeamsResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", tokenResp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID, team2.ID}}, http.StatusOK, &resPatchVPP)
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team2.ID,
AppStoreID: "100",
Platform: "ios",
}, http.StatusOK, &addAppResp)
s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{
Filename: "ipa_test.ipa",
TeamID: &team2.ID,
}, http.StatusConflict, "already has an installer available for the IPA Conflict Team 2 team.")
// Test Case 3: Verify "No team" works correctly
s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{
Filename: "ipa_test.ipa",
TeamID: nil,
}, http.StatusOK, "")
var newTeamResp3 teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("IPA Conflict Team 3")}}, http.StatusOK, &newTeamResp3)
team3 := newTeamResp3.Team
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &tokenResp)
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", tokenResp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID, team2.ID, team3.ID, 0}}, http.StatusOK, &resPatchVPP)
res = s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: nil,
AppStoreID: "100",
Platform: "ios",
}, http.StatusConflict)
txt = extractServerErrorText(res.Body)
require.Contains(t, txt, "already has an installer available for the No team team.")
}
func (s *integrationMDMTestSuite) TestVPPAppScheduledUpdates() {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := t.Context()
// Reset the VPP proxy data to what it was before this test
s.registerResetVPPProxyData(t)
vppAutoUpdateTest := func(t *testing.T, team *fleet.Team, host *fleet.Host, deviceClient *mdmtest.TestAppleMDMClient) {
// Set an iOS and iPadOS app on the VPP response.
s.appleVPPProxySrvData = map[string]string{
"1": `{"id": "1", "attributes": {"name": "App 1", "platformAttributes": {"ios": {"bundleId": "app-1", "artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "1.0.0"}}}, "deviceFamilies": ["iphone", "ipad"]}}`,
}
if team.ID != 0 {
// Transfer host to team.
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{host.ID}, TeamID: &team.ID}, http.StatusOK)
}
// Add iOS VPP application.
iOSVPPApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "1",
Platform: fleet.IOSPlatform,
},
},
}
// Add iOS app to the team.
addAppResp := addAppStoreAppResponse{}
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID,
AppStoreID: iOSVPPApp.AdamID,
Platform: iOSVPPApp.Platform,
}, http.StatusOK, &addAppResp)
// Add iPadOS VPP application.
iPadOSVPPApp := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "1",
Platform: fleet.IPadOSPlatform,
},
},
}
// Add iPadOS app to the team.
addAppResp = addAppStoreAppResponse{}
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID,
AppStoreID: iPadOSVPPApp.AdamID,
Platform: iPadOSVPPApp.Platform,
}, http.StatusOK, &addAppResp)
// Get title ID of the VPP app.
var appTitleID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &appTitleID, `SELECT title_id FROM vpp_apps WHERE adam_id = '1' AND platform = ?`, host.Platform)
})
require.NotZero(t, appTitleID)
// Trigger install to the host
installResp := installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, appTitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
// iOS device acknowledges the InstallApplication command.
s.runWorker()
cmd, err := deviceClient.Idle()
require.NoError(t, err)
require.Equal(t, "InstallApplication", cmd.Command.RequestType)
// Acknowledge InstallApplication command
var fullCmd micromdm.CommandPayload
err = plist.Unmarshal(cmd.Raw, &fullCmd)
require.NoError(t, err)
installApplicationCommandUUID := cmd.CommandUUID
cmd, err = deviceClient.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
require.Nil(t, cmd)
// Fleet will return an InstalledApplicationList to verify the installation.
//
// iOS device processes such command, and simulates the software is
// installed by returning in the list.
s.runWorker()
cmd, err = deviceClient.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType)
fullCmd = micromdm.CommandPayload{}
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
require.Contains(t, cmd.CommandUUID, fleet.VerifySoftwareInstallVPPPrefix)
cmd, err = deviceClient.AcknowledgeInstalledApplicationList(
deviceClient.UUID,
cmd.CommandUUID,
[]fleet.Software{
{
Name: "App 1",
BundleIdentifier: "app-1",
Version: "1.0.0",
Installed: true,
},
},
)
require.NoError(t, err)
// Check activity is generated for the installation.
s.lastActivityMatches(
fleet.ActivityInstalledAppStoreApp{}.ActivityName(),
fmt.Sprintf(
`{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "from_auto_update": false, "status": "%s", "self_service": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`,
host.ID,
host.DisplayName(),
"App 1",
"1",
installApplicationCommandUUID,
fleet.SoftwareInstalled,
host.Platform,
),
0,
)
// Issue a refetch on the iOS host, and make sure the commands are queued.
triggerRefetch := func() {
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/refetch", host.ID), nil, http.StatusOK)
commands, err := s.ds.GetHostMDMCommands(context.Background(), host.ID)
require.NoError(t, err)
require.Len(t, commands, 3)
assert.ElementsMatch(t, []fleet.HostMDMCommand{
{HostID: host.ID, CommandType: fleet.RefetchAppsCommandUUIDPrefix},
{HostID: host.ID, CommandType: fleet.RefetchCertsCommandUUIDPrefix},
{HostID: host.ID, CommandType: fleet.RefetchDeviceCommandUUIDPrefix},
}, commands)
}
handleRefetch := func(software []fleet.Software) {
s.runWorker()
// 1. InstalledApplicationList
cmd, err = deviceClient.Idle()
require.NoError(t, err)
require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType)
fullCmd = micromdm.CommandPayload{}
err = plist.Unmarshal(cmd.Raw, &fullCmd)
require.NoError(t, err)
cmd, err = deviceClient.AcknowledgeInstalledApplicationList(
deviceClient.UUID,
cmd.CommandUUID,
software,
)
require.NoError(t, err)
// 2. CertificateList
cmd, err = deviceClient.Idle()
require.NoError(t, err)
require.Equal(t, "CertificateList", cmd.Command.RequestType)
var fullCmd micromdm.CommandPayload
err := plist.Unmarshal(cmd.Raw, &fullCmd)
require.NoError(t, err)
cmd, err = deviceClient.AcknowledgeCertificateList(deviceClient.UUID, cmd.CommandUUID, nil)
require.NoError(t, err)
// 3. DeviceInformation
cmd, err = deviceClient.Idle()
require.NoError(t, err)
require.Equal(t, "DeviceInformation", cmd.Command.RequestType)
fullCmd = micromdm.CommandPayload{}
err = plist.Unmarshal(cmd.Raw, &fullCmd)
require.NoError(t, err)
deviceName := "iPhone 17"
deviceProductName := "iPhone"
if host.Platform == "ipados" {
deviceName = "iPad 17"
deviceProductName = "iPad"
}
cmd, err = deviceClient.AcknowledgeDeviceInformation(deviceClient.UUID, cmd.CommandUUID, deviceName, deviceProductName, "America/Los_Angeles")
require.NoError(t, err)
}
// First refetch will populate the timezone of the device (because DeviceInformation command is always sent last in refetches).
triggerRefetch()
handleRefetch([]fleet.Software{
{
Name: "App 1",
BundleIdentifier: "app-1",
Version: "1.0.0",
Installed: true,
},
})
// Reload information after the refetch.
host, err = s.ds.Host(ctx, host.ID)
require.NoError(t, err)
// Second refetch should perform no auto updates of any kind (nothing configured yet).
triggerRefetch()
lastActivityID := s.lastActivityMatches(
fleet.ActivityInstalledAppStoreApp{}.ActivityName(), "", 0,
)
handleRefetch([]fleet.Software{
{
Name: "App 1",
BundleIdentifier: "app-1",
Version: "1.0.0",
Installed: true,
},
})
// No new activity is created (no update yet).
s.lastActivityMatches(fleet.ActivityInstalledAppStoreApp{}.ActivityName(), "", lastActivityID) // no new activity yet
// Configure auto-updates on the VPP app on a time that is currently not now in America/Los_Angeles.
nowInLosAngeles, err := getCurrentLocalTimeInHostTimeZone(ctx, "America/Los_Angeles")
require.NoError(t, err)
endTime := nowInLosAngeles.Add(-1 * time.Minute)
startTime := endTime.Add(-1 * time.Hour)
startTimeHHMM := startTime.Format("15:04")
endTimeHHMM := endTime.Format("15:04")
var updateAppStoreAppResponsePayload updateAppStoreAppResponse
t.Logf("Time in America/Los_Angeles: %s, window = [%s, %s]", nowInLosAngeles, startTimeHHMM, endTimeHHMM)
s.DoJSON("PATCH", fmt.Sprintf("/api/v1/fleet/software/titles/%d/app_store_app", appTitleID), updateAppStoreAppRequest{
TeamID: &team.ID,
AutoUpdateEnabled: ptr.Bool(true),
AutoUpdateStartTime: ptr.String(startTimeHHMM),
AutoUpdateEndTime: ptr.String(endTimeHHMM),
}, http.StatusOK, &updateAppStoreAppResponsePayload)
// Refetch should perform no auto updates of any kind because the host is not in the configured time window.
lastActivityID = s.lastActivityMatches(
fleet.ActivityEditedAppStoreApp{}.ActivityName(), "", 0,
)
triggerRefetch()
handleRefetch([]fleet.Software{
{
Name: "App 1",
BundleIdentifier: "app-1",
Version: "1.0.0",
Installed: true,
},
})
// No new activity is created (no update yet).
s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(), "", lastActivityID) // no new activity yet
// Configure auto-updates on the VPP app on a time that is currently in America/Los_Angeles.
nowInLosAngeles, err = getCurrentLocalTimeInHostTimeZone(ctx, "America/Los_Angeles")
require.NoError(t, err)
startTime = nowInLosAngeles.Add(-30 * time.Minute)
endTime = endTime.Add(1 * time.Hour)
startTimeHHMM = startTime.Format("15:04")
endTimeHHMM = endTime.Format("15:04")
updateAppStoreAppResponsePayload = updateAppStoreAppResponse{}
t.Logf("Time in America/Los_Angeles: %s, window = [%s, %s]", nowInLosAngeles, startTimeHHMM, endTimeHHMM)
s.DoJSON("PATCH", fmt.Sprintf("/api/v1/fleet/software/titles/%d/app_store_app", appTitleID), updateAppStoreAppRequest{
TeamID: &team.ID,
AutoUpdateEnabled: ptr.Bool(true),
AutoUpdateStartTime: ptr.String(startTimeHHMM),
AutoUpdateEndTime: ptr.String(endTimeHHMM),
}, http.StatusOK, &updateAppStoreAppResponsePayload)
// Refetch, but should not auto-update because the app is currently in the latest version.
lastActivityID = s.lastActivityMatches(
fleet.ActivityEditedAppStoreApp{}.ActivityName(), "", 0,
)
triggerRefetch()
handleRefetch([]fleet.Software{
{
Name: "App 1",
BundleIdentifier: "app-1",
Version: "1.0.0",
Installed: true,
},
})
// No new activity is created (no update yet).
s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(), "", lastActivityID)
// Update latest version of the app in VPP (simulate the app being updated in Apple App Store).
s.appleVPPProxySrvData = map[string]string{
"1": `{"id": "1", "attributes": {"name": "App 1", "platformAttributes": {"ios": {"bundleId": "app-1", "artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "2.0.0"}}}, "deviceFamilies": ["iphone", "ipad"]}}`,
}
stubbedConfig := apple_apps.StubbedConfig() // authentication is tested elsewhere
err = vpp.RefreshVersions(ctx, s.ds, stubbedConfig)
require.NoError(t, err)
// Spoof the previous installation time to skip the installed-1-hour-ago filtering.
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(ctx, `UPDATE host_vpp_software_installs SET created_at = DATE_SUB(NOW(), INTERVAL 2 HOUR);`)
return err
})
// Refetch, should not trigger auto-update because the app is not listed in the application list.
// This can happens when the app is in a state of downloaded but "still installing/initializing".
lastActivityID = s.lastActivityMatches(
fleet.ActivityEditedAppStoreApp{}.ActivityName(), "", 0,
)
triggerRefetch()
handleRefetch([]fleet.Software{
{},
})
// No new activity is created (no update yet).
s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(), "", lastActivityID)
// Refetch, should not trigger auto-update because the app is listed but version is not provided in the application list.
// This can happens when the app is in a state of downloaded but "still installing/initializing".
lastActivityID = s.lastActivityMatches(
fleet.ActivityEditedAppStoreApp{}.ActivityName(), "", 0,
)
triggerRefetch()
handleRefetch([]fleet.Software{
{
Name: "App 1",
BundleIdentifier: "app-1",
Installed: true,
},
})
// No new activity is created (no update yet).
s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(), "", lastActivityID)
// Refetch, should not trigger auto-update because the app is listed with an invalid version string.
// Just testing we handle such scenario.
lastActivityID = s.lastActivityMatches(
fleet.ActivityEditedAppStoreApp{}.ActivityName(), "", 0,
)
triggerRefetch()
handleRefetch([]fleet.Software{
{
Name: "App 1",
BundleIdentifier: "app-1",
Version: "invalid",
Installed: true,
},
})
// No new activity is created (no update yet).
s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(), "", lastActivityID)
// Refetch, should trigger auto-update because the app is currently not in the latest version.
triggerRefetch()
handleRefetch([]fleet.Software{
{
Name: "App 1",
BundleIdentifier: "app-1",
Version: "1.0.0",
Installed: true,
},
})
// iOS device acknowledges the InstallApplication command associated to the auto-update.
s.runWorker()
cmd, err = deviceClient.Idle()
require.NoError(t, err)
require.Equal(t, "InstallApplication", cmd.Command.RequestType)
// Acknowledge InstallApplication command
fullCmd = micromdm.CommandPayload{}
err = plist.Unmarshal(cmd.Raw, &fullCmd)
require.NoError(t, err)
installApplicationCommandUUID = cmd.CommandUUID
cmd, err = deviceClient.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
require.Nil(t, cmd)
// Fleet will return an InstalledApplicationList to verify the installation.
// Return the application with the latest version 2.0.0 (simulating the update was successful).
s.runWorker()
cmd, err = deviceClient.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType)
fullCmd = micromdm.CommandPayload{}
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
require.Contains(t, cmd.CommandUUID, fleet.VerifySoftwareInstallVPPPrefix)
cmd, err = deviceClient.AcknowledgeInstalledApplicationList(
deviceClient.UUID,
cmd.CommandUUID,
[]fleet.Software{
{
Name: "App 1",
BundleIdentifier: "app-1",
Version: "2.0.0",
Installed: true,
},
},
)
require.NoError(t, err)
// Check activity is generated for the installation.
lastActivityID = s.lastActivityMatches(
fleet.ActivityInstalledAppStoreApp{}.ActivityName(),
fmt.Sprintf(
// See `"from_auto_update": true`.
`{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "from_auto_update": true, "status": "%s", "self_service": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`,
host.ID,
host.DisplayName(),
"App 1",
"1",
installApplicationCommandUUID,
fleet.SoftwareInstalled,
host.Platform,
),
0,
)
// Trigger a refetch to refresh software inventory.
triggerRefetch()
handleRefetch([]fleet.Software{
{
Name: "App 1",
BundleIdentifier: "app-1",
Version: "2.0.0",
Installed: true,
},
})
// Check the host software inventory is updated.
software, _, err := s.ds.ListSoftware(ctx, fleet.SoftwareListOptions{
HostID: &host.ID,
})
require.NoError(t, err)
require.Len(t, software, 1)
require.Equal(t, "2.0.0", software[0].Version)
// Update latest version of the app in VPP again (simulate the app being updated in Apple App Store).
s.appleVPPProxySrvData = map[string]string{
"1": `{"id": "1", "attributes": {"name": "App 1", "platformAttributes": {"ios": {"bundleId": "app-1", "artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "3.0.0"}}}, "deviceFamilies": ["iphone", "ipad"]}}`,
}
err = vpp.RefreshVersions(ctx, s.ds, stubbedConfig)
require.NoError(t, err)
// Refetch, should not trigger auto-update because the app was recently updated (in the last hour).
// Register the previous activity id for the install.
triggerRefetch()
handleRefetch([]fleet.Software{
{
Name: "App 1",
BundleIdentifier: "app-1",
Version: "2.0.0",
Installed: true,
},
})
// No new activity.
s.lastActivityMatches(fleet.ActivityInstalledAppStoreApp{}.ActivityName(), "", lastActivityID)
}
// Create a team and a VPP token on it.
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)
// Enroll iOS device, and add serial number to fake Apple server (for VPP APIs).
iosHost, iosClientDevice := s.createAppleMobileHostThenEnrollMDM("ios")
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, iosClientDevice.SerialNumber)
t.Run("iphone-on-a-team", func(t *testing.T) {
vppAutoUpdateTest(t, team, iosHost, iosClientDevice)
})
// Enroll iPadOS device, and add serial number to fake Apple server (for VPP APIs).
ipadosHost, ipadosClientDevice := s.createAppleMobileHostThenEnrollMDM("ipados")
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, ipadosClientDevice.SerialNumber)
t.Run("ipad-on-a-team", func(t *testing.T) {
vppAutoUpdateTest(t, team, ipadosHost, ipadosClientDevice)
})
ipadHost, ipodClientDevice := s.createIpodHostThenEnrollMDM()
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, ipodClientDevice.SerialNumber)
t.Run("ipod-on-a team", func(t *testing.T) {
vppAutoUpdateTest(t, team, ipadHost, ipodClientDevice)
})
// Enroll iOS device, and add serial number to fake Apple server (for VPP APIs).
iosHostNoTeam, iosClientDeviceNoTeam := s.createAppleMobileHostThenEnrollMDM("ios")
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, iosClientDeviceNoTeam.SerialNumber)
// Set VPP token for "No team".
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, "DELETE FROM vpp_tokens;")
return err
})
s.setVPPTokenForTeam(0)
t.Run("iphone-on-no-team", func(t *testing.T) {
vppAutoUpdateTest(t, &fleet.Team{ID: 0}, iosHostNoTeam, iosClientDeviceNoTeam)
})
// Enroll iOS device, and add serial number to fake Apple server (for VPP APIs).
ipadosHostNoTeam, ipadosClientDeviceNoTeam := s.createAppleMobileHostThenEnrollMDM("ipados")
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, ipadosClientDeviceNoTeam.SerialNumber)
t.Run("ipad-on-no-team", func(t *testing.T) {
vppAutoUpdateTest(t, &fleet.Team{ID: 0}, ipadosHostNoTeam, ipadosClientDeviceNoTeam)
})
ipadHostNoTeam, ipodClientDeviceNoTeam := s.createIpodHostThenEnrollMDM()
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, ipodClientDeviceNoTeam.SerialNumber)
t.Run("ipod-on-no-team", func(t *testing.T) {
vppAutoUpdateTest(t, &fleet.Team{ID: 0}, ipadHostNoTeam, ipodClientDeviceNoTeam)
})
}
// Test for this special-case bugfix:
// https://github.com/fleetdm/fleet/issues/37290
func (s *integrationMDMTestSuite) TestVPPAppInstallVerificationXcodeSpecialCase() {
t := s.T()
s.setSkipWorkerJobs(t)
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)
// Reset the VPP proxy data to what it was before this test
s.registerResetVPPProxyData(t)
s.appleVPPProxySrvData = map[string]string{
"1": `{"id": "1", "attributes": {"name": "Xcode", "platformAttributes": {"osx": {"bundleId": "com.apple.dt.Xcode", "artwork": {"url": "https://example.com/images/1/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "1.0.0"}}}, "deviceFamilies": ["mac"]}}`,
"2": `{"id": "2", "attributes": {"name": "App 2", "platformAttributes": {"osx": {"bundleId": "b-2", "artwork": {"url": "https://example.com/images/2/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "2.0.0"}}}, "deviceFamilies": ["mac"]}}`,
}
// Add apps targeting macOS to the team
appXcode := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "1",
Platform: fleet.MacOSPlatform,
},
},
Name: "Xcode",
BundleIdentifier: "com.apple.dt.Xcode",
IconURL: "https://example.com/images/1/512x512.png",
LatestVersion: "1.0.0",
}
app2 := &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/512x512.png",
LatestVersion: "2.0.0",
}
var addAppResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: appXcode.AdamID, SelfService: true}, http.StatusOK, &addAppResp)
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: app2.AdamID, SelfService: false}, http.StatusOK, &addAppResp)
// list the software titles to get the title IDs
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")
var appXcodeTitleID, app2TitleID uint
for _, sw := range listSw.SoftwareTitles {
require.NotNil(t, sw.AppStoreApp)
switch sw.Name {
case appXcode.Name:
appXcodeTitleID = sw.ID
case app2.Name:
app2TitleID = sw.ID
}
}
// create a host that will receive the VPP install commands
mdmHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setOrbitEnrollment(t, mdmHost, s.ds)
s.runWorker()
checkInstallFleetdCommandSent(t, mdmDevice, true)
// 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}, TeamID: &team.ID}, http.StatusOK)
// trigger install of Xcode
installResp := installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, appXcodeTitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
// check that it starts polling with ManagedOnly=true, and switches to ManagedOnly=false once it stops reporting it as Installing
processInstallAppCmds := func(device *mdmtest.TestAppleMDMClient, appList []fleet.Software, expectManagedOnly bool, expectCommands ...string) {
s.runWorker()
cmd, err := device.Idle()
require.NoError(t, err)
expectedCommandsSet := make(map[string]bool, len(expectCommands))
for _, ec := range expectCommands {
expectedCommandsSet[ec] = true
}
requestTypeSeen := make(map[string]bool)
for cmd != nil {
requestTypeSeen[cmd.Command.RequestType] = true
var fullCmd micromdm.CommandPayload
switch cmd.Command.RequestType {
case "InstalledApplicationList":
if !expectedCommandsSet["InstalledApplicationList"] {
require.Fail(t, "unexpected MDM command on client", cmd.Command.RequestType)
}
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
require.Equal(t, expectManagedOnly, fullCmd.Command.InstalledApplicationList.ManagedAppsOnly)
cmd, err = device.AcknowledgeInstalledApplicationList(
device.UUID,
cmd.CommandUUID,
appList,
)
require.NoError(t, err)
case "InstallApplication":
if !expectedCommandsSet["InstallApplication"] {
require.Fail(t, "unexpected MDM command on client", cmd.Command.RequestType)
}
cmd, err = device.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
default:
require.Fail(t, "unexpected MDM command on client", cmd.Command.RequestType)
}
}
for ec := range expectedCommandsSet {
require.True(t, requestTypeSeen[ec], "expected %s command", ec)
}
}
// first iteration, only the command to install the application
processInstallAppCmds(mdmDevice, nil, true, "InstallApplication")
// second iteration, return Xcode as installing
processInstallAppCmds(mdmDevice,
[]fleet.Software{
{
Name: appXcode.Name,
BundleIdentifier: appXcode.BundleIdentifier,
Version: appXcode.LatestVersion,
Installed: false, // installing
},
}, true, "InstalledApplicationList")
// third iteration, stop returning Xcode
processInstallAppCmds(mdmDevice,
[]fleet.Software{}, true, "InstalledApplicationList")
// fourth iteration, apps requested with non-managed too, to verify Xcode
processInstallAppCmds(mdmDevice,
[]fleet.Software{
{
Name: appXcode.Name,
BundleIdentifier: appXcode.BundleIdentifier,
Version: appXcode.LatestVersion,
Installed: true,
},
{
Name: "other",
BundleIdentifier: "some.bundle",
Version: "1.2",
Installed: true,
},
}, false, "InstalledApplicationList")
// check that it is properly verified as installed
var 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(appXcodeTitleID))
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", "installed", "team_id",
fmt.Sprint(team.ID), "software_title_id", fmt.Sprint(appXcodeTitleID))
require.Equal(t, 1, countResp.Count)
// trigger install of app2
installResp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, app2TitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
// first iteration, install app2
processInstallAppCmds(mdmDevice, []fleet.Software{}, true, "InstallApplication")
// second iteration, return app2 as installing
processInstallAppCmds(mdmDevice,
[]fleet.Software{
{
Name: app2.Name,
BundleIdentifier: app2.BundleIdentifier,
Version: app2.LatestVersion,
Installed: false, // installing
},
}, true, "InstalledApplicationList")
// third iteration, do not return app2, which should not trigger the Xcode special-case
processInstallAppCmds(mdmDevice, []fleet.Software{}, true, "InstalledApplicationList")
// fourth iteration, return app2 as installed, still only managed apps were requested
processInstallAppCmds(mdmDevice,
[]fleet.Software{
{
Name: app2.Name,
BundleIdentifier: app2.BundleIdentifier,
Version: app2.LatestVersion,
Installed: true,
},
}, true, "InstalledApplicationList")
// check that it is properly verified as installed
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(app2TitleID))
require.Len(t, listResp.Hosts, 1)
require.Equal(t, listResp.Hosts[0].ID, mdmHost.ID)
// trigger install of both apps together on a different host
mdmHost2, mdmDevice2 := createHostThenEnrollMDM(s.ds, s.server.URL, t)
mdmHost2.OrbitNodeKey = ptr.String(setOrbitEnrollment(t, mdmHost2, s.ds))
s.runWorker()
checkInstallFleetdCommandSent(t, mdmDevice2, true)
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, mdmHost2.HardwareSerial)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{mdmHost2.ID}, TeamID: &team.ID}, http.StatusOK)
// enqueue a script execution first, so that when it's marked as executed, both
// vpp app installs activate at the same time (VPP app installs get batch-activated
// when they are consecutive in the upcoming queue)
var runResp runScriptResponse
s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: mdmHost2.ID, ScriptContents: "echo"}, http.StatusAccepted, &runResp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost2.ID, appXcodeTitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost2.ID, app2TitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
// check the host's upcoming activities, should be the script and 2 VPP app installs
var hostActivitiesResp listHostUpcomingActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", mdmHost2.ID),
nil, http.StatusOK, &hostActivitiesResp)
require.Len(t, hostActivitiesResp.Activities, 3)
require.Equal(t, fleet.ActivityTypeRanScript{}.ActivityName(), hostActivitiesResp.Activities[0].Type)
require.Equal(t, fleet.ActivityInstalledAppStoreApp{}.ActivityName(), hostActivitiesResp.Activities[1].Type)
require.Equal(t, fleet.ActivityInstalledAppStoreApp{}.ActivityName(), hostActivitiesResp.Activities[2].Type)
scriptExecID := hostActivitiesResp.Activities[0].UUID
// set a result for the script, activating the 2 VPP installs next
var orbitPostScriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *mdmHost2.OrbitNodeKey, scriptExecID)),
http.StatusOK, &orbitPostScriptResp)
// first iteration, install app2 and Xcode, report both as installing
processInstallAppCmds(mdmDevice2,
[]fleet.Software{
{
Name: appXcode.Name,
BundleIdentifier: appXcode.BundleIdentifier,
Version: appXcode.LatestVersion,
Installed: false, // installing
},
{
Name: app2.Name,
BundleIdentifier: app2.BundleIdentifier,
Version: app2.LatestVersion,
Installed: false, // installing
},
}, true, "InstallApplication", "InstalledApplicationList")
// second iteration, return app2 and Xcode as installing, no install command sent
processInstallAppCmds(mdmDevice2,
[]fleet.Software{
{
Name: appXcode.Name,
BundleIdentifier: appXcode.BundleIdentifier,
Version: appXcode.LatestVersion,
Installed: false, // installing
},
{
Name: app2.Name,
BundleIdentifier: app2.BundleIdentifier,
Version: app2.LatestVersion,
Installed: false, // installing
},
}, true, "InstalledApplicationList")
// third iteration, return app2 as installed, Xcode removed
processInstallAppCmds(mdmDevice2,
[]fleet.Software{
{
Name: app2.Name,
BundleIdentifier: app2.BundleIdentifier,
Version: app2.LatestVersion,
Installed: true,
},
}, true, "InstalledApplicationList")
// fourth iteration, return Xcode and app2 as installed, not-managed-only requested
processInstallAppCmds(mdmDevice2,
[]fleet.Software{
{
Name: app2.Name,
BundleIdentifier: app2.BundleIdentifier,
Version: app2.LatestVersion,
Installed: true,
},
{
Name: appXcode.Name,
BundleIdentifier: appXcode.BundleIdentifier,
Version: appXcode.LatestVersion,
Installed: true,
},
}, false, "InstalledApplicationList")
// check that both are properly verified as installed
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(app2TitleID), "order_key", "h.id")
require.Len(t, listResp.Hosts, 2)
require.Equal(t, listResp.Hosts[0].ID, mdmHost.ID)
require.Equal(t, listResp.Hosts[1].ID, mdmHost2.ID)
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(appXcodeTitleID), "order_key", "h.id")
require.Len(t, listResp.Hosts, 2)
require.Equal(t, listResp.Hosts[0].ID, mdmHost.ID)
require.Equal(t, listResp.Hosts[1].ID, mdmHost2.ID)
}