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"]) +}