From 9032883b47cf78e8e7d1b9c2d7f4e74c968bf1f3 Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Mon, 1 Jun 2026 11:41:34 -0300 Subject: [PATCH] Fix `fleetctl get fleets` to use source of truth (DB) for software (#46480) Resolves #44970 (1/2). --- - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - [X] Added/updated automated tests - [X] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **Bug Fixes** * `fleetctl get fleets` / `get teams` now display software and setup experience from authoritative software endpoints. * Preserve literal setup_experience fields (avoid erroneous macos_setup renames) when applying and when transmitting JSON for software entries. * **Tests** * Added regression tests and test helpers to ensure software/setup_experience are sourced correctly and to prevent nil panics in related tests. --- changes/44970-fix-apply | 1 + changes/44970-get-fleets-setup-experience | 1 + .../fleetctl/apply_deprecated_test.go | 1 + cmd/fleetctl/fleetctl/apply_test.go | 1 + cmd/fleetctl/fleetctl/get.go | 166 +++++++++++++++++- cmd/fleetctl/fleetctl/get_test.go | 96 ++++++++++ .../platform/endpointer/json_key_rewriter.go | 59 ++++++- .../endpointer/json_key_rewriter_test.go | 93 ++++++++++ 8 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 changes/44970-fix-apply create mode 100644 changes/44970-get-fleets-setup-experience diff --git a/changes/44970-fix-apply b/changes/44970-fix-apply new file mode 100644 index 0000000000..e4129fc4c5 --- /dev/null +++ b/changes/44970-fix-apply @@ -0,0 +1 @@ +* Fixed bug in `apply` to prevent `setup_experience` in software items from being renamed to `macos_setup`. diff --git a/changes/44970-get-fleets-setup-experience b/changes/44970-get-fleets-setup-experience new file mode 100644 index 0000000000..108310a165 --- /dev/null +++ b/changes/44970-get-fleets-setup-experience @@ -0,0 +1 @@ +- Fixed `fleetctl get fleets` (and `fleetctl get teams`) so the software section, including each app's `setup_experience` value, reflects the real configuration instead of being read from the (potentially stale) team config. Software is now fetched from the software titles and setup experience endpoints, which are the source of truth. diff --git a/cmd/fleetctl/fleetctl/apply_deprecated_test.go b/cmd/fleetctl/fleetctl/apply_deprecated_test.go index b237775ae9..c1c7304cb6 100644 --- a/cmd/fleetctl/fleetctl/apply_deprecated_test.go +++ b/cmd/fleetctl/fleetctl/apply_deprecated_test.go @@ -605,6 +605,7 @@ func TestApplyMacosSetupDeprecatedKeys(t *testing.T) { license := &fleet.LicenseInfo{Tier: tier, Expiration: time.Now().Add(24 * time.Hour)} depStorage := SetupMockDEPStorageAndMockDEPServer(t) _, ds := testing_utils.RunServerWithMockedDS(t, &service.TestServerOpts{License: license, DEPStorage: depStorage}) + mockEmptyTeamSoftware(ds) tm1 := &fleet.Team{ID: 1, Name: "tm1", Config: fleet.TeamConfig{ Features: fleet.Features{ diff --git a/cmd/fleetctl/fleetctl/apply_test.go b/cmd/fleetctl/fleetctl/apply_test.go index 6b4aff6cfa..6b969fccf9 100644 --- a/cmd/fleetctl/fleetctl/apply_test.go +++ b/cmd/fleetctl/fleetctl/apply_test.go @@ -2203,6 +2203,7 @@ func TestApplyMacosSetup(t *testing.T) { license := &fleet.LicenseInfo{Tier: tier, Expiration: time.Now().Add(24 * time.Hour)} depStorage := SetupMockDEPStorageAndMockDEPServer(t) _, ds := testing_utils.RunServerWithMockedDS(t, &service.TestServerOpts{License: license, DEPStorage: depStorage}) + mockEmptyTeamSoftware(ds) tm1 := &fleet.Team{ID: 1, Name: "tm1", Config: fleet.TeamConfig{ Features: fleet.Features{ diff --git a/cmd/fleetctl/fleetctl/get.go b/cmd/fleetctl/fleetctl/get.go index 2fd57abe8f..a1b7afb43a 100644 --- a/cmd/fleetctl/fleetctl/get.go +++ b/cmd/fleetctl/fleetctl/get.go @@ -15,6 +15,7 @@ import ( "github.com/beevik/etree" "github.com/fatih/color" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/pkg/rawjson" "github.com/fleetdm/fleet/v4/pkg/secure" "github.com/fleetdm/fleet/v4/server/fleet" @@ -266,15 +267,24 @@ func printUserRoles(c *cli.Context, users []fleet.User) error { return printSpec(c, spec) } -func printTeams(c *cli.Context, teams []fleet.Team) error { +func printTeams(c *cli.Context, client *service.Client, teams []fleet.Team) error { for _, team := range teams { - var teamItem interface{} = team + software, err := getTeamSoftwareSpec(client, team.ID) + if err != nil { + return err + } + + var teamItem any if c.Bool(yamlFlagName) { teamSpec, err := fleet.TeamSpecFromTeam(&team) if err != nil { return err } + teamSpec.Software = software teamItem = teamSpec + } else { + team.Config.Software = software + teamItem = team } spec := specGeneric{ Kind: fleet.FleetKind, @@ -291,6 +301,156 @@ func printTeams(c *cli.Context, teams []fleet.Team) error { return nil } +// getTeamSoftwareSpec builds the software section of a team spec from the +// authoritative software endpoints (software titles + setup experience). +func getTeamSoftwareSpec(client *service.Client, teamID uint) (*fleet.SoftwareSpec, error) { + titles, err := client.ListSoftwareTitles(fmt.Sprintf("available_for_install=1&fleet_id=%d", teamID)) + if err != nil { + return nil, fmt.Errorf("could not list software titles for fleet %d: %w", teamID, err) + } + if len(titles) == 0 { + return nil, nil + } + + // The setup experience membership is the source of truth in the setup + // experience tables, exposed via the setup experience endpoint. + setupSoftwareByTitleID := make(map[uint]struct{}) + setupAppsByName := make(map[string]struct{}) + setupSoftware, err := client.GetSetupExperienceSoftware("macos,windows,linux,ios,ipados,android", teamID) + if err != nil { + return nil, fmt.Errorf("could not get setup experience software for fleet %d: %w", teamID, err) + } + for _, sw := range setupSoftware { + if pkg := sw.SoftwarePackage; pkg != nil && pkg.InstallDuringSetup != nil && *pkg.InstallDuringSetup { + setupSoftwareByTitleID[sw.ID] = struct{}{} + } + if app := sw.AppStoreApp; app != nil && app.InstallDuringSetup != nil && *app.InstallDuringSetup { + setupAppsByName[app.FullyQualifiedName()] = struct{}{} + } + } + + var ( + packages []fleet.SoftwarePackageSpec + fmas []fleet.MaintainedAppSpec + appStoreApps []fleet.TeamSpecAppStoreApp + seenInHouse = make(map[string]struct{}) + fmaSlugs = make(map[uint]string) + ) + + resolveFMASlug := func(id uint) (string, error) { + if slug, ok := fmaSlugs[id]; ok { + return slug, nil + } + app, err := client.GetFleetMaintainedApp(id) + if err != nil { + return "", fmt.Errorf("could not resolve fleet-maintained app %d: %w", id, err) + } + fmaSlugs[id] = app.Slug + return app.Slug, nil + } + + for _, title := range titles { + // In-house (.ipa) apps can appear once per platform; only emit them once. + if isDuplicateInHouseApp(title, seenInHouse) { + continue + } + + // Fetch the full title so we get fields not present in the list result, + // such as the configured labels. + detail, err := client.GetSoftwareTitleByID(title.ID, &teamID) + if err != nil { + return nil, fmt.Errorf("could not get software title %d for fleet %d: %w", title.ID, teamID, err) + } + + switch { + case detail.SoftwarePackage != nil: + pkg := detail.SoftwarePackage + if pkg.FleetMaintainedAppID != nil { + slug, err := resolveFMASlug(*pkg.FleetMaintainedAppID) + if err != nil { + return nil, err + } + fmas = append(fmas, fleet.MaintainedAppSpec{ + Slug: slug, + SelfService: pkg.SelfService, + LabelsIncludeAny: scopeLabelNames(pkg.LabelsIncludeAny), + LabelsExcludeAny: scopeLabelNames(pkg.LabelsExcludeAny), + LabelsIncludeAll: scopeLabelNames(pkg.LabelsIncludeAll), + Categories: pkg.Categories, + InstallDuringSetup: setupExperienceValue(setupSoftwareByTitleID, title.ID), + }) + continue + } + packages = append(packages, fleet.SoftwarePackageSpec{ + URL: pkg.URL, + SHA256: pkg.StorageID, + SelfService: pkg.SelfService, + LabelsIncludeAny: scopeLabelNames(pkg.LabelsIncludeAny), + LabelsExcludeAny: scopeLabelNames(pkg.LabelsExcludeAny), + LabelsIncludeAll: scopeLabelNames(pkg.LabelsIncludeAll), + Categories: pkg.Categories, + InstallDuringSetup: setupExperienceValue(setupSoftwareByTitleID, title.ID), + }) + case detail.AppStoreApp != nil: + app := detail.AppStoreApp + var installDuringSetup optjson.Bool + if _, ok := setupAppsByName[app.VPPAppID.String()]; ok { + installDuringSetup = optjson.SetBool(true) + } + appStoreAppSpec := fleet.TeamSpecAppStoreApp{ + AppStoreID: app.AdamID, + Platform: string(app.Platform), + SelfService: app.SelfService, + LabelsIncludeAny: scopeLabelNames(app.LabelsIncludeAny), + LabelsExcludeAny: scopeLabelNames(app.LabelsExcludeAny), + LabelsIncludeAll: scopeLabelNames(app.LabelsIncludeAll), + Categories: app.Categories, + InstallDuringSetup: installDuringSetup, + } + appStoreApps = append(appStoreApps, appStoreAppSpec) + } + } + + if len(packages) == 0 && len(fmas) == 0 && len(appStoreApps) == 0 { + return nil, nil + } + + spec := &fleet.SoftwareSpec{} + if len(packages) > 0 { + spec.Packages = optjson.SetSlice(packages) + } + if len(fmas) > 0 { + spec.FleetMaintainedApps = optjson.SetSlice(fmas) + } + if len(appStoreApps) > 0 { + spec.AppStoreApps = optjson.SetSlice(appStoreApps) + } + return spec, nil +} + +// setupExperienceValue returns an optjson.Bool set to true when the given title +// is part of the setup experience, and an unset value otherwise (so it is not +// changed on re-apply). +func setupExperienceValue(setupByTitleID map[uint]struct{}, titleID uint) optjson.Bool { + if _, ok := setupByTitleID[titleID]; ok { + return optjson.SetBool(true) + } + return optjson.Bool{} +} + +// scopeLabelNames extracts the label names from a list of software scope labels. +// It returns nil when there are no labels, so the field is omitted from output. +func scopeLabelNames(labels []fleet.SoftwareScopeLabel) []string { + if len(labels) == 0 { + return nil + } + names := make([]string, len(labels)) + for i, l := range labels { + names[i] = l.LabelName + } + return names +} + func printSpec(c *cli.Context, spec specGeneric) error { // Marshal the spec value to JSON, unmarshal to a raw tree, and apply // alias key renames (e.g. "teams" → "fleets") so both JSON and YAML @@ -1257,7 +1417,7 @@ func getFleetsCommand() *cli.Command { } if c.Bool(jsonFlagName) || c.Bool(yamlFlagName) { - err = printTeams(c, teams) + err = printTeams(c, client, teams) if err != nil { return err } diff --git a/cmd/fleetctl/fleetctl/get_test.go b/cmd/fleetctl/fleetctl/get_test.go index 012f43d0cf..bf80fdff33 100644 --- a/cmd/fleetctl/fleetctl/get_test.go +++ b/cmd/fleetctl/fleetctl/get_test.go @@ -94,6 +94,19 @@ var userRoleList = []*fleet.User{ }, } +// mockEmptyTeamSoftware wires the datastore methods used by `get teams` to +// fetch a team's software (titles + setup experience) so they return no +// software. Tests that don't exercise software output use this to avoid nil +// func panics now that the command fetches software from these endpoints. +func mockEmptyTeamSoftware(ds *mock.Store) { + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, &fleet.PaginationMetadata{}, nil + } + ds.ListSetupExperienceSoftwareTitlesFunc = func(ctx context.Context, platform string, teamID uint, opts fleet.ListOptions) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, &fleet.PaginationMetadata{}, nil + } +} + var setCurrentUserSession = func(t *testing.T, ds *mock.Store, user *fleet.User) { user, err := ds.NewUser(context.Background(), user) require.NoError(t, err) @@ -250,6 +263,7 @@ func TestGetTeams(t *testing.T) { }, }, nil } + mockEmptyTeamSoftware(ds) b, err := os.ReadFile(filepath.Join("testdata", "expectedGetTeamsText.txt")) require.NoError(t, err) @@ -345,6 +359,87 @@ func TestGetTeamsByName(t *testing.T) { assert.Equal(t, expectedText, runAppForTest(t, []string{"get", "fleets", "--name", "test1"})) } +// TestGetTeamsSoftwareFromSourceOfTruth verifies that `get fleets` builds the +// software section (including the setup_experience membership) from the +// software endpoints, which are the source of truth, rather than from the +// (potentially stale) team config. Regression test for +// https://github.com/fleetdm/fleet/issues/44970. +func TestGetTeamsSoftwareFromSourceOfTruth(t *testing.T) { + _, ds := testing_utils.RunServerWithMockedDS(t, + &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}}) + + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + return []*fleet.Team{ + { + ID: 1, + Name: "team1", + // The team config carries no (or stale) software; it must be ignored. + Config: fleet.TeamConfig{}, + }, + }, nil + } + ds.TeamExistsFunc = func(ctx context.Context, teamID uint) (bool, error) { + return true, nil + } + + // Two titles available for install: a VPP app store app and a custom package. + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + require.True(t, opt.AvailableForInstall) + require.NotNil(t, opt.TeamID) + require.EqualValues(t, 1, *opt.TeamID) + return []fleet.SoftwareTitleListResult{ + {ID: 10, Name: "VPPApp", AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: "123", Platform: "darwin"}}, + {ID: 20, Name: "Pkg", SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "pkg.pkg", PackageURL: new("https://example.com/pkg.pkg")}}, + }, 2, &fleet.PaginationMetadata{}, nil + } + + // The VPP app is part of the setup experience; this is the source of truth + // for the setup_experience flag, not the team config. + ds.ListSetupExperienceSoftwareTitlesFunc = func(ctx context.Context, platform string, teamID uint, opts fleet.ListOptions) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return []fleet.SoftwareTitleListResult{ + {ID: 10, AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: "123", Platform: "darwin", InstallDuringSetup: new(true)}}, + }, 1, &fleet.PaginationMetadata{}, nil + } + + ds.SoftwareTitleByIDFunc = func(ctx context.Context, id uint, teamID *uint, tmFilter fleet.TeamFilter) (*fleet.SoftwareTitle, error) { + switch id { + case 10: + return &fleet.SoftwareTitle{ + ID: 10, + Name: "VPPApp", + AppStoreApp: &fleet.VPPAppStoreApp{ + VPPAppID: fleet.VPPAppID{AdamID: "123", Platform: "darwin"}, + SelfService: true, + }, + }, nil + case 20: + return &fleet.SoftwareTitle{ + ID: 20, + Name: "Pkg", + SoftwarePackage: &fleet.SoftwareInstaller{ + URL: "https://example.com/pkg.pkg", + StorageID: "abc123", + }, + }, nil + } + return nil, fmt.Errorf("unexpected software title id %d", id) + } + + out := runAppForTest(t, []string{"get", "fleets", "--yaml"}) + + // The app store app's setup_experience must reflect the real state (true), + // not the empty/null value previously read from the team config. + require.Contains(t, out, "app_store_id:") + require.Contains(t, out, "setup_experience: true") + // The package URL comes from the software title, not the config. + require.Contains(t, out, "url: https://example.com/pkg.pkg") + require.Contains(t, out, "hash_sha256: abc123") + + require.True(t, ds.ListSoftwareTitlesFuncInvoked) + require.True(t, ds.ListSetupExperienceSoftwareTitlesFuncInvoked) + require.True(t, ds.SoftwareTitleByIDFuncInvoked) +} + func TestGetHosts(t *testing.T) { _, ds := testing_utils.RunServerWithMockedDS(t) @@ -2694,6 +2789,7 @@ func TestGetTeamsYAMLAndApply(t *testing.T) { ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { return []*fleet.Team{team1, team2}, nil } + mockEmptyTeamSoftware(ds) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{AgentOptions: &agentOpts, MDM: fleet.MDM{EnabledAndConfigured: true}}, nil } diff --git a/server/platform/endpointer/json_key_rewriter.go b/server/platform/endpointer/json_key_rewriter.go index 4eae18c8d5..3b6eaca084 100644 --- a/server/platform/endpointer/json_key_rewriter.go +++ b/server/platform/endpointer/json_key_rewriter.go @@ -159,6 +159,14 @@ func RewriteOldToNewKeys(data []byte, rules []AliasRule) ([]byte, error) { return result, err } +// softwareScopeKey marks the JSON object/array container whose contents are +// the values of TeamSpec.Software — i.e. SoftwarePackageSpec / +// TeamSpecAppStoreApp / MaintainedAppSpec items. The literal `setup_experience` +// install flag on those items collides with the `macos_setup`↔`setup_experience` +// rename on the MDM section, so renames are skipped under this subtree. See +// https://github.com/fleetdm/fleet/issues/44970. +const softwareScopeKey = "software" + // rewrite reads tokens from src, rewrites deprecated keys, checks for alias // conflicts, and writes the transformed JSON to w. func (r *JSONKeyRewriteReader) rewrite(src io.Reader, w io.Writer) error { @@ -169,6 +177,24 @@ func (r *JSONKeyRewriteReader) rewrite(src io.Reader, w io.Writer) error { // Pushed on '{', popped on '}'. var keyScopes []map[string]bool + // Track whether we are currently inside the `software` subtree. + // pendingKey is the most-recent object key whose value has not yet been + // read; openContainer/closeContainer maintain softwareDepth by checking + // whether the container being opened lives under `software`. + pendingKey := "" + softwareDepth := 0 + openContainer := func() { + if softwareDepth > 0 || pendingKey == softwareScopeKey { + softwareDepth++ + } + pendingKey = "" + } + closeContainer := func() { + if softwareDepth > 0 { + softwareDepth-- + } + } + for { tok, err := dec.ReadToken() if err != nil { @@ -183,6 +209,7 @@ func (r *JSONKeyRewriteReader) rewrite(src io.Reader, w io.Writer) error { switch kind { case '{': keyScopes = append(keyScopes, make(map[string]bool)) + openContainer() if err := enc.WriteToken(tok); err != nil { return err } @@ -191,6 +218,19 @@ func (r *JSONKeyRewriteReader) rewrite(src io.Reader, w io.Writer) error { if len(keyScopes) > 0 { keyScopes = keyScopes[:len(keyScopes)-1] } + closeContainer() + if err := enc.WriteToken(tok); err != nil { + return err + } + + case '[': + openContainer() + if err := enc.WriteToken(tok); err != nil { + return err + } + + case ']': + closeContainer() if err := enc.WriteToken(tok); err != nil { return err } @@ -213,6 +253,18 @@ func (r *JSONKeyRewriteReader) rewrite(src io.Reader, w io.Writer) error { if isKey { keyName := tok.String() + // Inside the `software` subtree, all inner keys are literal — + // most notably each item's `setup_experience` is a bool + // install flag, not the renamed `macos_setup` container. Pass + // them through untouched. + if softwareDepth > 0 { + pendingKey = keyName + if err := enc.WriteToken(tok); err != nil { + return err + } + continue + } + // Use OldKey as the canonical key for scope tracking. // Both OldKey (pass-through) and NewKey (rewrite) resolve // to the same canonical key for conflict detection. @@ -233,6 +285,7 @@ func (r *JSONKeyRewriteReader) rewrite(src io.Reader, w io.Writer) error { scope[canonicalKey] = true } + pendingKey = keyName // Write the key as-is (old name, which the struct expects). if err := enc.WriteToken(tok); err != nil { return err @@ -251,25 +304,29 @@ func (r *JSONKeyRewriteReader) rewrite(src io.Reader, w io.Writer) error { scope[canonicalKey] = true } + pendingKey = canonicalKey // Write the rewritten (old) key. if err := enc.WriteToken(jsontext.String(canonicalKey)); err != nil { return err } } else { // Not an aliased key — pass through unchanged. + pendingKey = keyName if err := enc.WriteToken(tok); err != nil { return err } } } else { // String value — pass through unchanged. + pendingKey = "" if err := enc.WriteToken(tok); err != nil { return err } } default: - // All other tokens: [, ], numbers, bools, null — pass through. + // All other tokens: numbers, bools, null — scalar values. + pendingKey = "" if err := enc.WriteToken(tok); err != nil { return err } diff --git a/server/platform/endpointer/json_key_rewriter_test.go b/server/platform/endpointer/json_key_rewriter_test.go index 5ac9ff9698..901c94a44d 100644 --- a/server/platform/endpointer/json_key_rewriter_test.go +++ b/server/platform/endpointer/json_key_rewriter_test.go @@ -205,6 +205,99 @@ func TestJSONKeyRewriteReader_ArrayOfObjects(t *testing.T) { } } +// TestJSONKeyRewriteReader_SoftwareSubtreeSkipsRules verifies that keys inside +// the `software` subtree are not subject to rename rules. The literal +// `setup_experience` install flag on SoftwarePackageSpec / TeamSpecAppStoreApp +// / MaintainedAppSpec items collides with the `macos_setup`↔`setup_experience` +// rename on the MDM section, and must be passed through untouched. +// Regression test for https://github.com/fleetdm/fleet/issues/44970. +func TestJSONKeyRewriteReader_SoftwareSubtreeSkipsRules(t *testing.T) { + input := `{ + "setup_experience": {"enable_end_user_authentication": true}, + "software": { + "packages": [ + {"url": "http://foo", "setup_experience": true}, + {"url": "http://bar", "setup_experience": false} + ], + "app_store_apps": [ + {"app_store_id": "1", "setup_experience": null} + ], + "fleet_maintained_apps": [ + {"slug": "foo", "setup_experience": true} + ] + } + }` + rules := []AliasRule{{OldKey: "macos_setup", NewKey: "setup_experience"}} + + r := NewJSONKeyRewriteReader(strings.NewReader(input), rules) + out, err := io.ReadAll(r) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + + // Top-level `setup_experience` (the object) is rewritten to `macos_setup`. + assert.NotNil(t, result["macos_setup"], "top-level container key must be rewritten") + _, hasNewAtRoot := result["setup_experience"] + assert.False(t, hasNewAtRoot, "new key should have been rewritten at the root") + + // Literal `setup_experience` flags inside software entries must NOT have + // been rewritten to `macos_setup`. + sw := result["software"].(map[string]any) + pkgs := sw["packages"].([]any) + assert.Equal(t, true, pkgs[0].(map[string]any)["setup_experience"]) + assert.Equal(t, false, pkgs[1].(map[string]any)["setup_experience"]) + _, hasMacOSSetupOnPkg := pkgs[0].(map[string]any)["macos_setup"] + assert.False(t, hasMacOSSetupOnPkg, "literal setup_experience inside software must not be rewritten") + + apps := sw["app_store_apps"].([]any) + assert.Nil(t, apps[0].(map[string]any)["setup_experience"]) + _, hasMacOSSetupOnApp := apps[0].(map[string]any)["macos_setup"] + assert.False(t, hasMacOSSetupOnApp, "null setup_experience inside software must not be rewritten") + + fmas := sw["fleet_maintained_apps"].([]any) + assert.Equal(t, true, fmas[0].(map[string]any)["setup_experience"]) + _, hasMacOSSetupOnFMA := fmas[0].(map[string]any)["macos_setup"] + assert.False(t, hasMacOSSetupOnFMA, "literal setup_experience on FMA must not be rewritten") +} + +// TestRewriteOldToNewKeys_SoftwareSubtreeSkipsRules verifies the same software- +// scope skip in the reverse direction (old→new). A client posting a YAML with +// `setup_experience: true` on software items must not have those flags clobbered +// to `macos_setup` during client-side normalization. +func TestRewriteOldToNewKeys_SoftwareSubtreeSkipsRules(t *testing.T) { + input := `{ + "macos_setup": {"enable_end_user_authentication": true}, + "software": { + "packages": [{"url": "http://foo", "setup_experience": true}], + "app_store_apps": [{"app_store_id": "1", "setup_experience": true}], + "fleet_maintained_apps": [{"slug": "foo", "setup_experience": true}] + } + }` + rules := []AliasRule{{OldKey: "macos_setup", NewKey: "setup_experience"}} + + out, err := RewriteOldToNewKeys([]byte(input), rules) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(out, &result)) + + // Top-level old `macos_setup` is rewritten to new `setup_experience`. + assert.NotNil(t, result["setup_experience"], "top-level old key must be rewritten to new") + _, hasOldAtRoot := result["macos_setup"] + assert.False(t, hasOldAtRoot, "old key should have been rewritten at the root") + + // Literal `setup_experience` flags inside software entries must remain. + sw := result["software"].(map[string]any) + for _, key := range []string{"packages", "app_store_apps", "fleet_maintained_apps"} { + items := sw[key].([]any) + first := items[0].(map[string]any) + assert.Equal(t, true, first["setup_experience"], "literal flag on %s must be preserved", key) + _, hasOld := first["macos_setup"] + assert.False(t, hasOld, "literal setup_experience on %s must not be renamed to macos_setup", key) + } +} + func TestJSONKeyRewriteReader_MultipleRules(t *testing.T) { input := `{"team_id": 1, "team_name": "Engineering"}` rules := []AliasRule{