Trigger VPP installs for iOS/iPad on enroll (#33870)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #33699

Enqueues and kicks off installation process for iOS and iPadOS apps
marked for installation during setup

Changes file already added during earlier work ont his feature

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [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/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually
This commit is contained in:
Jordan Montgomery
2025-10-09 11:38:11 -04:00
committed by GitHub
parent 22f950e708
commit d7086ff872
10 changed files with 932 additions and 37 deletions
+22 -17
View File
@@ -77,7 +77,8 @@ INNER JOIN vpp_apps_teams vat
INNER JOIN software_titles st
ON va.title_id = st.id
WHERE vat.install_during_setup = true
AND vat.global_or_team_id = ?`
AND vat.global_or_team_id = ?
AND va.platform = ?`
stmtSetupScripts := `
INSERT INTO setup_experience_status_results (
@@ -95,6 +96,8 @@ WHERE global_or_team_id = ?`
var totalInsertions uint
if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
totalInsertions = 0 // reset for each attempt
// Clean out old statuses for the host
if _, err := tx.ExecContext(ctx, stmtClearSetupStatus, hostUUID); err != nil {
return ctxerr.Wrap(ctx, err, "removing stale setup experience entries")
@@ -102,23 +105,25 @@ WHERE global_or_team_id = ?`
// Software installers
fleetPlatform := fleet.PlatformFromHost(hostPlatformLike)
res, err := tx.ExecContext(ctx, stmtSoftwareInstallers, hostUUID, teamID, fleetPlatform, hostPlatformLike, hostPlatformLike)
if err != nil {
return ctxerr.Wrap(ctx, err, "inserting setup experience software installers")
if fleetPlatform != "ios" && fleetPlatform != "ipados" {
res, err := tx.ExecContext(ctx, stmtSoftwareInstallers, hostUUID, teamID, fleetPlatform, hostPlatformLike, hostPlatformLike)
if err != nil {
return ctxerr.Wrap(ctx, err, "inserting setup experience software installers")
}
inserts, err := res.RowsAffected()
if err != nil {
return ctxerr.Wrap(ctx, err, "retrieving number of inserted software installers")
}
totalInsertions += uint(inserts) // nolint: gosec
}
inserts, err := res.RowsAffected()
if err != nil {
return ctxerr.Wrap(ctx, err, "retrieving number of inserted software installers")
}
totalInsertions += uint(inserts) // nolint: gosec
// VPP apps
if fleetPlatform == "darwin" {
res, err = tx.ExecContext(ctx, stmtVPPApps, hostUUID, teamID)
if fleetPlatform == "darwin" || fleetPlatform == "ios" || fleetPlatform == "ipados" {
res, err := tx.ExecContext(ctx, stmtVPPApps, hostUUID, teamID, fleetPlatform)
if err != nil {
return ctxerr.Wrap(ctx, err, "inserting setup experience vpp apps")
}
inserts, err = res.RowsAffected()
inserts, err := res.RowsAffected()
if err != nil {
return ctxerr.Wrap(ctx, err, "retrieving number of inserted vpp apps")
}
@@ -127,19 +132,19 @@ WHERE global_or_team_id = ?`
// Scripts
if fleetPlatform == "darwin" {
res, err = tx.ExecContext(ctx, stmtSetupScripts, hostUUID, teamID)
res, err := tx.ExecContext(ctx, stmtSetupScripts, hostUUID, teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "inserting setup experience scripts")
}
inserts, err = res.RowsAffected()
inserts, err := res.RowsAffected()
if err != nil {
return ctxerr.Wrap(ctx, err, "retrieving number of inserted setup experience scripts")
}
totalInsertions += uint(inserts) // nolint: gosec
}
// Set setup experience on darwin hosts only if they have something configured.
if fleetPlatform == "darwin" {
// Set setup experience on Apple hosts only if they have something configured.
if fleetPlatform == "darwin" || fleetPlatform == "ios" || fleetPlatform == "ipados" {
if totalInsertions > 0 {
if err := setHostAwaitingConfiguration(ctx, tx, hostUUID, true); err != nil {
return ctxerr.Wrap(ctx, err, "setting host awaiting configuration to true")
@@ -417,7 +422,7 @@ FROM setup_experience_status_results sesr
LEFT JOIN setup_experience_scripts ses ON ses.id = sesr.setup_experience_script_id
LEFT JOIN software_installers si ON si.id = sesr.software_installer_id
LEFT JOIN vpp_apps_teams vat ON vat.id = sesr.vpp_app_team_id
LEFT JOIN vpp_apps va ON vat.adam_id = va.adam_id
LEFT JOIN vpp_apps va ON vat.adam_id = va.adam_id AND vat.platform = va.platform
WHERE host_uuid = ?
`
var results []*fleet.SetupExperienceStatusResult
+8
View File
@@ -1102,3 +1102,11 @@ type MDMAppleEnrolledDeviceInfo struct {
Platform string `db:"platform"`
EnrollTeamID *uint `db:"enroll_team_id"`
}
type AppleMDMVPPInstaller interface {
// GetVPPTokenIfCanInstallVPPApps returns the host team's VPP token if the host can be a target for VPP apps
GetVPPTokenIfCanInstallVPPApps(ctx context.Context, appleDevice bool, host *Host) (string, error)
// InstallVPPAppPostValidation installs a VPP app, assuming that GetVPPTokenIfCanInstallVPPApps has passed and provided a VPP token
InstallVPPAppPostValidation(ctx context.Context, host *Host, vppApp *VPPApp, token string, opts HostSoftwareInstallOptions) (string, error)
}
+1 -1
View File
@@ -228,7 +228,7 @@ func (t *HostLifecycle) turnOnApple(ctx context.Context, opts HostOptions) error
opts.Platform,
tmID,
opts.EnrollReference,
!opts.HasSetupExperienceItems,
!opts.HasSetupExperienceItems || opts.Platform != "darwin",
)
return ctxerr.Wrap(ctx, err, "queue DEP post-enroll task")
}
+3 -1
View File
@@ -3620,7 +3620,9 @@ func (svc *MDMAppleCheckinAndCommandService) TokenUpdate(r *mdm.Request, m *mdm.
var hasSetupExpItems bool
if m.AwaitingConfiguration {
if !info.MigrationInProgress {
// Always run setup experience on non-macOS hosts(i.e. iOS/iPadOS), only run it on macOS if
// this is not an ABM MDM migration
if info.Platform != "darwin" || !info.MigrationInProgress {
// Enqueue setup experience items and mark the host as being in setup experience
hasSetupExpItems, err = svc.ds.EnqueueSetupExperienceItems(r.Context, info.Platform, r.ID, info.TeamID)
if err != nil {
@@ -20,6 +20,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/worker"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
micromdm "github.com/micromdm/micromdm/mdm/mdm"
@@ -1869,6 +1870,524 @@ func (s *integrationMDMTestSuite) TestSetupExperienceVPPCRUD() {
checkSetupExperienceSoftware(t, "ios", team.ID, []uint{titleIDsByApp[iOSApp2]})
}
func (s *integrationMDMTestSuite) TestSetupExperienceIOSAndIPadOS() {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := context.Background()
abmOrgName := "fleet_ade_ios_ipados_team_test"
s.enableABM(abmOrgName)
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
var acResp appConfigResponse
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{
"mdm": {
"apple_business_manager": [{
"organization_name": %q,
"macos_team": %q,
"ios_team": %q,
"ipados_team": %q
}]
}
}`, abmOrgName, team.Name, team.Name, team.Name)), http.StatusOK, &acResp)
orgName := "Fleet Device Management Inc."
token := "mycooltoken"
expTime := time.Now().Add(200 * time.Hour).UTC().Round(time.Second)
expDate := expTime.Format(fleet.VPPTimeFormat)
tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName)
t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL)
var validToken uploadVPPTokenResponse
s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "", &validToken)
var getVPPTokenResp getVPPTokensResponse
s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &getVPPTokenResp)
// Associate team to the VPP token.
var resPatchVPP patchVPPTokensTeamsResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", getVPPTokenResp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP)
// app 1 macOS only
macOSApp1 := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "1",
Platform: fleet.MacOSPlatform,
},
},
Name: "App 1",
BundleIdentifier: "a-1",
IconURL: "https://example.com/images/1",
LatestVersion: "1.0.0",
}
// App 2 supports macOS, iOS, iPadOS
macOSApp2 := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "2",
Platform: fleet.MacOSPlatform,
},
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
LatestVersion: "2.0.0",
}
iOSApp2 := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "2",
Platform: fleet.IOSPlatform,
},
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
LatestVersion: "2.0.0",
}
iPadOSApp2 := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "2",
Platform: fleet.IPadOSPlatform,
},
},
Name: "App 2",
BundleIdentifier: "b-2",
IconURL: "https://example.com/images/2",
LatestVersion: "2.0.0",
}
// App 3 is iPadOS only
iPadOSApp3 := &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",
LatestVersion: "3.0.0",
}
expectedApps := []*fleet.VPPApp{macOSApp1, macOSApp2, iOSApp2, iPadOSApp2, iPadOSApp3}
var addAppResp addAppStoreAppResponse
// Add apps
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)
})
require.NoError(t, err)
return titleID
}
titleIDsByApp := make(map[*fleet.VPPApp]uint)
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)
titleIDsByApp[app] = getSoftwareTitleIDFromApp(app)
}
putSetupExperienceSoftwareForPlatform := func(t *testing.T, platform string, teamID uint, titleIDs []uint) {
var swInstallResp putSetupExperienceSoftwareResponse
s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{
Platform: platform,
TeamID: teamID,
TitleIDs: titleIDs,
}, http.StatusOK, &swInstallResp)
}
// Set the 2 apps for macOS
putSetupExperienceSoftwareForPlatform(t, "macos", team.ID, []uint{titleIDsByApp[macOSApp1], titleIDsByApp[macOSApp2]})
// Add an app for iOS
putSetupExperienceSoftwareForPlatform(t, "ios", team.ID, []uint{titleIDsByApp[iOSApp2]})
// Add apps for iPadOS
putSetupExperienceSoftwareForPlatform(t, "ipados", team.ID, []uint{titleIDsByApp[iPadOSApp2], titleIDsByApp[iPadOSApp3]})
// Add a profile
teamProfile := mobileconfigForTest("N1", "I1")
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{teamProfile}}, http.StatusNoContent, "team_id", fmt.Sprint(team.ID))
devices := []godep.Device{
{
Model: "iPad Pro 12.9\" (Wi-Fi Only - 3rd Gen)",
OS: "iPadOS",
DeviceFamily: "iPad",
OpType: "added",
SerialNumber: "ipad-123456",
},
{
Model: "iPhone 16 Pro",
OS: "iOS",
DeviceFamily: "iPhone",
OpType: "added",
SerialNumber: "iphone-123456",
},
}
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, devices[0].SerialNumber, devices[1].SerialNumber)
vppAppIDsByDeviceFamily := map[string][]*fleet.VPPApp{
"iPhone": {iOSApp2},
"iPad": {iPadOSApp2, iPadOSApp3},
}
for _, enableReleaseManually := range []bool{true, false} {
for _, enrollmentProfileFromDEPUsingPost := range []bool{true, false} {
for _, device := range devices {
t.Run(fmt.Sprintf("%sSetupExperience;enableReleaseManually=%t;EnrollmentProfileFromDEPUsingPost=%t", device.DeviceFamily, enableReleaseManually, enrollmentProfileFromDEPUsingPost), func(t *testing.T) {
s.runDEPEnrollReleaseMobileDeviceWithVPPTest(t, device, DEPEnrollMobileTestOpts{
ABMOrg: abmOrgName,
EnableReleaseManually: enableReleaseManually,
TeamID: &team.ID,
CustomProfileIdent: "N1",
EnrollmentProfileFromDEPUsingPost: enrollmentProfileFromDEPUsingPost,
VppAppsToInstall: vppAppIDsByDeviceFamily[device.DeviceFamily],
})
})
}
}
}
}
type DEPEnrollMobileTestOpts struct {
ABMOrg string
EnableReleaseManually bool
TeamID *uint
CustomProfileIdent string
EnrollmentProfileFromDEPUsingPost bool
VppAppsToInstall []*fleet.VPPApp
}
func (s *integrationMDMTestSuite) runDEPEnrollReleaseMobileDeviceWithVPPTest(t *testing.T, device godep.Device, opts DEPEnrollMobileTestOpts) {
ctx := context.Background()
// set the enable release device manually option
payload := map[string]any{
"enable_release_device_manually": opts.EnableReleaseManually,
"manual_agent_install": false,
}
if opts.TeamID != nil {
payload["team_id"] = *opts.TeamID
}
s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent)
t.Cleanup(func() {
// Get back to the default state.
payload["enable_release_device_manually"] = false
s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent)
})
// query all hosts - none yet
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Empty(t, listHostsRes.Hosts)
s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
return map[string]*push.Response{}, nil
}
s.mockDEPResponse(opts.ABMOrg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
err := encoder.Encode(map[string]string{"auth_session_token": "xyz"})
require.NoError(t, err)
case "/profile":
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()})
require.NoError(t, err)
case "/server/devices":
err := encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{device}})
require.NoError(t, err)
case "/devices/sync":
// This endpoint is polled over time to sync devices from
// ABM, send a repeated serial and a new one
err := encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{device}, Cursor: "foo"})
require.NoError(t, err)
case "/profile/devices":
b, err := io.ReadAll(r.Body)
require.NoError(t, err)
var prof profileAssignmentReq
require.NoError(t, json.Unmarshal(b, &prof))
var resp godep.ProfileResponse
resp.ProfileUUID = prof.ProfileUUID
resp.Devices = make(map[string]string, len(prof.Devices))
for _, device := range prof.Devices {
resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess)
}
err = encoder.Encode(resp)
require.NoError(t, err)
default:
_, _ = w.Write([]byte(`{}`))
}
}))
// trigger a profile sync
s.runDEPSchedule()
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 1)
require.Equal(t, listHostsRes.Hosts[0].HardwareSerial, device.SerialNumber)
enrolledHost := listHostsRes.Hosts[0].Host
t.Cleanup(func() {
// delete the enrolled host
err := s.ds.DeleteHost(ctx, enrolledHost.ID)
require.NoError(t, err)
})
// enroll the host
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
clientOpts := make([]mdmtest.TestMDMAppleClientOption, 0)
if opts.EnrollmentProfileFromDEPUsingPost {
clientOpts = append(clientOpts, mdmtest.WithEnrollmentProfileFromDEPUsingPost())
}
mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken, clientOpts...)
switch device.DeviceFamily {
case "iPhone":
mdmDevice.Model = "iPhone14,6"
case "iPad":
mdmDevice.Model = "iPad8,7"
default:
// Only expecting mobile devices for this test
t.Fatalf("unexpected device family: %s", device.DeviceFamily)
}
mdmDevice.SerialNumber = device.SerialNumber
err := mdmDevice.Enroll()
require.NoError(t, err)
// The host should be awaiting configuration
awaitingConfiguration, err := s.ds.GetHostAwaitingConfiguration(ctx, mdmDevice.UUID)
require.NoError(t, err)
require.True(t, awaitingConfiguration)
// run the worker to process the DEP enroll request
s.runWorker()
// run the cron to assign configuration profiles
s.awaitTriggerProfileSchedule(t)
var cmds []*micromdm.CommandPayload
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
// For reporting back via InstalledApplicationList
installedVPPApps := make([]fleet.Software, 0, len(opts.VppAppsToInstall))
// For verifying number of installs
installedApps := make(map[string]int, len(opts.VppAppsToInstall))
var installProfileCount, installAppCount, refetchVerifyCount, otherCount int
var profileCustomSeen, profileFleetCASeen, unexpectedProfileSeen bool
// Can be useful for debugging
logCommands := false
for cmd != nil {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
if strings.HasPrefix(cmd.CommandUUID, fleet.RefetchAppsCommandUUID()) {
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
continue
}
switch cmd.Command.RequestType {
case "InstallProfile":
if logCommands {
fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload))
}
installProfileCount++
if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), //nolint:gocritic // ignore ifElseChain
fmt.Sprintf("<string>%s</string>", opts.CustomProfileIdent)) {
profileCustomSeen = true
} else if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetdConfigPayloadIdentifier)) {
unexpectedProfileSeen = true
} else if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string>", mobileconfig.FleetCARootConfigPayloadIdentifier)) {
profileFleetCASeen = true
} else if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), fmt.Sprintf("<string>%s</string", mobileconfig.FleetFileVaultPayloadIdentifier)) &&
strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "ForceEnableInSetupAssistant") {
unexpectedProfileSeen = true
}
case "InstallApplication":
if logCommands {
fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, fmt.Sprint(*fullCmd.Command.InstallApplication.ITunesStoreID))
}
for _, app := range opts.VppAppsToInstall {
if app.AdamID == fmt.Sprint(*fullCmd.Command.InstallApplication.ITunesStoreID) {
installedVPPApps = append(installedVPPApps, fleet.Software{BundleIdentifier: app.BundleIdentifier, Name: app.Name, Version: app.LatestVersion, Installed: true})
installedApps[app.AdamID]++
}
}
installAppCount++
case "InstallEnterpriseApplication":
if logCommands {
if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil {
fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL)
} else {
fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
}
}
case "InstalledApplicationList":
if logCommands {
fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
}
// 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))
cmd, err = mdmDevice.AcknowledgeInstalledApplicationList(
mdmDevice.UUID,
cmd.CommandUUID,
installedVPPApps,
)
require.NoError(t, err)
// TODO: We don't actually normally get a command back from the acknowledgement of the InstalledAppList
// but we'll get additional install commands if we follow it up with an idle. Is this a bug? I think it
// may be because of how we handle activating the next upcoming activity?
if cmd == nil {
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
}
continue
default:
if logCommands {
fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
}
otherCount++
}
cmds = append(cmds, &fullCmd)
if cmd.Command.RequestType == "InstallApplication" {
pending, err := s.ds.GetQueuedJobs(ctx, 5, time.Now().UTC().Add(time.Minute))
require.NoError(t, err)
for _, job := range pending {
if job.Name == "apple_software" {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE jobs SET not_before = ? WHERE id = ?`, time.Now().Add(-1*time.Minute).UTC(), job.ID)
return err
})
}
}
// Run the worker to process the VPP verification job before acking so that the Verify command is waiting for us
s.runWorker()
}
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
// expected commands: install CA, install profile (only the custom one),
// not expected: account configuration, since enrollment_reference not set
require.Len(t, cmds, 2+len(opts.VppAppsToInstall))
require.Equal(t, 2, installProfileCount)
require.True(t, profileCustomSeen)
require.True(t, profileFleetCASeen)
require.Equal(t, false, unexpectedProfileSeen)
require.Equal(t, len(opts.VppAppsToInstall), installAppCount)
require.Equal(t, len(opts.VppAppsToInstall), len(installedApps))
// Each expected app should be installed exactly once
for _, app := range opts.VppAppsToInstall {
require.Equal(t, 1, installedApps[app.AdamID])
}
require.Equal(t, 0, otherCount)
pendingReleaseJobs := []*fleet.Job{}
if opts.EnableReleaseManually {
// get the worker's pending job from the future, there should not be any
// because it needs to be released manually
pending, err := s.ds.GetQueuedJobs(ctx, 5, time.Now().UTC().Add(time.Minute))
require.NoError(t, err)
for _, job := range pending {
if job.Name == "apple_mdm" && strings.Contains(string(*job.Args), string(worker.AppleMDMPostDEPReleaseDeviceTask)) {
pendingReleaseJobs = append(pendingReleaseJobs, job)
} else if job.Name == "apple_software" {
// Just delete the job for now to keep things clean
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM jobs WHERE id = ?`, job.ID)
return err
})
}
}
require.Len(t, pendingReleaseJobs, 0)
} else {
// otherwise the device release job should be enqueued
pending, err := s.ds.GetQueuedJobs(ctx, 5, time.Now().UTC().Add(time.Minute))
require.NoError(t, err)
for _, job := range pending {
if job.Name == "apple_mdm" && strings.Contains(string(*job.Args), string(worker.AppleMDMPostDEPReleaseDeviceTask)) {
pendingReleaseJobs = append(pendingReleaseJobs, job)
} else if job.Name == "apple_software" {
// Just delete the job for now to keep things clean
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM jobs WHERE id = ?`, job.ID)
return err
})
}
}
require.Len(t, pendingReleaseJobs, 1)
require.Equal(t, "apple_mdm", pendingReleaseJobs[0].Name)
require.Contains(t, string(*pendingReleaseJobs[0].Args), worker.AppleMDMPostDEPReleaseDeviceTask)
// make the pending job ready to run immediately and run the job
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE jobs SET not_before = ? WHERE id = ?`, time.Now().Add(-1*time.Minute).UTC(), pendingReleaseJobs[0].ID)
return err
})
s.runWorker()
// make the device process the commands, it should receive the
// DeviceConfigured one.
cmds = cmds[:0]
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
cmds = append(cmds, &fullCmd)
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
require.Len(t, cmds, 1)
var deviceConfiguredCount int
for _, cmd := range cmds {
if strings.HasPrefix(cmd.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix) || strings.HasPrefix(cmd.CommandUUID, fleet.VerifySoftwareInstallVPPPrefix) {
refetchVerifyCount++
continue
}
switch cmd.Command.RequestType {
case "DeviceConfigured":
deviceConfiguredCount++
default:
otherCount++
}
}
require.Equal(t, 1, deviceConfiguredCount)
require.Equal(t, 0, otherCount)
}
}
// TestSetupExperienceEndpointsPlatformIsolation tests that setting the setup experience software items
// for one platform doesn't remove the items for another platform on the same team.
func (s *integrationMDMTestSuite) TestSetupExperienceEndpointsPlatformIsolation() {
+8 -1
View File
@@ -365,7 +365,14 @@ func (s *integrationMDMTestSuite) SetupSuite() {
{Name: fleet.MDMAssetSCEPChallenge, Value: []byte(s.scepChallenge)},
}, nil)
require.NoError(s.T(), err)
users, server := RunServerForTestsWithDS(s.T(), s.ds, &serverConfig)
svc, ctx := NewTestService(s.T(), s.ds, fleetCfg, &serverConfig)
// This is a bit of a code smell but I don't see a better way to initialize this for the tests. The
// initialization pattern works fine in our normal fleet server setup
appleMDMJob.VPPInstaller = svc
users, server := RunServerForTestsWithServiceWithDS(s.T(), ctx, s.ds, svc, &serverConfig)
s.server = server
s.users = users
s.token = s.getTestAdminToken()
+105
View File
@@ -14,6 +14,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/appmanifest"
"github.com/fleetdm/fleet/v4/server/ptr"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/google/uuid"
@@ -43,6 +44,7 @@ type AppleMDM struct {
Log kitlog.Logger
Commander *apple_mdm.MDMAppleCommander
BootstrapPackageStore fleet.MDMBootstrapPackageStore
VPPInstaller fleet.AppleMDMVPPInstaller
}
// Name returns the name of the job.
@@ -154,6 +156,13 @@ func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs)
if bootstrapCmdUUID != "" {
awaitCmdUUIDs = append(awaitCmdUUIDs, bootstrapCmdUUID)
}
} else {
// TODO: We likely want to wait for the actual installs to complete not just the commands to be ack'd
commandUUIDs, err := a.installSetupExperienceVPPAppsOnIosIpadOS(ctx, args.HostUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "installing setup experience VPP apps on iOS/iPadOS")
}
awaitCmdUUIDs = append(awaitCmdUUIDs, commandUUIDs...)
}
if ref := args.EnrollReference; ref != "" {
@@ -438,6 +447,102 @@ func (a *AppleMDM) installFleetd(ctx context.Context, hostUUID string) (string,
return cmdUUID, nil
}
func (a *AppleMDM) installSetupExperienceVPPAppsOnIosIpadOS(ctx context.Context, hostUUID string) ([]string, error) {
statuses, err := a.Datastore.ListSetupExperienceResultsByHostUUID(ctx, hostUUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "retrieving setup experience status results for next step")
}
var appsPending []*fleet.SetupExperienceStatusResult
commandUUIDs := []string{}
for _, status := range statuses {
if err := status.IsValid(); err != nil {
return nil, ctxerr.Wrap(ctx, err, "invalid row")
}
switch {
case status.VPPAppTeamID != nil:
if status.Status == fleet.SetupExperienceStatusPending {
appsPending = append(appsPending, status)
}
case status.SetupExperienceScriptID != nil, status.SoftwareInstallerID != nil:
status.Status = fleet.SetupExperienceStatusFailure
err = a.Datastore.UpdateSetupExperienceStatusResult(ctx, status)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "updating setup experience status result to failure")
}
// If we enqueued a non-VPP item for an iOS/iPadOS device, it likely a code bug
level.Error(a.Log).Log("msg", "unexpected setup experience item for iOS/iPadOS device, only VPP apps are supported", "host_uuid", hostUUID, "status_id", status.ID)
}
}
if len(appsPending) > 0 {
// enqueue vpp apps
// TODO Is there a better way to get a host by UUID? This is a somewhat "wide" search which feels unnecessary
host, err := a.Datastore.HostByIdentifier(ctx, hostUUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "retrieving host by UUID")
}
for _, app := range appsPending {
vppAppID, err := app.VPPAppID()
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "constructing vpp app details for installation")
}
if app.SoftwareTitleID == nil {
return nil, ctxerr.Errorf(ctx, "setup experience software title id missing from vpp app install request: %d", app.ID)
}
vppApp := &fleet.VPPApp{
TitleID: *app.SoftwareTitleID,
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: *vppAppID,
},
}
opts := fleet.HostSoftwareInstallOptions{
SelfService: false,
ForSetupExperience: true,
}
cmdUUID, err := a.installSoftwareFromVPP(ctx, host, vppApp, true, opts)
app.NanoCommandUUID = &cmdUUID
app.Status = fleet.SetupExperienceStatusRunning
if err != nil {
// if we get an error (e.g. no available licenses) while attempting to enqueue the
// install, then we should immediately go to an error state so setup experience
// isn't blocked.
level.Error(a.Log).Log("msg", "got an error when attempting to enqueue VPP app install", "err", err, "adam_id", app.VPPAppAdamID)
app.Status = fleet.SetupExperienceStatusFailure
app.Error = ptr.String(err.Error())
} else {
commandUUIDs = append(commandUUIDs, cmdUUID)
}
if err := a.Datastore.UpdateSetupExperienceStatusResult(ctx, app); err != nil {
return nil, ctxerr.Wrap(ctx, err, "updating setup experience with vpp install command uuid")
}
}
}
return commandUUIDs, nil
}
func (a *AppleMDM) installSoftwareFromVPP(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp, appleDevice bool, opts fleet.HostSoftwareInstallOptions) (string, error) {
// Should not happen in the normal course of events but can happen in tests
// and likely indicates things weren't initialized properly.
if a.VPPInstaller == nil {
return "", errors.New("VPP installer not configured")
}
token, err := a.VPPInstaller.GetVPPTokenIfCanInstallVPPApps(ctx, appleDevice, host)
if err != nil {
return "", err
}
return a.VPPInstaller.InstallVPPAppPostValidation(ctx, host, vppApp, token, opts)
}
func (a *AppleMDM) installBootstrapPackage(ctx context.Context, hostUUID string, teamID *uint) (string, error) {
// GetMDMAppleBootstrapPackageMeta expects team id 0 for no team
var tmID uint
+262 -16
View File
@@ -17,6 +17,7 @@ import (
nanomdm_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
mock "github.com/fleetdm/fleet/v4/server/mock/mdm"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
kitlog "github.com/go-kit/log"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
@@ -39,6 +40,34 @@ func (m mockPusher) Push(context.Context, []string) (map[string]*nanomdm_push.Re
return res, m.err
}
type installAppResponse struct {
CommandUUID string
Error error
}
type mockVPPInstaller struct {
t *testing.T
installedApps []*fleet.VPPApp
appInstallResponses map[string]installAppResponse
getTokenErr error
}
func (m *mockVPPInstaller) GetVPPTokenIfCanInstallVPPApps(ctx context.Context, appleDevice bool, host *fleet.Host) (string, error) {
require.True(m.t, appleDevice)
if m.getTokenErr != nil {
return "", m.getTokenErr
}
return "valid-token", nil
}
func (m *mockVPPInstaller) InstallVPPAppPostValidation(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp, token string, opts fleet.HostSoftwareInstallOptions) (string, error) {
require.True(m.t, opts.ForSetupExperience)
resp, ok := m.appInstallResponses[vppApp.AdamID]
require.True(m.t, ok)
m.installedApps = append(m.installedApps, vppApp)
return resp.CommandUUID, resp.Error
}
func TestAppleMDM(t *testing.T) {
ctx := context.Background()
@@ -58,14 +87,14 @@ func TestAppleMDM(t *testing.T) {
testOrgName := "fleet-test"
createEnrolledHost := func(t *testing.T, i int, teamID *uint, depAssignedToFleet bool) *fleet.Host {
createEnrolledHost := func(t *testing.T, i int, teamID *uint, depAssignedToFleet bool, platform string) *fleet.Host {
// create the host
h, err := ds.NewHost(ctx, &fleet.Host{
Hostname: fmt.Sprintf("test-host%d-name", i),
OsqueryHostID: ptr.String(fmt.Sprintf("osquery-%d", i)),
NodeKey: ptr.String(fmt.Sprintf("nodekey-%d", i)),
UUID: uuid.New().String(),
Platform: "darwin",
Platform: platform,
HardwareSerial: fmt.Sprintf("serial-%d", i),
TeamID: teamID,
})
@@ -149,7 +178,7 @@ func TestAppleMDM(t *testing.T) {
w.Register(mdmWorker)
// create a host and enqueue the job
h := createEnrolledHost(t, 1, nil, true)
h := createEnrolledHost(t, 1, nil, true, "darwin")
err := QueueAppleMDMJob(ctx, ds, nopLog, AppleMDMPostDEPEnrollmentTask, h.UUID, "darwin", nil, "", false)
require.NoError(t, err)
@@ -179,7 +208,7 @@ func TestAppleMDM(t *testing.T) {
w.Register(mdmWorker)
// create a host and enqueue the job
h := createEnrolledHost(t, 1, nil, true)
h := createEnrolledHost(t, 1, nil, true, "darwin")
err := QueueAppleMDMJob(ctx, ds, nopLog, AppleMDMTask("no-such-task"), h.UUID, "darwin", nil, "", false)
require.NoError(t, err)
@@ -202,7 +231,7 @@ func TestAppleMDM(t *testing.T) {
mysql.SetTestABMAssets(t, ds, testOrgName)
defer mysql.TruncateTables(t, ds)
h := createEnrolledHost(t, 1, nil, true)
h := createEnrolledHost(t, 1, nil, true, "darwin")
mdmWorker := &AppleMDM{
Datastore: ds,
@@ -237,7 +266,7 @@ func TestAppleMDM(t *testing.T) {
mysql.SetTestABMAssets(t, ds, testOrgName)
t.Cleanup(func() { mysql.TruncateTables(t, ds) })
h := createEnrolledHost(t, 1, nil, true)
h := createEnrolledHost(t, 1, nil, true, "darwin")
enableManualRelease(t, nil)
mdmWorker := &AppleMDM{
@@ -272,7 +301,7 @@ func TestAppleMDM(t *testing.T) {
mysql.SetTestABMAssets(t, ds, testOrgName)
defer mysql.TruncateTables(t, ds)
h := createEnrolledHost(t, 1, nil, true)
h := createEnrolledHost(t, 1, nil, true, "darwin")
err := ds.InsertMDMAppleBootstrapPackage(ctx, &fleet.MDMAppleBootstrapPackage{
Name: "custom-bootstrap",
TeamID: 0, // no-team
@@ -321,7 +350,7 @@ func TestAppleMDM(t *testing.T) {
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "test"})
require.NoError(t, err)
h := createEnrolledHost(t, 1, &tm.ID, true)
h := createEnrolledHost(t, 1, &tm.ID, true, "darwin")
err = ds.InsertMDMAppleBootstrapPackage(ctx, &fleet.MDMAppleBootstrapPackage{
Name: "custom-team-bootstrap",
TeamID: tm.ID,
@@ -371,7 +400,7 @@ func TestAppleMDM(t *testing.T) {
require.NoError(t, err)
enableManualRelease(t, &tm.ID)
h := createEnrolledHost(t, 1, &tm.ID, true)
h := createEnrolledHost(t, 1, &tm.ID, true, "darwin")
err = ds.InsertMDMAppleBootstrapPackage(ctx, &fleet.MDMAppleBootstrapPackage{
Name: "custom-team-bootstrap",
TeamID: tm.ID,
@@ -417,7 +446,7 @@ func TestAppleMDM(t *testing.T) {
mysql.SetTestABMAssets(t, ds, testOrgName)
defer mysql.TruncateTables(t, ds)
h := createEnrolledHost(t, 1, nil, true)
h := createEnrolledHost(t, 1, nil, true, "darwin")
mdmWorker := &AppleMDM{
Datastore: ds,
@@ -460,7 +489,7 @@ func TestAppleMDM(t *testing.T) {
idpAcc, err := ds.GetMDMIdPAccountByEmail(ctx, "test@example.com")
require.NoError(t, err)
h := createEnrolledHost(t, 1, nil, true)
h := createEnrolledHost(t, 1, nil, true, "darwin")
mdmWorker := &AppleMDM{
Datastore: ds,
@@ -513,7 +542,7 @@ func TestAppleMDM(t *testing.T) {
_, err = ds.SaveTeam(ctx, tm)
require.NoError(t, err)
h := createEnrolledHost(t, 1, &tm.ID, true)
h := createEnrolledHost(t, 1, &tm.ID, true, "darwin")
mdmWorker := &AppleMDM{
Datastore: ds,
@@ -547,7 +576,7 @@ func TestAppleMDM(t *testing.T) {
mysql.SetTestABMAssets(t, ds, testOrgName)
defer mysql.TruncateTables(t, ds)
h := createEnrolledHost(t, 1, nil, true)
h := createEnrolledHost(t, 1, nil, true, "darwin")
mdmWorker := &AppleMDM{
Datastore: ds,
@@ -578,7 +607,7 @@ func TestAppleMDM(t *testing.T) {
mysql.SetTestABMAssets(t, ds, testOrgName)
defer mysql.TruncateTables(t, ds)
h := createEnrolledHost(t, 1, nil, true)
h := createEnrolledHost(t, 1, nil, true, "darwin")
mdmWorker := &AppleMDM{
Datastore: ds,
@@ -614,7 +643,7 @@ func TestAppleMDM(t *testing.T) {
mysql.SetTestABMAssets(t, ds, testOrgName)
defer mysql.TruncateTables(t, ds)
h := createEnrolledHost(t, 1, nil, true)
h := createEnrolledHost(t, 1, nil, true, "darwin")
mdmWorker := &AppleMDM{
Datastore: ds,
@@ -709,7 +738,7 @@ func TestAppleMDM(t *testing.T) {
mysql.SetTestABMAssets(t, ds, testOrgName)
defer mysql.TruncateTables(t, ds)
h := createEnrolledHost(t, 1, nil, true)
h := createEnrolledHost(t, 1, nil, true, "darwin")
mdmWorker := &AppleMDM{
Datastore: ds,
@@ -769,6 +798,223 @@ func TestAppleMDM(t *testing.T) {
require.NoError(t, err)
require.Len(t, jobs, 0)
})
t.Run("installs enqueued VPP apps", func(t *testing.T) {
mysql.SetTestABMAssets(t, ds, testOrgName)
test.CreateInsertGlobalVPPToken(t, ds)
defer mysql.TruncateTables(t, ds)
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "test"})
require.NoError(t, err)
h := createEnrolledHost(t, 1, &tm.ID, true, "ios")
expectedAppInstalls := []*fleet.VPPApp{}
for i := 0; i < 3; i++ {
idx := fmt.Sprint(i)
vppApp := &fleet.VPPApp{
Name: "vpp_worker-" + idx, LatestVersion: "1.0.0", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "depworker-" + idx, Platform: fleet.IOSPlatform}},
BundleIdentifier: "b" + idx,
}
vppAppWithTeam, err := ds.InsertVPPAppWithTeam(ctx, vppApp, &tm.ID)
require.NoError(t, err)
expectedAppInstalls = append(expectedAppInstalls, vppAppWithTeam)
}
appInstallResponses := make(map[string]installAppResponse, len(expectedAppInstalls))
for _, appWithTeam := range expectedAppInstalls {
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
stmt := `
INSERT INTO setup_experience_status_results (
host_uuid,
name,
status,
vpp_app_team_id
) VALUES (?, ?, ?, ?)
`
_, err = q.ExecContext(ctx, stmt, h.UUID, appWithTeam.Name, fleet.SetupExperienceStatusPending, appWithTeam.VPPAppTeam.AppTeamID)
return err
})
appInstallResponses[appWithTeam.AdamID] = installAppResponse{CommandUUID: uuid.NewString(), Error: nil}
}
vppInstaller := &mockVPPInstaller{t: t, appInstallResponses: appInstallResponses}
mdmWorker := &AppleMDM{
VPPInstaller: vppInstaller,
Datastore: ds,
Log: nopLog,
Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
}
w := NewWorker(ds, nopLog)
w.Register(mdmWorker)
err = QueueAppleMDMJob(ctx, ds, nopLog, AppleMDMPostDEPEnrollmentTask, h.UUID, h.Platform, nil, "", true)
require.NoError(t, err)
// run the worker, should succeed
err = w.ProcessJobs(ctx)
require.NoError(t, err)
// ensure the job's not_before allows it to be returned if it were to run
// again
time.Sleep(time.Second)
jobs, err := ds.GetQueuedJobs(ctx, 10, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job
require.NoError(t, err)
require.NotEmpty(t, jobs)
var releaseJob *fleet.Job
for _, job := range jobs {
if job.Name == appleMDMJobName {
// THere should only be one release job
require.Nil(t, releaseJob)
releaseJob = job
}
}
// We should have found a release job
require.NotNil(t, releaseJob)
// It should be the release task
require.Contains(t, string(*releaseJob.Args), AppleMDMPostDEPReleaseDeviceTask)
// And it should contain the command IDs for the installs
expectedAdamIDs := make([]string, 0, len(expectedAppInstalls))
installedAdamIDs := make([]string, 0, len(vppInstaller.installedApps))
for _, app := range expectedAppInstalls {
require.Contains(t, string(*releaseJob.Args), appInstallResponses[app.AdamID].CommandUUID)
expectedAdamIDs = append(expectedAdamIDs, app.AdamID)
}
for _, installed := range vppInstaller.installedApps {
installedAdamIDs = append(installedAdamIDs, installed.AdamID)
}
require.ElementsMatch(t, expectedAdamIDs, installedAdamIDs)
results, err := ds.ListSetupExperienceResultsByHostUUID(ctx, h.UUID)
require.NoError(t, err)
require.Len(t, results, len(expectedAppInstalls))
for _, result := range results {
require.Equal(t, fleet.SetupExperienceStatusRunning, result.Status)
}
})
t.Run("marks failed VPP installs as failed, runs all others", func(t *testing.T) {
mysql.SetTestABMAssets(t, ds, testOrgName)
test.CreateInsertGlobalVPPToken(t, ds)
defer mysql.TruncateTables(t, ds)
badCommandUUID := "bad-command-uuid"
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "test"})
require.NoError(t, err)
h := createEnrolledHost(t, 1, &tm.ID, true, "ios")
expectedAppInstalls := []*fleet.VPPApp{}
for i := 0; i < 3; i++ {
idx := fmt.Sprint(i)
vppApp := &fleet.VPPApp{
Name: "vpp_worker-" + idx, LatestVersion: "1.0.0", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "depworker-" + idx, Platform: fleet.IOSPlatform}},
BundleIdentifier: "b" + idx,
}
vppAppWithTeam, err := ds.InsertVPPAppWithTeam(ctx, vppApp, &tm.ID)
require.NoError(t, err)
expectedAppInstalls = append(expectedAppInstalls, vppAppWithTeam)
}
appInstallResponses := make(map[string]installAppResponse, len(expectedAppInstalls))
for _, appWithTeam := range expectedAppInstalls {
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
stmt := `
INSERT INTO setup_experience_status_results (
host_uuid,
name,
status,
vpp_app_team_id
) VALUES (?, ?, ?, ?)
`
_, err = q.ExecContext(ctx, stmt, h.UUID, appWithTeam.Name, fleet.SetupExperienceStatusPending, appWithTeam.VPPAppTeam.AppTeamID)
return err
})
if len(appInstallResponses) == 0 {
// first one, simulate a failure. It shouldn't actually
// return a command UUID here but even if it does we
// should not wait on it
appInstallResponses[appWithTeam.AdamID] = installAppResponse{CommandUUID: badCommandUUID, Error: errors.New("test error")}
continue
}
// rest succeed
appInstallResponses[appWithTeam.AdamID] = installAppResponse{CommandUUID: uuid.NewString(), Error: nil}
}
vppInstaller := &mockVPPInstaller{t: t, appInstallResponses: appInstallResponses}
mdmWorker := &AppleMDM{
VPPInstaller: vppInstaller,
Datastore: ds,
Log: nopLog,
Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
}
w := NewWorker(ds, nopLog)
w.Register(mdmWorker)
err = QueueAppleMDMJob(ctx, ds, nopLog, AppleMDMPostDEPEnrollmentTask, h.UUID, h.Platform, nil, "", true)
require.NoError(t, err)
// run the worker, should succeed
err = w.ProcessJobs(ctx)
require.NoError(t, err)
// ensure the job's not_before allows it to be returned if it were to run
// again
time.Sleep(time.Second)
jobs, err := ds.GetQueuedJobs(ctx, 10, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job
require.NoError(t, err)
require.NotEmpty(t, jobs)
var releaseJob *fleet.Job
for _, job := range jobs {
if job.Name == appleMDMJobName {
// THere should only be one release job
require.Nil(t, releaseJob)
releaseJob = job
}
}
// We should have found a release job
require.NotNil(t, releaseJob)
// It should be the release task
require.Contains(t, string(*releaseJob.Args), AppleMDMPostDEPReleaseDeviceTask)
// And it should contain the command IDs for the installs that didn't error
expectedAdamIDs := make([]string, 0, len(expectedAppInstalls))
installedAdamIDs := make([]string, 0, len(vppInstaller.installedApps))
for _, app := range expectedAppInstalls {
expectedAdamIDs = append(expectedAdamIDs, app.AdamID)
if appInstallResponses[app.AdamID].Error != nil {
// this one failed, so it should not be in the release command
continue
}
require.Contains(t, string(*releaseJob.Args), appInstallResponses[app.AdamID].CommandUUID)
}
require.NotContains(t, string(*releaseJob.Args), badCommandUUID)
for _, installed := range vppInstaller.installedApps {
installedAdamIDs = append(installedAdamIDs, installed.AdamID)
}
require.ElementsMatch(t, expectedAdamIDs, installedAdamIDs)
results, err := ds.ListSetupExperienceResultsByHostUUID(ctx, h.UUID)
require.NoError(t, err)
require.Len(t, results, len(expectedAppInstalls))
for _, result := range results {
require.NotNil(t, result.VPPAppAdamID)
if *result.VPPAppAdamID == expectedAppInstalls[0].AdamID {
// this is the one we simulated a failure for
require.Equal(t, fleet.SetupExperienceStatusFailure, result.Status)
continue
}
require.Equal(t, fleet.SetupExperienceStatusRunning, result.Status)
}
})
}
func TestGetSignedURL(t *testing.T) {