Files
fleet/server/service/integration_apple_vpp_config_test.go

885 lines
42 KiB
Go

package service
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"time"
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/datastore/mysql/mysqltest"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android/service/androidmgmt"
"github.com/fleetdm/fleet/v4/server/mdm/apple/apple_apps"
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
"google.golang.org/api/androidmanagement/v1"
)
func (s *integrationMDMTestSuite) TestVPPAppleManagedAppConfiguration() {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := context.Background()
// VPP setup: token + team association.
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "vpp-apple-config-team"})
require.NoError(t, err)
orgName := "Fleet Device Management Inc."
token := "applemcptoken"
expDate := time.Now().Add(200 * time.Hour).UTC().Round(time.Second).Format(fleet.VPPTimeFormat)
tokenJSON := fmt.Sprintf(`{"expDate":%q,"token":%q,"orgName":%q}`, expDate, token, orgName)
dev_mode.SetOverride("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL, t)
// Adam IDs "2" and "3" come pre-registered by the mock VPP server with iOS/iPadOS metadata.
const iosAdamID = "2"
const ipadOSAdamID = "3"
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)
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)
const validPlist = `<dict><key>ServerURL</key><string>https://example.com</string></dict>`
const validPlist2 = `<dict><key>ServerURL</key><string>https://other.example.com</string><key>HostUUID</key><string>$FLEET_VAR_HOST_UUID</string></dict>`
// Helper: encode an XML string as a JSON string (the form clients send).
asJSONString := func(s string) json.RawMessage {
b, err := json.Marshal(s)
require.NoError(t, err)
return json.RawMessage(b)
}
// Helper: read the stored configuration directly from the datastore.
readStoredConfig := func(adamID string, platform fleet.InstallableDevicePlatform) []byte {
got, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), platform, adamID, team.ID)
require.NoError(t, err)
return got
}
// 1. Add iOS app with valid plist configuration → 200, stored, activity emitted.
var addResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID,
AppStoreID: iosAdamID,
Platform: fleet.IOSPlatform,
Configuration: asJSONString(validPlist),
}, http.StatusOK, &addResp)
require.NotZero(t, addResp.TitleID)
require.Equal(t, []byte(validPlist), readStoredConfig(iosAdamID, fleet.IOSPlatform))
// 2. Update iOS app with new configuration that includes an allowed Fleet variable.
var updResp updateAppStoreAppResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID),
&updateAppStoreAppRequest{
TeamID: &team.ID,
Configuration: asJSONString(validPlist2),
}, http.StatusOK, &updResp)
require.Equal(t, []byte(validPlist2), readStoredConfig(iosAdamID, fleet.IOSPlatform))
// GET title returns the iOS configuration as a JSON string of plist; unmarshal to recover the raw plist bytes.
var titleResp getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addResp.TitleID),
&getSoftwareTitleRequest{ID: addResp.TitleID, TeamID: &team.ID},
http.StatusOK, &titleResp, "fleet_id", fmt.Sprint(team.ID))
require.NotNil(t, titleResp.SoftwareTitle.AppStoreApp)
var gotPlist string
require.NoError(t, json.Unmarshal(titleResp.SoftwareTitle.AppStoreApp.Configuration, &gotPlist))
require.Equal(t, validPlist2, gotPlist)
// 3. Update iOS app omitting `configuration` field → no change.
updResp = updateAppStoreAppResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID),
&updateAppStoreAppRequest{TeamID: &team.ID, SelfService: new(true)}, http.StatusOK, &updResp)
require.Equal(t, []byte(validPlist2), readStoredConfig(iosAdamID, fleet.IOSPlatform))
// 3b. Update iOS app with `configuration: null` → row deleted (clear semantics
// must match the batch path; previously the single-app PATCH stored empty bytes).
updResp = updateAppStoreAppResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID),
&updateAppStoreAppRequest{TeamID: &team.ID, Configuration: json.RawMessage(`null`)},
http.StatusOK, &updResp)
_, err = s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), fleet.IOSPlatform, iosAdamID, team.ID)
require.True(t, fleet.IsNotFound(err), "expected configuration row to be deleted on null PATCH, got %v", err)
// Re-set the configuration so the rest of the test continues with state.
updResp = updateAppStoreAppResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID),
&updateAppStoreAppRequest{TeamID: &team.ID, Configuration: asJSONString(validPlist2)},
http.StatusOK, &updResp)
require.Equal(t, []byte(validPlist2), readStoredConfig(iosAdamID, fleet.IOSPlatform))
// 4. Add iPadOS app with malformed XML → 422.
res := s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID,
AppStoreID: ipadOSAdamID,
Platform: fleet.IPadOSPlatform,
Configuration: asJSONString(`not actually a plist`),
}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), "invalid plist")
// 5. Add iOS app with disallowed Fleet variable → 422.
res = s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID,
AppStoreID: ipadOSAdamID,
Platform: fleet.IPadOSPlatform,
Configuration: asJSONString(`<dict><key>K</key><string>$FLEET_VAR_NDES_SCEP_CHALLENGE</string></dict>`),
}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), "$FLEET_VAR_NDES_SCEP_CHALLENGE")
// Update iOS app with malformed XML → 422.
res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID),
&updateAppStoreAppRequest{
TeamID: &team.ID,
Configuration: asJSONString(`not actually a plist`),
}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), "invalid plist")
// Update iOS app with disallowed Fleet variable → 422.
res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addResp.TitleID),
&updateAppStoreAppRequest{
TeamID: &team.ID,
Configuration: asJSONString(`<dict><key>K</key><string>$FLEET_VAR_NDES_SCEP_CHALLENGE</string></dict>`),
}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(res.Body), "$FLEET_VAR_NDES_SCEP_CHALLENGE")
// macOS adam ID — pre-registered as a macOS-only app in the mock VPP server.
const macosAdamID = "1"
requireNoStoredConfig := func(platform fleet.InstallableDevicePlatform, adamID string, teamID uint) {
_, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), platform, adamID, teamID)
require.True(t, fleet.IsNotFound(err), "expected not found, got %v", err)
}
// 6. Add macOS app with configuration → 200, configuration silently dropped.
var addMacResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID,
AppStoreID: macosAdamID,
Platform: fleet.MacOSPlatform,
Configuration: asJSONString(validPlist),
}, http.StatusOK, &addMacResp)
require.NotZero(t, addMacResp.TitleID)
requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, team.ID)
// 7. Update macOS app with configuration → 200, configuration still not stored.
var updMacResp updateAppStoreAppResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addMacResp.TitleID),
&updateAppStoreAppRequest{
TeamID: &team.ID,
Configuration: asJSONString(validPlist),
}, http.StatusOK, &updMacResp)
requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, team.ID)
// 8. Add macOS app with malformed XML → 200 (silent drop must come before validation).
const macosAdamIDInvalid = "2"
var addMacInvalidResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID,
AppStoreID: macosAdamIDInvalid,
Platform: fleet.MacOSPlatform,
Configuration: asJSONString(`not actually a plist`),
}, http.StatusOK, &addMacInvalidResp)
require.NotZero(t, addMacInvalidResp.TitleID)
requireNoStoredConfig(fleet.MacOSPlatform, macosAdamIDInvalid, team.ID)
// 9. Update macOS app with malformed XML → 200, still no row.
var updMacInvalidResp updateAppStoreAppResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", addMacResp.TitleID),
&updateAppStoreAppRequest{
TeamID: &team.ID,
Configuration: asJSONString(`not actually a plist`),
}, http.StatusOK, &updMacInvalidResp)
requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, team.ID)
t.Run("BatchAssociateVPPApps", func(t *testing.T) {
// Use a fresh team so batch "replace all" doesn't clobber the prior state.
batchTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "vpp-apple-config-batch-team"})
require.NoError(t, err)
var resPatchVPPBatch patchVPPTokensTeamsResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", getVPPTokenResp.Tokens[0].ID),
patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID, batchTeam.ID}}, http.StatusOK, &resPatchVPPBatch)
var batchResp batchAssociateAppStoreAppsResponse
// iOS in the same batch proves the path actually ran, so a missing macOS row isn't a no-op.
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
batchAssociateAppStoreAppsRequest{
Apps: []fleet.VPPBatchPayload{
{AppStoreID: iosAdamID, Platform: fleet.IOSPlatform, Configuration: asJSONString(validPlist)},
{AppStoreID: macosAdamID, Platform: fleet.MacOSPlatform, Configuration: asJSONString(validPlist)},
},
}, http.StatusOK, &batchResp, "fleet_name", batchTeam.Name)
// iOS config IS stored — confirms the batch wrote configurations.
iosCfg, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), fleet.IOSPlatform, iosAdamID, batchTeam.ID)
require.NoError(t, err)
require.Equal(t, []byte(validPlist), iosCfg)
// macOS config silently dropped.
requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, batchTeam.ID)
// Sanity: macOS app IS associated — silent drop applies to config only, not the app association.
macMeta, err := s.ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, macosAdamID, fleet.MacOSPlatform, &batchTeam.ID)
require.NoError(t, err)
require.Equal(t, macosAdamID, macMeta.AdamID)
// Remove macOS via batch by omitting it from the payload.
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
batchAssociateAppStoreAppsRequest{
Apps: []fleet.VPPBatchPayload{
{AppStoreID: iosAdamID, Platform: fleet.IOSPlatform, Configuration: asJSONString(validPlist)},
},
}, http.StatusOK, &batchResp, "fleet_name", batchTeam.Name)
_, err = s.ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, macosAdamID, fleet.MacOSPlatform, &batchTeam.ID)
require.True(t, fleet.IsNotFound(err), "expected macOS app to be removed, got %v", err)
// Re-add macOS via batch with malformed plist → locks the silent-drop ordering for the batch path.
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch",
batchAssociateAppStoreAppsRequest{
Apps: []fleet.VPPBatchPayload{
{AppStoreID: iosAdamID, Platform: fleet.IOSPlatform, Configuration: asJSONString(validPlist)},
{AppStoreID: macosAdamID, Platform: fleet.MacOSPlatform, Configuration: asJSONString(`not actually a plist`)},
},
}, http.StatusOK, &batchResp, "fleet_name", batchTeam.Name)
macMeta, err = s.ds.GetVPPAppMetadataByAdamIDPlatformTeamID(ctx, macosAdamID, fleet.MacOSPlatform, &batchTeam.ID)
require.NoError(t, err)
require.Equal(t, macosAdamID, macMeta.AdamID)
requireNoStoredConfig(fleet.MacOSPlatform, macosAdamID, batchTeam.ID)
})
}
// TestManagedAppConfigurationWireFormat asserts the on-the-wire shape of
// VPPAppStoreApp.Configuration using hand-built request bodies and raw
// response bytes (no Go struct marshalling on either side).
func (s *integrationMDMTestSuite) TestManagedAppConfigurationWireFormat() {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := context.Background()
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "vpp-config-wire-team"})
require.NoError(t, err)
s.setVPPTokenForTeam(team.ID)
// readBody compacts the pretty-printed JSON so assertions can match unindented wire bytes.
readBody := func(resp *http.Response) string {
t.Helper()
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
var compact bytes.Buffer
require.NoError(t, json.Compact(&compact, raw))
return compact.String()
}
t.Run("vpp ios wire format", func(t *testing.T) {
// Adam ID "2" is pre-registered as an iOS app by the mock VPP server.
const iosAdamID = "2"
reqBody := fmt.Appendf(nil,
`{"fleet_id":%d,"app_store_id":%q,"platform":"ios","configuration":"<dict><key>K</key><string>v</string></dict>"}`,
team.ID, iosAdamID)
resp := s.DoRaw("POST", "/api/latest/fleet/software/app_store_apps", reqBody, http.StatusOK)
var addResp addAppStoreAppResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&addResp))
require.NoError(t, resp.Body.Close())
require.NotZero(t, addResp.TitleID)
stored, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), fleet.IOSPlatform, iosAdamID, team.ID)
require.NoError(t, err)
require.Equal(t, `<dict><key>K</key><string>v</string></dict>`, string(stored))
resp = s.DoRaw("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addResp.TitleID), nil, http.StatusOK, "fleet_id", fmt.Sprint(team.ID))
body := readBody(resp)
require.Contains(t, body, `"configuration":"<dict><key>K</key><string>v</string></dict>"`)
})
t.Run("android wire format", func(t *testing.T) {
s.enableAndroidMDM(t)
const androidAdamID = "com.test.wireformat"
s.androidAPIClient.EnterprisesApplicationsFunc = func(_ context.Context, _, _ string) (*androidmanagement.Application, error) {
return &androidmanagement.Application{IconUrl: "https://example.com/icon.png", Title: "WireApp"}, nil
}
s.androidAPIClient.EnterprisesPoliciesPatchFunc = func(_ context.Context, _ string, policy *androidmanagement.Policy, _ androidmgmt.PoliciesPatchOpts) (*androidmanagement.Policy, error) {
return policy, nil
}
reqBody := fmt.Appendf(nil,
`{"fleet_id":%d,"app_store_id":%q,"platform":"android","configuration":{"workProfileWidgets":"WORK_PROFILE_WIDGETS_ALLOWED"}}`,
team.ID, androidAdamID)
resp := s.DoRaw("POST", "/api/latest/fleet/software/app_store_apps", reqBody, http.StatusOK)
var addResp addAppStoreAppResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&addResp))
require.NoError(t, resp.Body.Close())
require.NotZero(t, addResp.TitleID)
stored, err := s.ds.GetAndroidAppConfiguration(ctxdb.RequirePrimary(ctx, true), androidAdamID, team.ID)
require.NoError(t, err)
require.JSONEq(t, `{"workProfileWidgets":"WORK_PROFILE_WIDGETS_ALLOWED"}`, string(stored))
resp = s.DoRaw("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addResp.TitleID), nil, http.StatusOK, "fleet_id", fmt.Sprint(team.ID))
body := readBody(resp)
require.Contains(t, body, `"configuration":{"workProfileWidgets":"WORK_PROFILE_WIDGETS_ALLOWED"}`)
})
}
// TestVPPManagedConfigurationOnInstallCommand asserts the InstallApplication
// MDM command bytes for the VPP managed-configuration paths.
func (s *integrationMDMTestSuite) TestVPPManagedConfigurationOnInstallCommand() {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := context.Background()
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "vpp-managedcfg-install-team"})
require.NoError(t, err)
s.setVPPTokenForTeam(team.ID)
s.registerResetVPPProxyData(t)
asJSONString := func(s string) json.RawMessage {
b, err := json.Marshal(s)
require.NoError(t, err)
return json.RawMessage(b)
}
titleIDFor := func(adamID string, platform fleet.InstallableDevicePlatform) uint {
var id uint
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &id,
`SELECT title_id FROM vpp_apps WHERE adam_id = ? AND platform = ?`, adamID, platform)
})
require.NotZero(t, id, "title_id for adam=%s platform=%s", adamID, platform)
return id
}
// installAndCaptureCmd triggers an install and returns the InstallApplication bytes,
// then completes the verification so the host is ready for another install.
installAndCaptureCmd := func(t *testing.T, host *fleet.Host, dev *mdmtest.TestAppleMDMClient, titleID uint, installed fleet.Software) []byte {
t.Helper()
installed.Installed = true
// Drain any pending verification commands from prior installs.
for {
cmd, err := dev.Idle()
require.NoError(t, err)
if cmd == nil {
break
}
require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType,
"unexpected pending command %q while draining verifications", cmd.Command.RequestType)
_, err = dev.AcknowledgeInstalledApplicationList(dev.UUID, cmd.CommandUUID,
[]fleet.Software{installed})
require.NoError(t, err)
}
var installResp installSoftwareResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID),
&installSoftwareRequest{}, http.StatusAccepted, &installResp)
s.awaitRunAppleMDMWorkerSchedule()
s.runWorker()
cmd, err := dev.Idle()
require.NoError(t, err)
require.NotNil(t, cmd, "expected an MDM command after install trigger but device went idle")
require.Equal(t, "InstallApplication", cmd.Command.RequestType)
raw := append([]byte(nil), cmd.Raw...)
_, err = dev.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
s.runWorker()
cmd, err = dev.Idle()
require.NoError(t, err)
require.NotNil(t, cmd, "expected an InstalledApplicationList verification command")
require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType)
installed.Installed = true
_, err = dev.AcknowledgeInstalledApplicationList(dev.UUID, cmd.CommandUUID,
[]fleet.Software{installed})
require.NoError(t, err)
return raw
}
// Adam ID "2" is registered for iOS + iPadOS + macOS in the mock proxy.
const adamMulti = "2"
// Adam ID "1" is macOS-only.
const adamMac = "1"
iosHost, iosDev := s.createAppleMobileHostThenEnrollMDM("ios")
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, iosDev.SerialNumber)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{iosHost.ID}, TeamID: &team.ID}, http.StatusOK)
// App-2 metadata as the mock proxy reports it for ios / ipados.
app2Installed := fleet.Software{Name: "App 2", BundleIdentifier: "b-2", Version: "2.0.0"}
t.Run("iOS install carries Configuration and resolves $FLEET_VAR_HOST_UUID", func(t *testing.T) {
plistXML := `<dict><key>K</key><string>v</string><key>UUID</key><string>$FLEET_VAR_HOST_UUID</string></dict>`
var addResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID, AppStoreID: adamMulti, Platform: fleet.IOSPlatform,
Configuration: asJSONString(plistXML),
}, http.StatusOK, &addResp)
raw := string(installAndCaptureCmd(t, iosHost, iosDev, titleIDFor(adamMulti, fleet.IOSPlatform), app2Installed))
require.Contains(t, raw, "<key>Configuration</key>",
"InstallApplication should include Configuration dict for iOS")
require.Contains(t, raw, "<key>K</key>")
require.Contains(t, raw, "<string>v</string>")
require.NotContains(t, raw, "$FLEET_VAR_HOST_UUID",
"Fleet variable must be resolved before sending to device")
require.Contains(t, raw, fmt.Sprintf("<string>%s</string>", iosHost.UUID),
"Resolved value should be the iOS host's own UUID")
})
t.Run("clearing configuration drops Configuration from the next install", func(t *testing.T) {
titleID := titleIDFor(adamMulti, fleet.IOSPlatform)
s.DoJSON("PATCH",
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID),
&updateAppStoreAppRequest{TeamID: &team.ID, Configuration: json.RawMessage(`null`)},
http.StatusOK, &updateAppStoreAppResponse{})
s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(), "", 0)
_, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true), fleet.IOSPlatform, adamMulti, team.ID)
require.True(t, fleet.IsNotFound(err), "expected config row deleted")
raw := string(installAndCaptureCmd(t, iosHost, iosDev, titleID, app2Installed))
require.NotContains(t, raw, "<key>Configuration</key>",
"InstallApplication should not include Configuration after clearing the stored config")
})
t.Run("second iOS host gets its own UUID substituted (per-host resolution)", func(t *testing.T) {
titleID := titleIDFor(adamMulti, fleet.IOSPlatform)
s.DoJSON("PATCH",
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID),
&updateAppStoreAppRequest{
TeamID: &team.ID,
Configuration: asJSONString(`<dict><key>UUID</key><string>$FLEET_VAR_HOST_UUID</string></dict>`),
},
http.StatusOK, &updateAppStoreAppResponse{})
ios2Host, ios2Dev := s.createAppleMobileHostThenEnrollMDM("ios")
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, ios2Dev.SerialNumber)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{ios2Host.ID}, TeamID: &team.ID}, http.StatusOK)
require.NotEqual(t, iosHost.UUID, ios2Host.UUID, "second host needs a distinct UUID for this test to be meaningful")
raw := string(installAndCaptureCmd(t, ios2Host, ios2Dev, titleID, app2Installed))
require.Contains(t, raw, fmt.Sprintf("<string>%s</string>", ios2Host.UUID),
"second host's command should carry its own UUID")
require.NotContains(t, raw, fmt.Sprintf("<string>%s</string>", iosHost.UUID),
"second host's command must not carry the first host's UUID")
})
t.Run("macOS install drops Configuration even when one was sent on add", func(t *testing.T) {
plistXML := `<dict><key>K</key><string>v</string></dict>`
var addResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID, AppStoreID: adamMac, Platform: fleet.MacOSPlatform,
Configuration: asJSONString(plistXML),
}, http.StatusOK, &addResp)
// macOS install path requires fleetd enrollment.
macHost, macDev := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setOrbitEnrollment(t, macHost, s.ds)
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, macHost.HardwareSerial)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{macHost.ID}, TeamID: &team.ID}, http.StatusOK)
// Drain the InstallFleetd command queued by enrollment.
s.awaitRunAppleMDMWorkerSchedule()
s.runWorker()
checkInstallFleetdCommandSent(t, macDev, true)
raw := string(installAndCaptureCmd(t, macHost, macDev,
titleIDFor(adamMac, fleet.MacOSPlatform),
fleet.Software{Name: "App 1", BundleIdentifier: "a-1", Version: "1.0.0"}))
require.NotContains(t, raw, "<key>Configuration</key>",
"macOS InstallApplication must not carry Configuration even if one was POSTed")
})
t.Run("same adam_id on iOS and iPadOS keep configs isolated", func(t *testing.T) {
const iosCfg = `<dict><key>side</key><string>ios</string></dict>`
const ipadCfg = `<dict><key>side</key><string>ipados</string></dict>`
iosTitle := titleIDFor(adamMulti, fleet.IOSPlatform)
s.DoJSON("PATCH",
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", iosTitle),
&updateAppStoreAppRequest{TeamID: &team.ID, Configuration: asJSONString(iosCfg)},
http.StatusOK, &updateAppStoreAppResponse{})
var addResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID, AppStoreID: adamMulti, Platform: fleet.IPadOSPlatform,
Configuration: asJSONString(ipadCfg),
}, http.StatusOK, &addResp)
storedIOS, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true),
fleet.IOSPlatform, adamMulti, team.ID)
require.NoError(t, err)
require.Equal(t, iosCfg, string(storedIOS))
storedIPad, err := s.ds.GetVPPAppConfiguration(ctxdb.RequirePrimary(ctx, true),
fleet.IPadOSPlatform, adamMulti, team.ID)
require.NoError(t, err)
require.Equal(t, ipadCfg, string(storedIPad))
require.NotEqual(t, string(storedIOS), string(storedIPad))
ipadHost, ipadDev := s.createAppleMobileHostThenEnrollMDM("ipados")
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, ipadDev.SerialNumber)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{ipadHost.ID}, TeamID: &team.ID}, http.StatusOK)
raw := string(installAndCaptureCmd(t, ipadHost, ipadDev,
titleIDFor(adamMulti, fleet.IPadOSPlatform), app2Installed))
require.Contains(t, raw, "<string>ipados</string>",
"iPadOS install must use the iPadOS-scoped config")
require.NotContains(t, raw, "<string>ios</string>",
"iPadOS install must not leak the iOS-scoped config bytes")
})
t.Run("updating configuration → next install carries the new bytes", func(t *testing.T) {
// Every install path re-reads the stored config at enqueue time
// (nanoEnqueueVPPInstall), so this also exercises the auto-update freshness path.
titleID := titleIDFor(adamMulti, fleet.IOSPlatform)
const updatedCfg = `<dict><key>K</key><string>updated</string></dict>`
s.DoJSON("PATCH",
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID),
&updateAppStoreAppRequest{TeamID: &team.ID, Configuration: asJSONString(updatedCfg)},
http.StatusOK, &updateAppStoreAppResponse{})
raw := string(installAndCaptureCmd(t, iosHost, iosDev, titleID, app2Installed))
require.Contains(t, raw, "<key>Configuration</key>",
"updated install should still carry a Configuration dict")
require.Contains(t, raw, "<string>updated</string>",
"updated install must carry the latest stored config bytes")
// Updating to a config that references an IDP variable the host hasn't
// been linked to → the install is RECORDED AS FAILED (visible in the
// activity feed + Install Details modal) instead of being rejected with
// a 400. A pre-flight substitution check in InstallVPPAppPostValidation
// catches this before reserving a license or enqueuing a command.
// Regression for #45851 / #45854.
s.DoJSON("PATCH",
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID),
&updateAppStoreAppRequest{
TeamID: &team.ID,
Configuration: asJSONString(`<dict><key>Email</key><string>$FLEET_VAR_HOST_END_USER_EMAIL_IDP</string></dict>`),
},
http.StatusOK, &updateAppStoreAppResponse{})
var uaBefore int
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &uaBefore,
`SELECT COUNT(*) FROM upcoming_activities WHERE host_id = ?`, iosHost.ID)
})
s.DoJSON("POST",
fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", iosHost.ID, titleID),
&installSoftwareRequest{}, http.StatusAccepted, &installSoftwareResponse{})
// A failed install row is recorded (verification_failed_at set).
var failedCount int
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &failedCount,
`SELECT COUNT(*) FROM host_vpp_software_installs WHERE host_id = ? AND verification_failed_at IS NOT NULL`, iosHost.ID)
})
require.Equal(t, 1, failedCount, "a failed VPP install row must be recorded")
// No zombie upcoming activity and no enqueued MDM command.
var uaAfter, nanoCount int
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
if err := sqlx.GetContext(ctx, q, &uaAfter,
`SELECT COUNT(*) FROM upcoming_activities WHERE host_id = ?`, iosHost.ID); err != nil {
return err
}
return sqlx.GetContext(ctx, q, &nanoCount,
`SELECT COUNT(*) FROM nano_commands nc
JOIN host_vpp_software_installs hvsi ON hvsi.command_uuid = nc.command_uuid
WHERE hvsi.host_id = ? AND hvsi.verification_failed_at IS NOT NULL`, iosHost.ID)
})
require.Equal(t, uaBefore, uaAfter, "failed install must not leave a zombie upcoming_activity row")
require.Zero(t, nanoCount, "failed install must not enqueue an MDM command")
// The failure is visible as a failed installed_app_store_app activity
// carrying the unresolvable variable as the reason.
var listActs listActivitiesResponse
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &listActs,
"order_key", "a.id", "order_direction", "desc", "per_page", "10")
var found bool
for _, act := range listActs.Activities {
if act.Type == (fleet.ActivityInstalledAppStoreApp{}).ActivityName() {
require.NotNil(t, act.Details)
require.Contains(t, string(*act.Details), string(fleet.SoftwareInstallFailed))
require.Contains(t, string(*act.Details), "$FLEET_VAR_HOST_END_USER_EMAIL_IDP")
found = true
break
}
}
require.True(t, found, "a failed installed_app_store_app activity must be in the feed")
// Regression: the per-team/per-app status summary (vpp.go
// GetSummaryHostVPPAppInstalls) and the host software list
// status=failed_install filter (software_installers.go
// vppHostSoftwareInstallJoin) must both count Fleet-side pre-flight
// failures. Before the SQL fix, the WHERE `ncr.id IS NOT NULL …`
// clause dropped these rows entirely (no nano_command_results is
// written when no MDM command is enqueued), so the install row
// existed in host_vpp_software_installs with verification_failed_at
// set but was invisible to both surfaces.
var titleSummary getSoftwareTitleResponse
s.DoJSON("GET",
fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID),
&getSoftwareTitleRequest{ID: titleID, TeamID: &team.ID},
http.StatusOK, &titleSummary, "fleet_id", fmt.Sprint(team.ID))
require.NotNil(t, titleSummary.SoftwareTitle.AppStoreApp)
require.NotNil(t, titleSummary.SoftwareTitle.AppStoreApp.Status)
require.GreaterOrEqual(t, titleSummary.SoftwareTitle.AppStoreApp.Status.Failed, uint(1),
"per-team/per-app failed-install summary must count the pre-flight failure")
var hostSw getHostSoftwareResponse
s.DoJSON("GET",
fmt.Sprintf("/api/latest/fleet/hosts/%d/software", iosHost.ID),
nil, http.StatusOK, &hostSw, "status", "failed_install")
var foundInList bool
for _, sw := range hostSw.Software {
if sw.ID == titleID {
foundInList = true
break
}
}
require.True(t, foundInList,
"host software list filtered by status=failed_install must include the pre-flight-failed title")
})
t.Run("self-service install carries Configuration with $FLEET_VAR_HOST_UUID resolved", func(t *testing.T) {
ssHost, ssDev := s.createAppleMobileHostThenEnrollMDM("ios")
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, ssDev.SerialNumber)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{ssHost.ID}, TeamID: &team.ID}, http.StatusOK)
// Self-service device endpoint requires cert auth.
const certSerial = uint64(987654321)
s.addHostIdentityCertificate(ssHost.UUID, certSerial)
headers := map[string]string{
"X-Client-Cert-Serial": fmt.Sprintf("%d", certSerial),
}
titleID := titleIDFor(adamMulti, fleet.IOSPlatform)
s.DoJSON("PATCH",
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID),
&updateAppStoreAppRequest{
TeamID: &team.ID, SelfService: new(true),
Configuration: asJSONString(`<dict><key>UUID</key><string>$FLEET_VAR_HOST_UUID</string></dict>`),
},
http.StatusOK, &updateAppStoreAppResponse{})
s.DoRawWithHeaders("POST",
fmt.Sprintf("/api/latest/fleet/device/%s/software/install/%d", ssHost.UUID, titleID),
nil, http.StatusAccepted, headers)
s.awaitRunAppleMDMWorkerSchedule()
s.runWorker()
cmd, err := ssDev.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "InstallApplication", cmd.Command.RequestType)
raw := string(cmd.Raw)
require.Contains(t, raw, "<key>Configuration</key>",
"self-service iOS install should still include Configuration")
require.Contains(t, raw, fmt.Sprintf("<string>%s</string>", ssHost.UUID),
"self-service install must resolve $FLEET_VAR_HOST_UUID to this host's UUID")
_, err = ssDev.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
})
t.Run("auto-update install carries the managed Configuration", func(t *testing.T) {
// adam "3" is unused by prior subtests; override its proxy metadata
// to declare iOS at v1.0.0 (default proxy data has it as iPadOS-only).
const cfgAdamID = "3"
s.appleVPPProxySrvData[cfgAdamID] = `{"id": "3", "attributes": {"name": "Config App", "platformAttributes": {"ios": {"bundleId": "app-cfg", "artwork": {"url": "https://example.com/images/3/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "1.0.0"}}}, "deviceFamilies": ["iphone", "ipad"]}}`
cfgHost, cfgDev := s.createAppleMobileHostThenEnrollMDM("ios")
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, cfgDev.SerialNumber)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{cfgHost.ID}, TeamID: &team.ID}, http.StatusOK)
var addResp addAppStoreAppResponse
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{
TeamID: &team.ID, AppStoreID: cfgAdamID, Platform: fleet.IOSPlatform,
Configuration: json.RawMessage(`"<dict><key>auto</key><string>update</string></dict>"`),
}, http.StatusOK, &addResp)
appTitleID := addResp.TitleID
require.NotZero(t, appTitleID)
// First install.
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", cfgHost.ID, appTitleID),
&installSoftwareRequest{}, http.StatusAccepted, &installSoftwareResponse{})
s.awaitRunAppleMDMWorkerSchedule()
s.runWorker()
cmd, err := cfgDev.Idle()
require.NoError(t, err)
require.Equal(t, "InstallApplication", cmd.Command.RequestType)
require.Contains(t, string(cmd.Raw), "<key>Configuration</key>")
require.Contains(t, string(cmd.Raw), "<string>update</string>")
_, err = cfgDev.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
s.runWorker()
cmd, err = cfgDev.Idle()
require.NoError(t, err)
require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType)
_, err = cfgDev.AcknowledgeInstalledApplicationList(cfgDev.UUID, cmd.CommandUUID, []fleet.Software{
{Name: "Config App", BundleIdentifier: "app-cfg", Version: "1.0.0", Installed: true},
})
require.NoError(t, err)
// handleRefetch processes the three commands a refetch queues:
// InstalledApplicationList, CertificateList, DeviceInformation.
handleRefetch := func(software []fleet.Software) {
s.runWorker()
c, err := cfgDev.Idle()
require.NoError(t, err)
require.Equal(t, "InstalledApplicationList", c.Command.RequestType)
_, err = cfgDev.AcknowledgeInstalledApplicationList(cfgDev.UUID, c.CommandUUID, software)
require.NoError(t, err)
c, err = cfgDev.Idle()
require.NoError(t, err)
require.Equal(t, "CertificateList", c.Command.RequestType)
_, err = cfgDev.AcknowledgeCertificateList(cfgDev.UUID, c.CommandUUID, nil)
require.NoError(t, err)
c, err = cfgDev.Idle()
require.NoError(t, err)
require.Equal(t, "DeviceInformation", c.Command.RequestType)
_, err = cfgDev.AcknowledgeDeviceInformation(cfgDev.UUID, c.CommandUUID, "iPhone 17", "iPhone", "America/Los_Angeles")
require.NoError(t, err)
}
// First refetch populates the host's timezone (DeviceInformation is sent last).
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/refetch", cfgHost.ID), nil, http.StatusOK)
handleRefetch([]fleet.Software{
{Name: "Config App", BundleIdentifier: "app-cfg", Version: "1.0.0", Installed: true},
})
// Enable auto-update inside the current Los Angeles window.
nowInLA, err := getCurrentLocalTimeInHostTimeZone(ctx, "America/Los_Angeles")
require.NoError(t, err)
startHHMM := nowInLA.Add(-30 * time.Minute).Format("15:04")
endHHMM := nowInLA.Add(30 * time.Minute).Format("15:04")
s.DoJSON("PATCH", fmt.Sprintf("/api/v1/fleet/software/titles/%d/app_store_app", appTitleID),
updateAppStoreAppRequest{
TeamID: &team.ID,
AutoUpdateEnabled: new(true),
AutoUpdateStartTime: &startHHMM,
AutoUpdateEndTime: &endHHMM,
}, http.StatusOK, &updateAppStoreAppResponse{})
// Bump latest version in proxy data, refresh, and bypass the 1-hour install filter.
s.appleVPPProxySrvData[cfgAdamID] = `{"id": "3", "attributes": {"name": "Config App", "platformAttributes": {"ios": {"bundleId": "app-cfg", "artwork": {"url": "https://example.com/images/3/{w}x{h}.{f}"}, "latestVersionInfo": {"versionDisplay": "2.0.0"}}}, "deviceFamilies": ["iphone", "ipad"]}}`
require.NoError(t, vpp.RefreshVersions(ctx, s.ds, apple_apps.StubbedConfig()))
mysqltest.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) WHERE host_id = ?`, cfgHost.ID)
return err
})
// Refetch with the host still on v1.0.0 → auto-update kicks in.
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/refetch", cfgHost.ID), nil, http.StatusOK)
handleRefetch([]fleet.Software{
{Name: "Config App", BundleIdentifier: "app-cfg", Version: "1.0.0", Installed: true},
})
s.runWorker()
cmd, err = cfgDev.Idle()
require.NoError(t, err)
require.NotNil(t, cmd, "expected an auto-update InstallApplication command")
require.Equal(t, "InstallApplication", cmd.Command.RequestType)
require.Contains(t, string(cmd.Raw), "<key>Configuration</key>",
"scheduled auto-update InstallApplication must carry Configuration")
require.Contains(t, string(cmd.Raw), "<string>update</string>",
"scheduled auto-update must use the latest stored config bytes")
isAuto, err := s.ds.IsAutoUpdateVPPInstall(ctx, cmd.CommandUUID)
require.NoError(t, err)
require.True(t, isAuto, "command must be recorded with from_auto_update=true")
_, err = cfgDev.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
})
t.Run("canceling a pending VPP install releases the reserved VPP license seat", func(t *testing.T) {
// Regression for #45851 / #45854 (license-leak follow-up to Jonathan's
// note about activated VPP install activities leaking licenses).
//
// A fresh install on a not-yet-licensed device reserves a seat via
// AssociateAssets. If the admin then cancels the upcoming activity
// before it reaches the device, Fleet must release the seat via
// DisassociateAssets — otherwise the seat leaks.
cancelHost, cancelDev := s.createAppleMobileHostThenEnrollMDM("ios")
s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, cancelDev.SerialNumber)
s.Do("POST", "/api/latest/fleet/hosts/transfer",
&addHostsToTeamRequest{HostIDs: []uint{cancelHost.ID}, TeamID: &team.ID}, http.StatusOK)
titleID := titleIDFor(adamMulti, fleet.IOSPlatform)
// Reset the managed config to a resolvable one so the install proceeds
// past the pre-flight check and actually reserves a seat.
s.DoJSON("PATCH",
fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID),
&updateAppStoreAppRequest{
TeamID: &team.ID,
Configuration: asJSONString(`<dict><key>UUID</key><string>$FLEET_VAR_HOST_UUID</string></dict>`),
},
http.StatusOK, &updateAppStoreAppResponse{})
// Snapshot the disassociate-call count so we can assert exactly one new
// release happens on cancel.
disassocBefore := len(s.appleVPPConfigSrvConfig.Disassociations)
s.DoJSON("POST",
fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", cancelHost.ID, titleID),
&installSoftwareRequest{}, http.StatusAccepted, &installSoftwareResponse{})
// Pull the activated install's command_uuid (== upcoming_activities.execution_id).
var row struct {
CommandUUID string `db:"command_uuid"`
AssociatedEventID string `db:"associated_event_id"`
}
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &row,
`SELECT command_uuid, COALESCE(associated_event_id, '') AS associated_event_id
FROM host_vpp_software_installs WHERE host_id = ? AND adam_id = ?`,
cancelHost.ID, adamMulti)
})
// Sanity: this install was the one that reserved a seat.
require.NotEmpty(t, row.AssociatedEventID, "test setup: this install should have reserved a license")
commandUUID := row.CommandUUID
// Cancel the upcoming activity (DELETE /hosts/{id}/activities/upcoming/{activity_id}).
s.Do("DELETE",
fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming/%s", cancelHost.ID, commandUUID),
nil, http.StatusNoContent)
// The mock VPP server must have received exactly one disassociate for
// this adam_id + serial.
require.Len(t, s.appleVPPConfigSrvConfig.Disassociations, disassocBefore+1,
"cancel must trigger exactly one DisassociateAssets call")
got := s.appleVPPConfigSrvConfig.Disassociations[disassocBefore]
require.Len(t, got.Assets, 1)
require.Equal(t, adamMulti, got.Assets[0].AdamID)
require.Equal(t, []string{cancelDev.SerialNumber}, got.SerialNumbers)
require.Empty(t, got.ClientUserIds, "device-enrolled host must disassociate by serial, not clientUserId")
})
}