From e7759a41c1c63e6c9d4f0715af09faab5a42d860 Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Thu, 15 Jan 2026 17:03:58 -0300 Subject: [PATCH] Add integration tests for VPP app auto updates (#38337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #38111. I made sure almost all of `handleScheduledUpdates` has coverage: Screenshot 2026-01-14 at 6 41
14 PM 0. Checkout this branch. 1. Download [coverage.txt](https://github.com/user-attachments/files/24625544/coverage.txt) 2. Run `go tool cover -html=coverage.txt`. --- cmd/osquery-perf/agent.go | 2 +- pkg/mdm/mdmtest/apple.go | 4 +- server/datastore/mysql/hosts.go | 7 +- server/service/apple_mdm.go | 6 +- .../service/integration_mdm_lifecycle_test.go | 2 +- server/service/integration_mdm_test.go | 12 +- .../service/integration_vpp_install_test.go | 490 ++++++++++++++++++ 7 files changed, 510 insertions(+), 13 deletions(-) diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go index c6c691aec9..04bb5f7619 100644 --- a/cmd/osquery-perf/agent.go +++ b/cmd/osquery-perf/agent.go @@ -3141,7 +3141,7 @@ func (a *mdmAgent) runAppleIDeviceMDMLoop(mdmSCEPChallenge string) { switch mdmCommandPayload.Command.RequestType { case "DeviceInformation": mdmCommandPayload, err = mdmClient.AcknowledgeDeviceInformation(udid, mdmCommandPayload.CommandUUID, deviceName, - productName) + productName, "America/Los_Angeles") case "InstalledApplicationList": software := a.softwareIOSandIPadOS(softwareSource) mdmCommandPayload, err = mdmClient.AcknowledgeInstalledApplicationList(udid, mdmCommandPayload.CommandUUID, software) diff --git a/pkg/mdm/mdmtest/apple.go b/pkg/mdm/mdmtest/apple.go index c8dbaff47f..4e3b07f48b 100644 --- a/pkg/mdm/mdmtest/apple.go +++ b/pkg/mdm/mdmtest/apple.go @@ -983,7 +983,7 @@ func (c *TestAppleMDMClient) NotNow(cmdUUID string) (*mdm.Command, error) { return c.sendAndDecodeCommandResponse(payload) } -func (c *TestAppleMDMClient) AcknowledgeDeviceInformation(udid, cmdUUID, deviceName, productName string) (*mdm.Command, error) { +func (c *TestAppleMDMClient) AcknowledgeDeviceInformation(udid, cmdUUID, deviceName, productName, timeZone string) (*mdm.Command, error) { payload := map[string]any{ "Status": "Acknowledged", "UDID": udid, @@ -996,13 +996,13 @@ func (c *TestAppleMDMClient) AcknowledgeDeviceInformation(udid, cmdUUID, deviceN "ProductName": productName, "WiFiMAC": "ff:ff:ff:ff:ff:ff", "IsMDMLostModeEnabled": false, + "TimeZone": timeZone, }, } return c.sendAndDecodeCommandResponse(payload) } func (c *TestAppleMDMClient) AcknowledgeDeviceLocation(udid, cmdUUID string) (*mdm.Command, error) { - payload := map[string]any{ "Status": "Acknowledged", "UDID": udid, diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 0adbdf3736..4d7d7fe717 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -5289,8 +5289,11 @@ func (ds *Datastore) UpdateHostRefetchCriticalQueriesUntil(ctx context.Context, // It only updates `hosts` table, other additional host information is ignored. func (ds *Datastore) UpdateHost(ctx context.Context, host *fleet.Host) error { if host.OrbitNodeKey == nil || *host.OrbitNodeKey == "" { - level.Debug(ds.logger).Log("msg", "missing orbit_node_key to update host", - "host_id", host.ID, "node_key", host.NodeKey, "orbit_node_key", host.OrbitNodeKey) + // iOS/iPadOS hosts currently do not use/set orbit_node_key. + if host.Platform != "ios" && host.Platform != "ipados" { + level.Debug(ds.logger).Log("msg", "missing orbit_node_key to update host", + "host_id", host.ID, "node_key", host.NodeKey, "orbit_node_key", host.OrbitNodeKey) + } } sqlStatement := ` diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 8c7067fbc7..b090043cb3 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -4085,7 +4085,11 @@ func (svc *MDMAppleCheckinAndCommandService) handleScheduledUpdates( ) for _, softwareWithAutoUpdateSchedule := range softwaresWithinUpdateSchedule { // Load software title. - softwareTitle, err := svc.ds.SoftwareTitleByID(ctx, softwareWithAutoUpdateSchedule.TitleID, host.TeamID, fleet.TeamFilter{}) + teamID := host.TeamID + if teamID == nil { + teamID = ptr.Uint(0) + } + softwareTitle, err := svc.ds.SoftwareTitleByID(ctx, softwareWithAutoUpdateSchedule.TitleID, teamID, fleet.TeamFilter{}) if err != nil { level.Error(logger).Log( "msg", "software title by id", diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index a9c024adc7..42665c4d0a 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -1069,7 +1069,7 @@ func (s *integrationMDMTestSuite) TestRefetchAfterReenrollIOSNoDelete() { cmd, err = mdmDevice.AcknowledgeCertificateList(mdmDevice.UUID, cmd.CommandUUID, []*x509.Certificate{}) require.NoError(t, err) case "DeviceInformation": - cmd, err = mdmDevice.AcknowledgeDeviceInformation(mdmDevice.UUID, cmd.CommandUUID, "Test Name", "iPhone 16") + cmd, err = mdmDevice.AcknowledgeDeviceInformation(mdmDevice.UUID, cmd.CommandUUID, "Test Name", "iPhone 16", "America/Los_Angeles") require.NoError(t, err) default: require.Fail(t, "unexpected command", cmd.Command.RequestType) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index ae05a0cfc7..4feea3c2fe 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -12307,7 +12307,7 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { cmd, err = mdmClient.AcknowledgeCertificateList(mdmClient.UUID, cmd.CommandUUID, testCerts) require.NoError(t, err) require.Equal(t, "DeviceInformation", cmd.Command.RequestType) - _, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceName, "iPhone SE") + _, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceName, "iPhone SE", "America/Los_Angeles") require.NoError(t, err) commands, err = s.ds.GetHostMDMCommands(context.Background(), host.ID) @@ -12362,7 +12362,7 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { cmd, err = mdmClientIPad.AcknowledgeCertificateList(mdmClientIPad.UUID, cmd.CommandUUID, testCerts) require.NoError(t, err) require.Equal(t, "DeviceInformation", cmd.Command.RequestType) - cmd, err = mdmClientIPad.AcknowledgeDeviceInformation(mdmClientIPad.UUID, cmd.CommandUUID, deviceNameIPad, "iPad 10") + cmd, err = mdmClientIPad.AcknowledgeDeviceInformation(mdmClientIPad.UUID, cmd.CommandUUID, deviceNameIPad, "iPad 10", "America/Los_Angeles") require.NoError(t, err) require.Nil(t, cmd) @@ -12428,7 +12428,7 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { require.NoError(t, err) require.Equal(t, "DeviceInformation", cmd.Command.RequestType) const deviceNameRenamed = "My new iPhone" - cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceNameRenamed, "iPhone SE") + cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceNameRenamed, "iPhone SE", "America/Los_Angeles") require.NoError(t, err) require.Nil(t, cmd) @@ -12490,7 +12490,7 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { require.Equal(t, "CertificateList", cmd.Command.RequestType) cmd, err = mdmClient.AcknowledgeCertificateList(mdmClient.UUID, cmd.CommandUUID, []*x509.Certificate{}) require.Equal(t, "DeviceInformation", cmd.Command.RequestType) - cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceNameRenamed, "iPhone SE") + cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceNameRenamed, "iPhone SE", "America/Los_Angeles") require.NoError(t, err) require.Nil(t, cmd) @@ -18535,7 +18535,7 @@ func (s *integrationMDMTestSuite) TestRecreateDeletedIPhoneADE() { cmd, err = mdmDevice.AcknowledgeCertificateList(mdmDevice.UUID, cmd.CommandUUID, []*x509.Certificate{}) require.NoError(t, err) case "DeviceInformation": - cmd, err = mdmDevice.AcknowledgeDeviceInformation(mdmDevice.UUID, cmd.CommandUUID, "Test Name", "iPhone 16") + cmd, err = mdmDevice.AcknowledgeDeviceInformation(mdmDevice.UUID, cmd.CommandUUID, "Test Name", "iPhone 16", "America/Los_Angeles") require.NoError(t, err) default: require.Fail(t, "unexpected command", cmd.Command.RequestType) @@ -20326,7 +20326,7 @@ func (s *integrationMDMTestSuite) TestInstalledApplicationListCommandForBYODiDev require.NoError(t, err) case "DeviceInformation": - cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, "My iPhone", "iPhone X") + cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, "My iPhone", "iPhone X", "America/Los_Angeles") require.NoError(t, err) default: diff --git a/server/service/integration_vpp_install_test.go b/server/service/integration_vpp_install_test.go index 9262380c9a..24350e4a28 100644 --- a/server/service/integration_vpp_install_test.go +++ b/server/service/integration_vpp_install_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "path/filepath" "strings" + "testing" "time" "github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest" @@ -1904,3 +1905,492 @@ func (s *integrationMDMTestSuite) TestInHouseAppVPPConflict() { 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) + + // 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"]}}`, + } + + vppAutoUpdateTest := func(t *testing.T, team *fleet.Team, host *fleet.Host, deviceClient *mdmtest.TestAppleMDMClient) { + 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"]}}`, + } + + noopAuthenticator := func(bool) (string, error) { return "", nil } // authentication is tested elsewhere + err = vpp.RefreshVersions(ctx, s.ds, noopAuthenticator) + 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, noopAuthenticator) + 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) + }) + + // 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) + }) +}