diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go
index 618be4dce6d..e1376c71bc9 100644
--- a/server/datastore/mysql/activities.go
+++ b/server/datastore/mysql/activities.go
@@ -11,6 +11,7 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
+ apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/jmoiron/sqlx"
@@ -1368,57 +1369,6 @@ WHERE
id = ?
`
- const insCmdStmt = `
-INSERT INTO
- nano_commands
-(command_uuid, request_type, command, subtype)
-SELECT
- ua.execution_id,
- 'InstallApplication',
- CONCAT(:raw_cmd_part1, :manifest_url, :raw_cmd_part2, ua.execution_id, :raw_cmd_part3),
- :subtype
-FROM
- upcoming_activities ua
- INNER JOIN in_house_app_upcoming_activities ihua
- ON ihua.upcoming_activity_id = ua.id
-WHERE
- ua.host_id = :host_id AND
- ua.execution_id IN (:execution_ids)
-`
-
- rawCmdPart1 := `
-
-
-
- Command
-
- InstallAsManaged
-
- ManagementFlags
- %d
- ChangeManagementState
- Managed
- InstallAsManaged
-
- Options
-
- PurchaseMethod
- 1
-
- RequestType
- InstallApplication
- ManifestURL
- `
-
- const rawCmdPart2 = `
-
- CommandUUID
- `
-
- const rawCmdPart3 = `
-
-`
-
const insNanoQueueStmt = `
INSERT INTO
nano_enrollment_queue
@@ -1451,15 +1401,6 @@ ORDER BY
return ctxerr.Wrap(ctx, err, "get host uuid")
}
- // Set management flags based on platform
- if fleet.IsAppleMobilePlatform(hostData.Platform) {
- // Remove app upon MDM removal
- rawCmdPart1 = fmt.Sprintf(rawCmdPart1, 1) // Mobile devices use management flag 1
- } else {
- // Keep app upon MDM removal
- rawCmdPart1 = fmt.Sprintf(rawCmdPart1, 0) // macOS devices use management flag 0
- }
-
// insert the host in-house app row
stmt, args, err := sqlx.In(insStmt, hostID, execIDs)
if err != nil {
@@ -1479,50 +1420,71 @@ ORDER BY
tid = *hostData.TeamID
}
- // Get the title ID for the in-house app being installed
- var titleID uint
- getTitleIDStmt := `
+ // Pull the (execution_id, in_house_app_id, software_title_id) tuples for
+ // each pending activation so we can build a per-app InstallApplication
+ // command in Go and inject the managed-app-configuration dict.
+ const pendingStmt = `
SELECT
- ihua.software_title_id
+ ua.execution_id,
+ ihua.in_house_app_id,
+ ihua.software_title_id
FROM
- upcoming_activities ua
- INNER JOIN in_house_app_upcoming_activities ihua
- ON ihua.upcoming_activity_id = ua.id
+ upcoming_activities ua
+ INNER JOIN in_house_app_upcoming_activities ihua
+ ON ihua.upcoming_activity_id = ua.id
WHERE
- ua.host_id = ? AND
- ua.execution_id IN (?)
+ ua.host_id = ? AND ua.execution_id IN (?)
`
-
- stmt, args, err = sqlx.In(getTitleIDStmt, hostID, execIDs)
+ type ihPending struct {
+ ExecutionID string `db:"execution_id"`
+ InHouseAppID uint `db:"in_house_app_id"`
+ SoftwareTitle uint `db:"software_title_id"`
+ }
+ stmt, args, err = sqlx.In(pendingStmt, hostID, execIDs)
if err != nil {
- return ctxerr.Wrap(ctx, err, "prepare get in-house app title id")
+ return ctxerr.Wrap(ctx, err, "prepare pending in-house install lookup")
}
-
- if err := sqlx.GetContext(ctx, tx, &titleID, stmt, args...); err != nil {
- return ctxerr.Wrap(ctx, err, "get in-house app title id")
+ var pending []ihPending
+ if err := sqlx.SelectContext(ctx, tx, &pending, stmt, args...); err != nil {
+ return ctxerr.Wrap(ctx, err, "list pending in-house installs")
}
-
- manifestURL := fmt.Sprintf("%s/api/latest/fleet/software/titles/%d/in_house_app/manifest?fleet_id=%d", appConfig.ServerSettings.ServerURL, titleID, tid)
-
- // insert the nano command
- namedArgs := map[string]any{
- "manifest_url": manifestURL,
- "raw_cmd_part1": rawCmdPart1,
- "raw_cmd_part2": rawCmdPart2,
- "raw_cmd_part3": rawCmdPart3,
- "subtype": mdm.CommandSubtypeNone,
- "host_id": hostID,
- "execution_ids": execIDs,
+ if len(pending) == 0 {
+ return nil
}
- stmt, args, err = sqlx.Named(insCmdStmt, namedArgs)
- if err != nil {
- return ctxerr.Wrap(ctx, err, "prepare insert nano commands")
+
+ // Bulk-fetch managed configurations for the in-house apps being installed.
+ // In-house Configuration is iOS/iPadOS-only; the builder drops it for
+ // macOS hosts anyway, but in_house_apps are always Apple-mobile so we just
+ // fetch unconditionally.
+ ids := make([]uint, 0, len(pending))
+ for _, p := range pending {
+ ids = append(ids, p.InHouseAppID)
}
- stmt, args, err = sqlx.In(stmt, args...)
+ configsByAppID, err := ds.BulkGetInHouseAppConfigurationsTx(ctx, tx, ids)
if err != nil {
- return ctxerr.Wrap(ctx, err, "expand IN arguments to insert nano commands")
+ return ctxerr.Wrap(ctx, err, "bulk get in-house app configurations")
+ }
+
+ // Build the InstallApplication plist for each pending activation, then do
+ // one batch INSERT into nano_commands.
+ insValues := make([]string, 0, len(pending))
+ insArgs := make([]any, 0, len(pending)*4)
+ for _, p := range pending {
+ manifestURL := fmt.Sprintf(
+ "%s/api/latest/fleet/software/titles/%d/in_house_app/manifest?fleet_id=%d",
+ appConfig.ServerSettings.ServerURL, p.SoftwareTitle, tid)
+ cmdBytes := apple_mdm.BuildInstallApplicationCommand(apple_mdm.InstallApplicationParams{
+ CommandUUID: p.ExecutionID,
+ HostPlatform: hostData.Platform,
+ ManifestURL: manifestURL,
+ Configuration: configsByAppID[p.InHouseAppID],
+ })
+ insValues = append(insValues, "(?, 'InstallApplication', ?, ?)")
+ insArgs = append(insArgs, p.ExecutionID, string(cmdBytes), mdm.CommandSubtypeNone)
}
- if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
+ insCmdStmt := `INSERT INTO nano_commands (command_uuid, request_type, command, subtype) VALUES ` +
+ strings.Join(insValues, ", ")
+ if _, err := tx.ExecContext(ctx, insCmdStmt, insArgs...); err != nil {
return ctxerr.Wrap(ctx, err, "insert nano commands")
}
diff --git a/server/datastore/mysql/in_house_apps.go b/server/datastore/mysql/in_house_apps.go
index 940f609735e..66bf4603720 100644
--- a/server/datastore/mysql/in_house_apps.go
+++ b/server/datastore/mysql/in_house_apps.go
@@ -1697,6 +1697,14 @@ func (ds *Datastore) GetInHouseAppConfiguration(ctx context.Context, inHouseAppI
}
func (ds *Datastore) BulkGetInHouseAppConfigurations(ctx context.Context, inHouseAppIDs []uint) (map[uint][]byte, error) {
+ return ds.bulkGetInHouseAppConfigurations(ctx, ds.reader(ctx), inHouseAppIDs)
+}
+
+func (ds *Datastore) BulkGetInHouseAppConfigurationsTx(ctx context.Context, tx sqlx.QueryerContext, inHouseAppIDs []uint) (map[uint][]byte, error) {
+ return ds.bulkGetInHouseAppConfigurations(ctx, tx, inHouseAppIDs)
+}
+
+func (ds *Datastore) bulkGetInHouseAppConfigurations(ctx context.Context, q sqlx.QueryerContext, inHouseAppIDs []uint) (map[uint][]byte, error) {
if len(inHouseAppIDs) == 0 {
return nil, nil
}
@@ -1718,7 +1726,7 @@ WHERE in_house_app_id IN (?)
InHouseAppID uint `db:"in_house_app_id"`
Configuration []byte `db:"configuration"`
}
- err = sqlx.SelectContext(ctx, ds.reader(ctx), &configs, stmt, args...)
+ err = sqlx.SelectContext(ctx, q, &configs, stmt, args...)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "bulk get in-house app configurations")
}
diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go
index cc434687dac..fc86e116c9b 100644
--- a/server/datastore/mysql/vpp.go
+++ b/server/datastore/mysql/vpp.go
@@ -16,6 +16,7 @@ import (
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
+ apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-sql-driver/mysql"
@@ -2861,99 +2862,101 @@ func (ds *Datastore) nanoEnqueueVPPInstall(ctx context.Context, tx sqlx.ExtConte
const getHostUUIDStmt = `
SELECT
- uuid, platform
+ uuid, platform, team_id
FROM
hosts
WHERE
id = ?
`
- // get the host uuid, requires for the nano tables
var hostData struct {
UUID string `db:"uuid"`
Platform string `db:"platform"`
+ TeamID *uint `db:"team_id"`
}
if err := sqlx.GetContext(ctx, tx, &hostData, getHostUUIDStmt, hostID); err != nil {
- return ctxerr.Wrap(ctx, err, "get host uuid")
+ return ctxerr.Wrap(ctx, err, "get host info for vpp install")
}
- const insCmdStmt = `
-INSERT INTO
- nano_commands
-(command_uuid, request_type, command, subtype)
+ // Pull the (execution_id, adam_id, platform) tuples for the pending
+ // activations on this host so we can build per-app commands in Go and
+ // inject the managed-app-configuration dict per (adam_id, platform, team).
+ const pendingStmt = `
SELECT
ua.execution_id,
- 'InstallApplication',
- CONCAT(:raw_cmd_part1, vaua.adam_id, :raw_cmd_part2, ua.execution_id, :raw_cmd_part3),
- :subtype
+ vaua.adam_id,
+ vaua.platform
FROM
upcoming_activities ua
INNER JOIN vpp_app_upcoming_activities vaua
ON vaua.upcoming_activity_id = ua.id
WHERE
- ua.host_id = :host_id AND
- ua.execution_id IN (:execution_ids)
+ ua.host_id = ? AND ua.execution_id IN (?)
`
-
- rawCmdPart1 := `
-
-
-
- Command
-
- InstallAsManaged
-
- ManagementFlags
- %d
- ChangeManagementState
- Managed
- InstallAsManaged
-
- Options
-
- PurchaseMethod
- 1
-
- RequestType
- InstallApplication
- iTunesStoreID
- `
-
- const rawCmdPart2 = `
-
- CommandUUID
- `
-
- const rawCmdPart3 = `
-
-`
-
- // Set management flags based on platform
- if fleet.IsAppleMobilePlatform(hostData.Platform) {
- // Remove app upon MDM removal
- rawCmdPart1 = fmt.Sprintf(rawCmdPart1, 1) // Mobile devices use management flag 1
- } else {
- // Keep app upon MDM removal
- rawCmdPart1 = fmt.Sprintf(rawCmdPart1, 0) // macOS devices use management flag 0
- }
-
- // insert the nano command
- namedArgs := map[string]any{
- "raw_cmd_part1": rawCmdPart1,
- "raw_cmd_part2": rawCmdPart2,
- "raw_cmd_part3": rawCmdPart3,
- "subtype": mdm.CommandSubtypeNone,
- "host_id": hostID,
- "execution_ids": execIDs,
+ type vppPending struct {
+ ExecutionID string `db:"execution_id"`
+ AdamID string `db:"adam_id"`
+ Platform string `db:"platform"`
}
- stmt, args, err := sqlx.Named(insCmdStmt, namedArgs)
+ stmt, args, err := sqlx.In(pendingStmt, hostID, execIDs)
if err != nil {
- return ctxerr.Wrap(ctx, err, "prepare insert nano commands")
+ return ctxerr.Wrap(ctx, err, "prepare pending vpp install lookup")
}
- stmt, args, err = sqlx.In(stmt, args...)
- if err != nil {
- return ctxerr.Wrap(ctx, err, "expand IN arguments to insert nano commands")
+ var pending []vppPending
+ if err := sqlx.SelectContext(ctx, tx, &pending, stmt, args...); err != nil {
+ return ctxerr.Wrap(ctx, err, "list pending vpp installs")
}
- if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
+ if len(pending) == 0 {
+ return nil
+ }
+
+ // Fetch managed configurations in bulk per platform. Configurations are
+ // keyed on (platform, adam_id, team_id) — host's team_id at install time.
+ // Only iOS / iPadOS get a Configuration dict; macOS VPP installs always
+ // drop the field at the builder level (and we don't even bother fetching).
+ tid := uint(0)
+ if hostData.TeamID != nil {
+ tid = *hostData.TeamID
+ }
+ configsByPlatformAdamID := make(map[string]map[string][]byte, 2)
+ {
+ adamIDsByPlatform := make(map[string][]string, 2)
+ for _, p := range pending {
+ if p.Platform == string(fleet.IOSPlatform) || p.Platform == string(fleet.IPadOSPlatform) {
+ adamIDsByPlatform[p.Platform] = append(adamIDsByPlatform[p.Platform], p.AdamID)
+ }
+ }
+ for platform, adamIDs := range adamIDsByPlatform {
+ cfgs, err := ds.BulkGetVPPAppConfigurationsTx(ctx, tx, fleet.InstallableDevicePlatform(platform), adamIDs, tid)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "bulk get vpp app configurations for install")
+ }
+ configsByPlatformAdamID[platform] = cfgs
+ }
+ }
+
+ // Build the InstallApplication plist for each pending activation, then do
+ // one batch INSERT into nano_commands. Per-host, per-app build is required
+ // so the Configuration dict (which varies by team / adam_id) can be
+ // inlined.
+ insValues := make([]string, 0, len(pending))
+ insArgs := make([]any, 0, len(pending)*4)
+ for _, p := range pending {
+ var cfg []byte
+ if cfgs, ok := configsByPlatformAdamID[p.Platform]; ok {
+ cfg = cfgs[p.AdamID]
+ }
+ cmdBytes := apple_mdm.BuildInstallApplicationCommand(apple_mdm.InstallApplicationParams{
+ CommandUUID: p.ExecutionID,
+ HostPlatform: hostData.Platform,
+ ITunesStoreID: p.AdamID,
+ Configuration: cfg,
+ })
+ insValues = append(insValues, "(?, 'InstallApplication', ?, ?)")
+ insArgs = append(insArgs, p.ExecutionID, string(cmdBytes), mdm.CommandSubtypeNone)
+ }
+ insCmdStmt := `INSERT INTO nano_commands (command_uuid, request_type, command, subtype) VALUES ` +
+ strings.Join(insValues, ", ")
+ if _, err := tx.ExecContext(ctx, insCmdStmt, insArgs...); err != nil {
return ctxerr.Wrap(ctx, err, "insert nano commands")
}
@@ -3051,6 +3054,14 @@ func (ds *Datastore) GetVPPAppConfiguration(ctx context.Context, platform fleet.
}
func (ds *Datastore) BulkGetVPPAppConfigurations(ctx context.Context, platform fleet.InstallableDevicePlatform, adamIDs []string, teamID uint) (map[string][]byte, error) {
+ return ds.bulkGetVPPAppConfigurations(ctx, ds.reader(ctx), platform, adamIDs, teamID)
+}
+
+func (ds *Datastore) BulkGetVPPAppConfigurationsTx(ctx context.Context, tx sqlx.QueryerContext, platform fleet.InstallableDevicePlatform, adamIDs []string, teamID uint) (map[string][]byte, error) {
+ return ds.bulkGetVPPAppConfigurations(ctx, tx, platform, adamIDs, teamID)
+}
+
+func (ds *Datastore) bulkGetVPPAppConfigurations(ctx context.Context, q sqlx.QueryerContext, platform fleet.InstallableDevicePlatform, adamIDs []string, teamID uint) (map[string][]byte, error) {
if len(adamIDs) == 0 {
return nil, nil
}
@@ -3072,7 +3083,7 @@ WHERE application_id IN (?) AND team_id = ? AND platform = ?
ApplicationID string `db:"application_id"`
Configuration []byte `db:"configuration"`
}
- err = sqlx.SelectContext(ctx, ds.reader(ctx), &configs, stmt, args...)
+ err = sqlx.SelectContext(ctx, q, &configs, stmt, args...)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "bulk get vpp app configurations")
}
diff --git a/server/mdm/apple/install_application.go b/server/mdm/apple/install_application.go
new file mode 100644
index 00000000000..bec802b1431
--- /dev/null
+++ b/server/mdm/apple/install_application.go
@@ -0,0 +1,132 @@
+package apple_mdm
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/fleetdm/fleet/v4/server/fleet"
+)
+
+// InstallApplicationParams carries the per-host inputs needed to build an
+// `InstallApplication` MDM command plist for either a VPP or in-house
+// (`.ipa`) Apple app.
+//
+// Configuration is the managed-app-configuration ... bytes for
+// the host. It is included only for iOS / iPadOS — macOS VPP installs always
+// drop it. Empty or nil omits the `Configuration>` entry, which Apple
+// treats as "clear any managed config for this app on next apply."
+type InstallApplicationParams struct {
+ // CommandUUID is the MDM command UUID (== upcoming_activities.execution_id).
+ CommandUUID string
+
+ // HostPlatform is the host's OS family ("ios" / "ipados" / "darwin").
+ // Determines ManagementFlags and whether Configuration is included.
+ HostPlatform string
+
+ // ITunesStoreID is the App Store / VPP adam id. Mutually exclusive with
+ // ManifestURL.
+ ITunesStoreID string
+
+ // ManifestURL is the in-house `.ipa` manifest URL. Mutually exclusive with
+ // ITunesStoreID.
+ ManifestURL string
+
+ // Configuration is the stored managed-app-configuration ...
+ // bytes. Caller is responsible for any per-host substitution before
+ // passing it in.
+ Configuration []byte
+}
+
+// BuildInstallApplicationCommand returns the XML plist for the given host's
+// `InstallApplication` MDM command. Caller inserts the bytes directly into
+// `nano_commands.command`.
+//
+// For iOS / iPadOS hosts, the `Configuration` dict is injected when params
+// supplies non-empty configuration bytes. For macOS, configuration is always
+// omitted regardless of the input — that's intentional and matches the
+// service-layer silent-drop behavior.
+func BuildInstallApplicationCommand(params InstallApplicationParams) []byte {
+ var managementFlags int
+ if fleet.IsAppleMobilePlatform(params.HostPlatform) {
+ // Mobile: remove the app when MDM is removed.
+ managementFlags = 1
+ }
+ // macOS keeps the app on MDM removal (flag 0).
+
+ var b strings.Builder
+ b.Grow(1024 + len(params.Configuration))
+
+ b.WriteString(`
+
+
+
+ Command
+
+ InstallAsManaged
+
+ ManagementFlags
+ `)
+ fmt.Fprintf(&b, "%d", managementFlags)
+ b.WriteString(`
+ ChangeManagementState
+ Managed
+ Options
+
+ PurchaseMethod
+ 1
+
+`)
+
+ // Configuration is iOS/iPadOS-only. Strip any outer plist wrapper so we
+ // inline only the bare ....
+ if fleet.IsAppleMobilePlatform(params.HostPlatform) && len(params.Configuration) > 0 {
+ b.WriteString(" Configuration\n ")
+ b.Write(stripPlistWrapper(params.Configuration))
+ b.WriteString("\n")
+ }
+
+ b.WriteString(` RequestType
+ InstallApplication
+`)
+ switch {
+ case params.ITunesStoreID != "":
+ fmt.Fprintf(&b, " iTunesStoreID\n %s\n", params.ITunesStoreID)
+ case params.ManifestURL != "":
+ fmt.Fprintf(&b, " ManifestURL\n %s\n", params.ManifestURL)
+ }
+
+ b.WriteString(`
+ CommandUUID
+ `)
+ b.WriteString(params.CommandUUID)
+ b.WriteString(`
+
+`)
+
+ return []byte(b.String())
+}
+
+// stripPlistWrapper removes , , and ...
+// wrapping, returning just the bare .... No-op on bare fragments.
+func stripPlistWrapper(b []byte) []byte {
+ s := strings.TrimSpace(string(b))
+ if strings.HasPrefix(s, ""); idx >= 0 {
+ s = strings.TrimSpace(s[idx+2:])
+ }
+ }
+ if strings.HasPrefix(s, ""); idx >= 0 {
+ s = strings.TrimSpace(s[idx+1:])
+ }
+ }
+ if strings.HasPrefix(s, ""); idx >= 0 {
+ s = strings.TrimSpace(s[idx+1:])
+ }
+ if strings.HasSuffix(s, "") {
+ s = strings.TrimSpace(s[:len(s)-len("")])
+ }
+ }
+ return []byte(s)
+}
diff --git a/server/mdm/apple/install_application_test.go b/server/mdm/apple/install_application_test.go
new file mode 100644
index 00000000000..057d64957e5
--- /dev/null
+++ b/server/mdm/apple/install_application_test.go
@@ -0,0 +1,177 @@
+package apple_mdm
+
+import (
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "howett.net/plist"
+)
+
+func TestBuildInstallApplicationCommand_VPP(t *testing.T) {
+ const commandUUID = "abc-123"
+ cases := []struct {
+ name string
+ params InstallApplicationParams
+ wantMgmt int
+ wantHas []string
+ wantNot []string
+ }{
+ {
+ name: "iOS VPP, no configuration → ManagementFlags=1, iTunesStoreID, no Configuration key",
+ params: InstallApplicationParams{
+ CommandUUID: commandUUID,
+ HostPlatform: "ios",
+ ITunesStoreID: "12345",
+ },
+ wantMgmt: 1,
+ wantHas: []string{"iTunesStoreID", "12345", commandUUID},
+ wantNot: []string{"Configuration", "ManifestURL"},
+ },
+ {
+ name: "iPadOS VPP with configuration → Configuration dict injected",
+ params: InstallApplicationParams{
+ CommandUUID: commandUUID,
+ HostPlatform: "ipados",
+ ITunesStoreID: "67890",
+ Configuration: []byte("Kv"),
+ },
+ wantMgmt: 1,
+ wantHas: []string{
+ "Configuration",
+ "Kv",
+ "iTunesStoreID",
+ },
+ },
+ {
+ name: "macOS VPP with configuration → Configuration silently dropped",
+ params: InstallApplicationParams{
+ CommandUUID: commandUUID,
+ HostPlatform: "darwin",
+ ITunesStoreID: "11111",
+ Configuration: []byte("Kshould-not-leak"),
+ },
+ wantMgmt: 0,
+ wantHas: []string{"iTunesStoreID"},
+ wantNot: []string{"Configuration", "should-not-leak"},
+ },
+ {
+ name: "iOS in-house with configuration → ManifestURL + Configuration injected",
+ params: InstallApplicationParams{
+ CommandUUID: commandUUID,
+ HostPlatform: "ios",
+ ManifestURL: "https://fleet.example.com/manifest",
+ Configuration: []byte("Kv"),
+ },
+ wantMgmt: 1,
+ wantHas: []string{
+ "ManifestURL",
+ "https://fleet.example.com/manifest",
+ "Configuration",
+ "Kv",
+ },
+ wantNot: []string{"iTunesStoreID"},
+ },
+ {
+ name: "iOS VPP with empty configuration bytes → key omitted (clear semantics)",
+ params: InstallApplicationParams{
+ CommandUUID: commandUUID,
+ HostPlatform: "ios",
+ ITunesStoreID: "55555",
+ Configuration: []byte{},
+ },
+ wantMgmt: 1,
+ wantNot: []string{"Configuration"},
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ out := string(BuildInstallApplicationCommand(c.params))
+
+ // Roundtrip through the plist parser to confirm we always emit a
+ // well-formed XML plist regardless of inputs. This is the same
+ // shape Apple MDM accepts; if this fails, devices will reject.
+ var anything any
+ format, err := plist.Unmarshal([]byte(out), &anything)
+ require.NoError(t, err, "output must be a valid plist")
+ require.Equal(t, plist.XMLFormat, format, "output must be XML plist (not binary/OpenStep)")
+
+ for _, s := range c.wantHas {
+ require.Contains(t, out, s, "expected to contain %q", s)
+ }
+ for _, s := range c.wantNot {
+ require.NotContains(t, out, s, "did not expect %q in output", s)
+ }
+ require.Contains(t, out,
+ ""+strconv.Itoa(c.wantMgmt)+"",
+ "ManagementFlags should be %d", c.wantMgmt)
+ require.Contains(t, out, ""+commandUUID+"")
+ })
+ }
+}
+
+func TestBuildInstallApplicationCommand_FullPlistDocumentNormalized(t *testing.T) {
+ fullDoc := []byte(`
+
+
+
+ ServerURL
+ https://example.com
+
+`)
+
+ out := string(BuildInstallApplicationCommand(InstallApplicationParams{
+ CommandUUID: "uuid",
+ HostPlatform: "ios",
+ ITunesStoreID: "1",
+ Configuration: fullDoc,
+ }))
+
+ var parsed map[string]any
+ _, err := plist.Unmarshal([]byte(out), &parsed)
+ require.NoError(t, err, "output with full-doc config must be a valid plist")
+ require.Equal(t, 1, strings.Count(out, " element allowed")
+
+ cmd := parsed["Command"].(map[string]any)
+ cfgDict, ok := cmd["Configuration"].(map[string]any)
+ require.True(t, ok, "Configuration value should be a dict")
+ require.Equal(t, "https://example.com", cfgDict["ServerURL"])
+}
+
+func TestBuildInstallApplicationCommand_ConfigurationOuterDictPreserved(t *testing.T) {
+ // The validator stores the bytes including the outer ....
+ // Builder must inline them as-is so the resulting plist nests correctly:
+ // Configuration...
+ cfg := []byte(`
+ ServerURL
+ https://example.com
+`)
+
+ out := string(BuildInstallApplicationCommand(InstallApplicationParams{
+ CommandUUID: "uuid",
+ HostPlatform: "ios",
+ ITunesStoreID: "1",
+ Configuration: cfg,
+ }))
+
+ // The Configuration value should be the stored dict, not double-wrapped.
+ configIdx := strings.Index(out, "Configuration")
+ require.NotEqual(t, -1, configIdx, "Configuration key present")
+ tail := out[configIdx:]
+ require.Contains(t, tail, string(cfg), "stored bytes inlined verbatim")
+
+ // Re-parse and verify Configuration is a nested dict, not a string.
+ var parsed map[string]any
+ _, err := plist.Unmarshal([]byte(out), &parsed)
+ require.NoError(t, err)
+ cmd, ok := parsed["Command"].(map[string]any)
+ require.True(t, ok, "Command should be a dict")
+ cfgVal, ok := cmd["Configuration"]
+ require.True(t, ok, "Configuration key should be present")
+ cfgDict, ok := cfgVal.(map[string]any)
+ require.True(t, ok, "Configuration value should be a dict, got %T", cfgVal)
+ require.Equal(t, "https://example.com", cfgDict["ServerURL"])
+}