package gitops import ( "context" "database/sql" "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl" "github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/fleetctltest" "github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/testing_utils" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/server/dev_mode" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( teamName = "Team Test" ) func TestGitOpsTeamSoftwareInstallers(t *testing.T) { testing_utils.StartSoftwareInstallerServer(t) testing_utils.StartAndServeVPPServer(t) cases := []struct { file string wantErr string }{ {"testdata/gitops/team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."}, {"testdata/gitops/team_software_installer_install_script_secret.yml", "environment variable \"FLEET_SECRET_NAME\" not set"}, {"testdata/gitops/team_software_installer_unsupported.yml", "The file's content doesn't match a supported installer format. Supported types: .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .py, .ipa or .ps1."}, {"testdata/gitops/team_software_installer_too_large.yml", "The maximum file size is 513MiB"}, {"testdata/gitops/team_software_installer_valid.yml", ""}, {"testdata/gitops/team_software_installer_subdir.yml", ""}, {"testdata/gitops/subdir/team_software_installer_valid.yml", ""}, {"testdata/gitops/team_software_installer_valid_apply.yml", ""}, {"testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."}, {"testdata/gitops/team_software_installer_pre_condition_multiple_queries_apply.yml", "should have only one query."}, {"testdata/gitops/team_software_installer_pre_condition_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_uninstall_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_no_url.yml", "at least one of hash_sha256 or url is required for each software package"}, {"testdata/gitops/team_software_installer_no_url_multi.yml", "multi_missing_url.yml, list item #1"}, { "testdata/gitops/team_software_installer_invalid_self_service_value.yml", "Couldn't edit \"../../fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml\" at \"software.packages.self_service\", expected type bool but got string", }, { "testdata/gitops/team_software_installer_invalid_both_include_exclude.yml", `only one of "labels_include_all", "labels_exclude_any" or "labels_include_any" can be specified`, }, {"testdata/gitops/team_software_installer_valid_include.yml", ""}, {"testdata/gitops/team_software_installer_valid_exclude.yml", ""}, {"testdata/gitops/team_software_installer_valid_include_all.yml", ""}, { "testdata/gitops/team_software_installer_invalid_unknown_label.yml", "Please create the missing labels, or update your settings to not refer to these labels.", }, // display_name tests {"testdata/gitops/team_software_installer_with_display_name.yml", ""}, {"testdata/gitops/team_software_installer_display_name_too_long.yml", "display_name is too long (max 255 characters)"}, {"testdata/gitops/team_software_app_store_display_name_too_long.yml", "display_name is too long (max 255 characters)"}, // team tests for setup experience software/script {"testdata/gitops/team_setup_software_valid.yml", ""}, {"testdata/gitops/team_setup_software_on_package.yml", ""}, {"testdata/gitops/team_setup_software_defined_in_conflicting_places.yml", " Setup experience may only be specified directly on software or within macos_setup, but not both."}, {"testdata/gitops/team_setup_software_defined_in_conflicting_places_vpp.yml", " Setup experience may only be specified directly on software or within macos_setup, but not both."}, {"testdata/gitops/team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"}, {"testdata/gitops/team_setup_software_invalid_software_package.yml", "no_such_software.yml\" does not exist for that fleet"}, {"testdata/gitops/team_setup_software_invalid_vpp_app.yml", "\"no_such_app\" does not exist for that fleet"}, {"testdata/gitops/team_software_installer_valid_ipa.yml", ""}, {"testdata/gitops/team_software_installer_subdir_ipa.yml", ""}, } for _, c := range cases { c.file = filepath.Join("../../fleetctl", c.file) t.Run(filepath.Base(c.file), func(t *testing.T) { ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t) tokExpire := time.Now().Add(time.Hour) token, err := test.CreateVPPTokenEncoded(tokExpire, "fleet", "ca") require.NoError(t, err) ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam, _ map[string]uint) (bool, error) { return false, nil } ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) { return []fleet.VPPAppResponse{}, nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { return &fleet.VPPTokenDB{ ID: 1, OrgName: "Fleet", Location: "Earth", RenewDate: tokExpire, Token: string(token), Teams: nil, CountryCode: "us", }, nil } ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { return []*fleet.LabelSpec{ { Name: "a", Description: "A global label", LabelMembershipType: fleet.LabelMembershipTypeManual, Hosts: []string{"host2", "host3"}, }, { Name: "b", Description: "Another label", LabelMembershipType: fleet.LabelMembershipTypeDynamic, Query: "SELECT 1 from osquery_info", }, }, nil } labelToIDs := map[string]uint{ fleet.BuiltinLabelMacOS14Plus: 1, "a": 2, "b": 3, } ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) { // for this test, recognize labels a and b (as well as the built-in macos 14+ one) ret := make(map[string]uint) for _, lbl := range names { id, ok := labelToIDs[lbl] if ok { ret[lbl] = id } } return ret, nil } ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) { return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil } ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID *uint, url string) (*fleet.ExistingSoftwareInstaller, error) { return nil, nil } ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) { return map[string]uint{}, nil } ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, options fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil } ds.ListCertificateAuthoritiesFunc = func(ctx context.Context) ([]*fleet.CertificateAuthoritySummary, error) { return nil, nil } ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error { return nil } ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error { return nil } _, err = fleetctltest.RunAppNoChecks([]string{"gitops", "-f", c.file}) if c.wantErr == "" { require.NoError(t, err) } else { require.ErrorContains(t, err, c.wantErr) } }) } } func TestGitOpsTeamSoftwareInstallersQueryEnv(t *testing.T) { testing_utils.StartSoftwareInstallerServer(t) ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t) t.Setenv("QUERY_VAR", "IT_WORKS") ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { if len(installers) != 0 && installers[0].PreInstallQuery != "select IT_WORKS" { return fmt.Errorf("Missing env var, got %s", installers[0].PreInstallQuery) } return nil } ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { return nil, nil } ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) { return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil } ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID *uint, url string) (*fleet.ExistingSoftwareInstaller, error) { return nil, nil } ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) { return map[string]uint{}, nil } ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, options fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil } ds.ListCertificateAuthoritiesFunc = func(ctx context.Context) ([]*fleet.CertificateAuthoritySummary, error) { return nil, nil } _, err := fleetctltest.RunAppNoChecks([]string{"gitops", "-f", "../../fleetctl/testdata/gitops/team_software_installer_valid_env_query.yml"}) require.NoError(t, err) } // gitops --dry-run with software.packages: [] must short-circuit on the server // and not exercise any of the batch-set or installer-validation datastore paths. // See ee/server/service/software_installers.go::BatchSetSoftwareInstallers. func TestGitOpsTeamSoftwareInstallersEmptyPackagesDryRun(t *testing.T) { ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t) ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { t.Errorf("BatchSetSoftwareInstallers must not be called for dry-run with empty packages") return nil } ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) { t.Errorf("GetTeamsWithInstallerByHash must not be called for dry-run with empty packages") return nil, nil } _, err := fleetctltest.RunAppNoChecks([]string{ "gitops", "--dry-run", "-f", "../../fleetctl/testdata/gitops/team_software_installer_valid_empty_packages.yml", }) require.NoError(t, err) } // gitops must print one message per software package the batch set will delete // (real run) or would delete (dry run): packages on the server whose title // matches no entry in the YAML. See #43729. func TestGitOpsSoftwareDeletionWarnings(t *testing.T) { ds, _, savedTeams := testing_utils.SetupFullGitOpsPremiumServer(t) // Pre-populate the team: with an existing team the empty-packages dry run // must NOT short-circuit when installers are pending deletion. tmName := os.Getenv("TEST_TEAM_NAME") team := &fleet.Team{ID: 123, Name: tmName} savedTeams[tmName] = &team var pendingDeletion []fleet.DeletedSoftwarePackage ds.GetSoftwareInstallersPendingDeletionFunc = func(ctx context.Context, tmID *uint, incoming []fleet.SoftwareTitleIdentifier) ([]fleet.DeletedSoftwarePackage, error) { // assert (not require): this runs on a server goroutine, where FailNow would misbehave. assert.Empty(t, incoming) // the YAML has no packages return pendingDeletion, nil } file := "../../fleetctl/testdata/gitops/team_software_installer_valid_empty_packages.yml" t.Run("dry run reports would-be deletions", func(t *testing.T) { pendingDeletion = []fleet.DeletedSoftwarePackage{ {TeamID: &team.ID, TitleID: 1, DisplayName: "Cool App"}, {TeamID: &team.ID, TitleID: 2, DisplayName: "Teammate Tool"}, } out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "--dry-run", "-f", file}) require.NoError(t, err) require.Contains(t, out.String(), "[-] would've deleted software - Cool App\n") require.Contains(t, out.String(), "[-] would've deleted software - Teammate Tool\n") }) t.Run("real run reports deletions", func(t *testing.T) { pendingDeletion = []fleet.DeletedSoftwarePackage{ {TeamID: &team.ID, TitleID: 1, DisplayName: "Cool App"}, } out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "-f", file}) require.NoError(t, err) require.Contains(t, out.String(), "[-] deleted software - Cool App\n") }) t.Run("no deletions, no noise", func(t *testing.T) { pendingDeletion = nil out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "--dry-run", "-f", file}) require.NoError(t, err) require.NotContains(t, out.String(), "deleted software") out, err = fleetctltest.RunAppNoChecks([]string{"gitops", "-f", file}) require.NoError(t, err) require.NotContains(t, out.String(), "deleted software") }) } func TestGitOpsNoTeamVPPPolicies(t *testing.T) { testing_utils.StartAndServeVPPServer(t) cases := []struct { noTeamFile string wantErr string vppApps []fleet.VPPAppResponse }{ { noTeamFile: "testdata/gitops/subdir/no_team_vpp_policies_valid.yml", vppApps: []fleet.VPPAppResponse{ { // for more test coverage Platform: fleet.MacOSPlatform, }, { // for more test coverage TitleID: ptr.Uint(122), Platform: fleet.MacOSPlatform, }, { TeamID: ptr.Uint(0), TitleID: ptr.Uint(123), AppStoreID: "1", Platform: fleet.IOSPlatform, }, { TeamID: ptr.Uint(0), TitleID: ptr.Uint(124), AppStoreID: "1", Platform: fleet.MacOSPlatform, }, { TeamID: ptr.Uint(0), TitleID: ptr.Uint(125), AppStoreID: "1", Platform: fleet.IPadOSPlatform, }, }, }, } for _, c := range cases { c.noTeamFile = filepath.Join("../../fleetctl", c.noTeamFile) t.Run(filepath.Base(c.noTeamFile), func(t *testing.T) { ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t) tokExpire := time.Now().Add(time.Hour) token, err := test.CreateVPPTokenEncoded(tokExpire, "fleet", "ca") require.NoError(t, err) ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam, _ map[string]uint) (bool, error) { return false, nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) { return c.vppApps, nil } ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { return &fleet.VPPTokenDB{ ID: 1, OrgName: "Fleet", Location: "Earth", RenewDate: tokExpire, Token: string(token), Teams: nil, CountryCode: "us", }, nil } labelToIDs := map[string]uint{ fleet.BuiltinLabelMacOS14Plus: 1, "a": 2, "b": 3, } ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) { // for this test, recognize labels a and b (as well as the built-in macos 14+ one) ret := make(map[string]uint) for _, lbl := range names { id, ok := labelToIDs[lbl] if ok { ret[lbl] = id } } return ret, nil } ds.LabelsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]*fleet.Label, error) { return map[string]*fleet.Label{ "a": { ID: 1, Name: "a", }, "b": { ID: 2, Name: "b", }, }, nil } ds.SetAsideLabelsFunc = func(ctx context.Context, notOnTeamID *uint, names []string, user fleet.User) error { return nil } ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) { return map[string]uint{}, nil } ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error { return nil } ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error { return nil } ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) { return &fleet.TeamLite{}, nil } t.Setenv("APPLE_BM_DEFAULT_TEAM", "") globalFile := "../../fleetctl/testdata/gitops/global_config_no_paths.yml" dstPath := filepath.Join(filepath.Dir(c.noTeamFile), "no-team.yml") t.Cleanup(func() { os.Remove(dstPath) }) err = file.Copy(c.noTeamFile, dstPath, 0o755) require.NoError(t, err) _, err = fleetctltest.RunAppNoChecks([]string{"gitops", "-f", globalFile, "-f", dstPath}) if c.wantErr == "" { require.NoError(t, err) } else { require.ErrorContains(t, err, c.wantErr) } }) } } func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { testing_utils.StartSoftwareInstallerServer(t) testing_utils.StartAndServeVPPServer(t) cases := []struct { noTeamFile string wantErr string }{ {"testdata/gitops/no_team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."}, {"testdata/gitops/no_team_software_installer_unsupported.yml", "The file's content doesn't match a supported installer format. Supported types: .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .py, .ipa or .ps1."}, {"testdata/gitops/no_team_software_installer_too_large.yml", "The maximum file size is 513MiB"}, {"testdata/gitops/no_team_software_installer_valid.yml", ""}, {"testdata/gitops/no_team_software_installer_subdir.yml", ""}, {"testdata/gitops/subdir/no_team_software_installer_valid.yml", ""}, {"testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."}, {"testdata/gitops/no_team_software_installer_pre_condition_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_uninstall_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_no_url.yml", "at least one of hash_sha256 or url is required for each software package"}, { "testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "Couldn't edit \"../../fleetctl/testdata/gitops/no-team.yml\" at \"software.packages.self_service\", expected type bool but got string", }, { "testdata/gitops/no_team_software_installer_invalid_both_include_exclude.yml", `only one of "labels_include_all", "labels_exclude_any" or "labels_include_any" can be specified`, }, {"testdata/gitops/no_team_software_installer_valid_include.yml", ""}, {"testdata/gitops/no_team_software_installer_valid_exclude.yml", ""}, {"testdata/gitops/no_team_software_installer_valid_include_all.yml", ""}, { "testdata/gitops/no_team_software_installer_invalid_unknown_label.yml", "Please create the missing labels, or update your settings to not refer to these labels.", }, // No team tests for setup experience software/script {"testdata/gitops/no_team_setup_software_valid.yml", ""}, {"testdata/gitops/no_team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"}, {"testdata/gitops/no_team_setup_software_invalid_software_package.yml", "no_such_software.yml\" does not exist for that fleet"}, {"testdata/gitops/no_team_setup_software_invalid_vpp_app.yml", "\"no_such_app\" does not exist for that fleet"}, {"testdata/gitops/no_team_software_installer_valid_ipa.yml", ""}, {"testdata/gitops/no_team_software_installer_subdir_ipa.yml", ""}, } for _, c := range cases { c.noTeamFile = filepath.Join("../../fleetctl", c.noTeamFile) t.Run(filepath.Base(c.noTeamFile), func(t *testing.T) { ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t) tokExpire := time.Now().Add(time.Hour) token, err := test.CreateVPPTokenEncoded(tokExpire, "fleet", "ca") require.NoError(t, err) ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam, _ map[string]uint) (bool, error) { return false, nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) { return []fleet.VPPAppResponse{}, nil } ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { return &fleet.VPPTokenDB{ ID: 1, OrgName: "Fleet", Location: "Earth", RenewDate: tokExpire, Token: string(token), Teams: nil, CountryCode: "us", }, nil } ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { return []*fleet.LabelSpec{ { Name: "a", Description: "A global label", LabelMembershipType: fleet.LabelMembershipTypeManual, Hosts: []string{"host2", "host3"}, }, { Name: "b", Description: "Another label", LabelMembershipType: fleet.LabelMembershipTypeDynamic, Query: "SELECT 1 from osquery_info", }, }, nil } ds.SetAsideLabelsFunc = func(ctx context.Context, notOnTeamID *uint, names []string, user fleet.User) error { return nil } labelToIDs := map[string]uint{ fleet.BuiltinLabelMacOS14Plus: 1, "a": 2, "b": 3, } ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) { // for this test, recognize labels a and b (as well as the built-in macos 14+ one) ret := make(map[string]uint) for _, lbl := range names { id, ok := labelToIDs[lbl] if ok { ret[lbl] = id } } return ret, nil } ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) { return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil } ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID *uint, url string) (*fleet.ExistingSoftwareInstaller, error) { return nil, nil } ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) { return map[string]uint{}, nil } ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error { return nil } ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error { return nil } ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) { return &fleet.TeamLite{}, nil } t.Setenv("APPLE_BM_DEFAULT_TEAM", "") globalFile := "../../fleetctl/testdata/gitops/global_config_no_paths.yml" if strings.HasPrefix(filepath.Base(c.noTeamFile), "no_team_setup_software") { // the controls section is in the no-team test file, so use a global file without that section globalFile = "../../fleetctl/testdata/gitops/global_config_no_paths_no_controls.yml" } dstPath := filepath.Join(filepath.Dir(c.noTeamFile), "no-team.yml") t.Cleanup(func() { os.Remove(dstPath) }) err = file.Copy(c.noTeamFile, dstPath, 0o755) require.NoError(t, err) _, err = fleetctltest.RunAppNoChecks([]string{"gitops", "-f", globalFile, "-f", dstPath}) if c.wantErr == "" { require.NoError(t, err) } else { require.ErrorContains(t, err, c.wantErr) } }) } } func TestGitOpsTeamVPPApps(t *testing.T) { testing_utils.StartAndServeVPPServer(t) cases := []struct { file string wantErr string tokenExpiration time.Time expectedLabels map[string]uint }{ {"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{}}, {"testdata/gitops/team_vpp_valid_app_self_service.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{}}, {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{}}, {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(-24 * time.Hour), map[string]uint{}}, {"testdata/gitops/team_vpp_valid_app.yml", "VPP token expired", time.Now().Add(-24 * time.Hour), map[string]uint{}}, {"testdata/gitops/team_vpp_invalid_app.yml", "app not available on vpp account", time.Now().Add(24 * time.Hour), map[string]uint{}}, { "testdata/gitops/team_vpp_incorrect_type.yml", "Couldn't edit \"../../fleetctl/testdata/gitops/team_vpp_incorrect_type.yml\" at \"software.app_store_apps.app_store_id\", expected type string but got number", time.Now().Add(24 * time.Hour), map[string]uint{}, }, {"testdata/gitops/team_vpp_empty_adamid.yml", "software app store id required", time.Now().Add(24 * time.Hour), map[string]uint{}}, { "testdata/gitops/team_vpp_valid_app_labels_exclude_any.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{"label 1": 1, "label 2": 2}, }, { "testdata/gitops/team_vpp_valid_app_labels_include_any.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{"label 1": 1, "label 2": 2}, }, { "testdata/gitops/team_vpp_valid_app_labels_include_all.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{"label 1": 1, "label 2": 2}, }, { "testdata/gitops/team_vpp_invalid_app_labels_exclude_any.yml", "Please create the missing labels, or update your settings to not refer to these labels.", time.Now().Add(24 * time.Hour), map[string]uint{"label 1": 1, "label 2": 2}, }, { "testdata/gitops/team_vpp_invalid_app_labels_include_any.yml", "Please create the missing labels, or update your settings to not refer to these labels.", time.Now().Add(24 * time.Hour), map[string]uint{"label 1": 1, "label 2": 2}, }, { "testdata/gitops/team_vpp_invalid_app_labels_both.yml", `only one of "labels_include_all", "labels_exclude_any" or "labels_include_any" can be specified for app store app`, time.Now().Add(24 * time.Hour), map[string]uint{}, }, } for _, c := range cases { c.file = filepath.Join("../../fleetctl", c.file) t.Run(filepath.Base(c.file), func(t *testing.T) { ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t) token, err := test.CreateVPPTokenEncoded(c.tokenExpiration, "fleet", "ca") require.NoError(t, err) ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam, _ map[string]uint) (bool, error) { return false, nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) { return []fleet.VPPAppResponse{}, nil } ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { return &fleet.VPPTokenDB{ ID: 1, OrgName: "Fleet", Location: "Earth", RenewDate: c.tokenExpiration, Token: string(token), Teams: nil, CountryCode: "us", }, nil } ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { return []*fleet.LabelSpec{ { Name: "label 1", Description: "A global label", LabelMembershipType: fleet.LabelMembershipTypeManual, Hosts: []string{"host2", "host3"}, }, { Name: "label 2", Description: "Another label", LabelMembershipType: fleet.LabelMembershipTypeDynamic, Query: "SELECT 1 from osquery_info", }, }, nil } ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) { return map[string]uint{}, nil } found := make(map[string]uint) ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) { for _, l := range names { if id, ok := c.expectedLabels[l]; ok { found[l] = id } } return found, nil } ds.LabelsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]*fleet.Label, error) { found2 := make(map[string]*fleet.Label) for _, l := range names { if id, ok := c.expectedLabels[l]; ok { found2[l] = &fleet.Label{ ID: id, Name: l, } } } return found2, nil } ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, options fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil } ds.ListCertificateAuthoritiesFunc = func(ctx context.Context) ([]*fleet.CertificateAuthoritySummary, error) { return nil, nil } ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error { return nil } ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error { return nil } _, err = fleetctltest.RunAppNoChecks([]string{"gitops", "-f", c.file}) if c.wantErr == "" { require.NoError(t, err) if len(c.expectedLabels) > 0 { require.True(t, ds.LabelIDsByNameFuncInvoked) require.True(t, ds.LabelsByNameFuncInvoked) } require.Equal(t, c.expectedLabels, found) } else { require.ErrorContains(t, err, c.wantErr) } }) } } // TestGitOpsTeamVPPAndApp tests the flow where a new team is created with VPP apps. // GitOps must first create the team, then assign VPP token to it, and only then add VPP apps. func TestGitOpsTeamVPPAndApp(t *testing.T) { testing_utils.StartAndServeVPPServer(t) ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t) renewDate := time.Now().Add(24 * time.Hour) token, err := test.CreateVPPTokenEncoded(renewDate, "fleet", "ca") require.NoError(t, err) ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) { return []fleet.VPPAppResponse{}, nil } ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) { return 0, nil } ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, options fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil } ds.ListCertificateAuthoritiesFunc = func(ctx context.Context) ([]*fleet.CertificateAuthoritySummary, error) { return nil, nil } // The following mocks are key to this test. vppToken := &fleet.VPPTokenDB{ ID: 1, OrgName: "Fleet", Location: "Earth", RenewDate: renewDate, Token: string(token), Teams: nil, CountryCode: "us", } tokensByTeams := make(map[uint]*fleet.VPPTokenDB) ds.UpdateVPPTokenTeamsFunc = func(ctx context.Context, id uint, teams []uint) (*fleet.VPPTokenDB, error) { for _, teamID := range teams { tokensByTeams[teamID] = vppToken } return vppToken, nil } ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { return []*fleet.VPPTokenDB{vppToken}, nil } ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { if teamID == nil { return vppToken, nil } token, ok := tokensByTeams[*teamID] if !ok { return nil, sql.ErrNoRows } return token, nil } ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) { return map[string]uint{}, nil } ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error { return nil } ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error { return nil } ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) { return &fleet.TeamLite{}, nil } buf, err := fleetctltest.RunAppNoChecks([]string{ "gitops", "-f", "../../fleetctl/testdata/gitops/global_config_vpp.yml", "-f", "../../fleetctl/testdata/gitops/team_vpp_valid_app.yml", }) require.NoError(t, err) assert.True(t, ds.UpdateVPPTokenTeamsFuncInvoked) assert.True(t, ds.GetVPPTokenByTeamIDFuncInvoked) assert.True(t, ds.SetTeamVPPAppsFuncInvoked) assert.Contains(t, buf.String(), fmt.Sprintf(fleetctl.ReapplyingTeamForVPPAppsMsg, teamName)) } // TestGitOpsExistingTeamVPPAppsWithMissingTeam tests the scenario where: // - An existing team with app_store_apps is in the VPP config // - A NEW team (doesn't exist yet) is also in the VPP config // When there are missing VPP teams, the VPP config is temporarily cleared, // which removes VPP assignments for ALL teams. We must defer app_store_apps // for all VPP teams, not just missing ones. (Issue #40785) func TestGitOpsExistingTeamVPPAppsWithMissingTeam(t *testing.T) { testing_utils.StartAndServeVPPServer(t) ds, _, savedTeams := testing_utils.SetupFullGitOpsPremiumServer(t) renewDate := time.Now().Add(24 * time.Hour) token, err := test.CreateVPPTokenEncoded(renewDate, "fleet", "ca") require.NoError(t, err) existingTeamName := "Existing Team" newTeamName := "New Team" // Pre-populate the existing team so checkVPPTeamAssignments sees it. existingTeam := &fleet.Team{ID: 42, Name: existingTeamName} savedTeams[existingTeamName] = &existingTeam // No existing labels — this test doesn't test label behavior. ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { return nil, nil } ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) { return []fleet.VPPAppResponse{}, nil } ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) { return 0, nil } ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, options fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil } ds.ListCertificateAuthoritiesFunc = func(ctx context.Context) ([]*fleet.CertificateAuthoritySummary, error) { return nil, nil } vppToken := &fleet.VPPTokenDB{ ID: 1, OrgName: "Fleet", Location: "Earth", RenewDate: renewDate, Token: string(token), Teams: nil, CountryCode: "us", } tokensByTeams := make(map[uint]*fleet.VPPTokenDB) ds.UpdateVPPTokenTeamsFunc = func(ctx context.Context, id uint, teams []uint) (*fleet.VPPTokenDB, error) { for _, teamID := range teams { tokensByTeams[teamID] = vppToken } return vppToken, nil } ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { return []*fleet.VPPTokenDB{vppToken}, nil } ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { if teamID == nil { return vppToken, nil } token, ok := tokensByTeams[*teamID] if !ok { return nil, sql.ErrNoRows } return token, nil } ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) { return map[string]uint{}, nil } ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error { return nil } ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error { return nil } ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) { return &fleet.TeamLite{}, nil } globalCfg := fmt.Sprintf(` policies: queries: agent_options: controls: org_settings: mdm: volume_purchasing_program: - location: Earth teams: - %q - %q server_settings: server_url: https://example.com org_info: org_name: Fleet secrets: - secret: "FLEET_GLOBAL_ENROLL_SECRET" `, existingTeamName, newTeamName) teamCfg := func(name string) string { return fmt.Sprintf(` name: %q team_settings: secrets: - secret: "%s-secret" features: enable_host_users: true enable_software_inventory: true host_expiry_settings: host_expiry_enabled: true host_expiry_window: 30 agent_options: controls: policies: queries: software: app_store_apps: - app_store_id: "1" `, name, name) } tmpDir := t.TempDir() globalFile := filepath.Join(tmpDir, "default.yml") require.NoError(t, os.WriteFile(globalFile, []byte(globalCfg), 0o644)) existingTeamFile := filepath.Join(tmpDir, "existing-team.yml") require.NoError(t, os.WriteFile(existingTeamFile, []byte(teamCfg(existingTeamName)), 0o644)) newTeamFile := filepath.Join(tmpDir, "new-team.yml") require.NoError(t, os.WriteFile(newTeamFile, []byte(teamCfg(newTeamName)), 0o644)) buf, err := fleetctltest.RunAppNoChecks([]string{ "gitops", "-f", globalFile, "-f", existingTeamFile, "-f", newTeamFile, }) require.NoError(t, err) assert.True(t, ds.UpdateVPPTokenTeamsFuncInvoked) assert.True(t, ds.GetVPPTokenByTeamIDFuncInvoked) assert.True(t, ds.SetTeamVPPAppsFuncInvoked) // Both teams should have had their VPP apps deferred and re-applied. assert.Contains(t, buf.String(), fmt.Sprintf(fleetctl.ReapplyingTeamForVPPAppsMsg, existingTeamName)) assert.Contains(t, buf.String(), fmt.Sprintf(fleetctl.ReapplyingTeamForVPPAppsMsg, newTeamName)) } // TestGitOpsNewTeamVPPSharedWithUnsuppliedExistingTeam covers issue #44444: adding // a NEW team that shares a VPP token with an EXISTING team must succeed even when // the existing team's config file is NOT supplied in the same run. Detecting a // missing (new) team strips the VPP config and re-applies it after teams are // created; that re-apply must validate referenced teams against teams that exist in // Fleet, not only against teams whose files were supplied this run. Previously this // errored with "volume_purchasing_program team not found in team configs". func TestGitOpsNewTeamVPPSharedWithUnsuppliedExistingTeam(t *testing.T) { testing_utils.StartAndServeVPPServer(t) ds, _, savedTeams := testing_utils.SetupFullGitOpsPremiumServer(t) renewDate := time.Now().Add(24 * time.Hour) token, err := test.CreateVPPTokenEncoded(renewDate, "fleet", "ca") require.NoError(t, err) existingTeamName := "Existing Team" newTeamName := "New Team" // Pre-populate the existing team so it exists in Fleet, but deliberately do NOT // supply its config file in the gitops run below. existingTeam := &fleet.Team{ID: 42, Name: existingTeamName} savedTeams[existingTeamName] = &existingTeam ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { return nil, nil } ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) { return []fleet.VPPAppResponse{}, nil } ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) { return 0, nil } ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, options fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil } ds.ListCertificateAuthoritiesFunc = func(ctx context.Context) ([]*fleet.CertificateAuthoritySummary, error) { return nil, nil } vppToken := &fleet.VPPTokenDB{ ID: 1, OrgName: "Fleet", Location: "Earth", RenewDate: renewDate, Token: string(token), Teams: nil, CountryCode: "us", } tokensByTeams := make(map[uint]*fleet.VPPTokenDB) ds.UpdateVPPTokenTeamsFunc = func(ctx context.Context, id uint, teams []uint) (*fleet.VPPTokenDB, error) { for _, teamID := range teams { tokensByTeams[teamID] = vppToken } return vppToken, nil } ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { return []*fleet.VPPTokenDB{vppToken}, nil } ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { if teamID == nil { return vppToken, nil } tok, ok := tokensByTeams[*teamID] if !ok { return nil, sql.ErrNoRows } return tok, nil } ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, teamID uint, names []string) ([]uint, error) { return []uint{}, nil } ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error { return nil } ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error { return nil } ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) { return &fleet.TeamLite{}, nil } globalCfg := fmt.Sprintf(` policies: queries: agent_options: controls: org_settings: mdm: volume_purchasing_program: - location: Earth fleets: - %q - %q server_settings: server_url: https://example.com org_info: org_name: Fleet secrets: - secret: "FLEET_GLOBAL_ENROLL_SECRET" `, existingTeamName, newTeamName) newTeamCfg := fmt.Sprintf(` name: %q team_settings: secrets: - secret: "new-secret" agent_options: controls: policies: queries: software: app_store_apps: - app_store_id: "1" `, newTeamName) tmpDir := t.TempDir() globalFile := filepath.Join(tmpDir, "default.yml") require.NoError(t, os.WriteFile(globalFile, []byte(globalCfg), 0o600)) newTeamFile := filepath.Join(tmpDir, "new-team.yml") require.NoError(t, os.WriteFile(newTeamFile, []byte(newTeamCfg), 0o600)) // Only default.yml + the new team's file — the existing team's file is omitted. buf, err := fleetctltest.RunAppNoChecks([]string{ "gitops", "-f", globalFile, "-f", newTeamFile, }) require.NoError(t, err) assert.True(t, ds.UpdateVPPTokenTeamsFuncInvoked) assert.True(t, ds.SetTeamVPPAppsFuncInvoked) assert.Contains(t, buf.String(), fmt.Sprintf(fleetctl.ReapplyingTeamForVPPAppsMsg, newTeamName)) } func TestGitOpsVPP(t *testing.T) { global := func(mdm string) string { return fmt.Sprintf(` controls: queries: policies: agent_options: software: org_settings: server_settings: server_url: "https://foo.example.com" org_info: org_name: GitOps Test secrets: - secret: "global" mdm: %s `, mdm) } team := func(name string) string { return fmt.Sprintf(` name: %s team_settings: secrets: - secret: "%s-secret" agent_options: controls: policies: queries: software: `, name, name) } workstations := team("💻 Workstations") iosTeam := team("📱🏢 Company-owned iPhones") ipadTeam := team("🔳🏢 Company-owned iPads") cases := []struct { name string cfgs []string dryRunAssertion func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) realRunAssertion func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) }{ { name: "new key all valid", cfgs: []string{ global(` volume_purchasing_program: - location: Fleet Device Management Inc. teams: - "💻 Workstations" - "📱🏢 Company-owned iPhones" - "🔳🏢 Company-owned iPads"`), workstations, iosTeam, ipadTeam, }, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) assert.Contains(t, out, "[!] gitops dry run succeeded") }, realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) assert.ElementsMatch( t, appCfg.MDM.VolumePurchasingProgram.Value, []fleet.MDMAppleVolumePurchasingProgramInfo{ { Location: "Fleet Device Management Inc.", Teams: []string{ "💻 Workstations", "📱🏢 Company-owned iPhones", "🔳🏢 Company-owned iPads", }, }, }, ) assert.Contains(t, out, "[!] gitops succeeded") }, }, { name: "new key multiple elements", cfgs: []string{ global(` volume_purchasing_program: - location: Acme Inc. teams: - "💻 Workstations" - location: Fleet Device Management Inc. teams: - "📱🏢 Company-owned iPhones" - "🔳🏢 Company-owned iPads"`), workstations, iosTeam, ipadTeam, }, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) assert.Contains(t, out, "[!] gitops dry run succeeded") }, realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) assert.ElementsMatch( t, appCfg.MDM.VolumePurchasingProgram.Value, []fleet.MDMAppleVolumePurchasingProgramInfo{ { Location: "Acme Inc.", Teams: []string{ "💻 Workstations", }, }, { Location: "Fleet Device Management Inc.", Teams: []string{ "📱🏢 Company-owned iPhones", "🔳🏢 Company-owned iPads", }, }, }, ) assert.Contains(t, out, "[!] gitops succeeded") }, }, { name: "using an undefined team errors", cfgs: []string{ global(` volume_purchasing_program: - location: Fleet Device Management Inc. teams: - "💻 Workstations" - "📱🏢 Company-owned iPhones" - "🔳🏢 Company-owned iPads"`), workstations, ipadTeam, }, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.ErrorContains(t, err, "volume_purchasing_program team 📱🏢 Company-owned iPhones not found in team configs") }, realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.ErrorContains(t, err, "volume_purchasing_program team 📱🏢 Company-owned iPhones not found in team configs") }, }, { name: "no team is supported", cfgs: []string{ global(` volume_purchasing_program: - location: Fleet Device Management Inc. teams: - "💻 Workstations" - "📱🏢 Company-owned iPhones" - "No team"`), workstations, iosTeam, }, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) assert.Contains(t, out, "[!] gitops dry run succeeded") }, realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) assert.ElementsMatch( t, appCfg.MDM.VolumePurchasingProgram.Value, []fleet.MDMAppleVolumePurchasingProgramInfo{ { Location: "Fleet Device Management Inc.", Teams: []string{ "💻 Workstations", "📱🏢 Company-owned iPhones", "No team", }, }, }, ) assert.Contains(t, out, "[!] gitops succeeded") }, }, { name: "all teams is supported", cfgs: []string{ global(` volume_purchasing_program: - location: Fleet Device Management Inc. teams: - "All teams"`), workstations, iosTeam, }, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) assert.Contains(t, out, "[!] gitops dry run succeeded") }, realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) assert.ElementsMatch( t, appCfg.MDM.VolumePurchasingProgram.Value, []fleet.MDMAppleVolumePurchasingProgramInfo{ { Location: "Fleet Device Management Inc.", Teams: []string{ "All teams", }, }, }, ) assert.Contains(t, out, "[!] gitops succeeded") }, }, { name: "all fleets is supported", cfgs: []string{ global(` volume_purchasing_program: - location: Fleet Device Management Inc. teams: - "All fleets"`), workstations, iosTeam, }, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { require.NoError(t, err) assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) assert.Contains(t, out, "[!] gitops dry run succeeded") }, realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { require.NoError(t, err) assert.ElementsMatch( t, appCfg.MDM.VolumePurchasingProgram.Value, []fleet.MDMAppleVolumePurchasingProgramInfo{ { Location: "Fleet Device Management Inc.", Teams: []string{ "All fleets", }, }, }, ) assert.Contains(t, out, "[!] gitops succeeded") }, }, { name: "not provided teams defaults to no team", cfgs: []string{ global(` volume_purchasing_program: - location: Fleet Device Management Inc. teams:`), workstations, ipadTeam, }, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) assert.Contains(t, out, "[!] gitops dry run succeeded") }, realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) assert.ElementsMatch( t, appCfg.MDM.VolumePurchasingProgram.Value, []fleet.MDMAppleVolumePurchasingProgramInfo{ { Location: "Fleet Device Management Inc.", Teams: nil, }, }, ) assert.Contains(t, out, "[!] gitops succeeded") }, }, { name: "non existent location fails", cfgs: []string{ global(` volume_purchasing_program: - location: Does not exist teams:`), workstations, ipadTeam, }, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { require.ErrorContains(t, err, "token with organization unit Does not exist doesn't exist") assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) assert.NotContains(t, out, "[!] gitops dry run succeeded") }, realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { require.ErrorContains(t, err, "token with organization unit Does not exist doesn't exist") assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) assert.NotContains(t, out, "[!] gitops dry run succeeded") }, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { ds, savedAppConfigPtr, savedTeams := testing_utils.SetupFullGitOpsPremiumServer(t) // No existing labels — this test doesn't test label behavior. ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { return nil, nil } ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { return []*fleet.VPPTokenDB{{Location: "Fleet Device Management Inc.", CountryCode: "us"}, {Location: "Acme Inc.", CountryCode: "us"}}, nil } ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}, {OrganizationName: "Foo Inc."}}, nil } ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) { return 1, nil } ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) { var res []*fleet.TeamSummary for _, tm := range savedTeams { res = append(res, &fleet.TeamSummary{Name: (*tm).Name, ID: (*tm).ID}) } return res, nil } ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { return nil } ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, options fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil } ds.ListCertificateAuthoritiesFunc = func(ctx context.Context) ([]*fleet.CertificateAuthoritySummary, error) { return nil, nil } ds.TeamLiteFunc = func(ctx context.Context, id uint) (*fleet.TeamLite, error) { return &fleet.TeamLite{}, nil } args := []string{"gitops"} for _, cfg := range tt.cfgs { if cfg != "" { tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) _, err = tmpFile.WriteString(cfg) require.NoError(t, err) args = append(args, "-f", tmpFile.Name()) } } // Dry run out, err := fleetctltest.RunAppNoChecks(append(args, "--dry-run")) tt.dryRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err) if t.Failed() { t.FailNow() } // Real run out, err = fleetctltest.RunAppNoChecks(args) tt.realRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err) // Second real run, now that all the teams are saved out, err = fleetctltest.RunAppNoChecks(args) tt.realRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err) }) } } // TestGitOpsTeamVPPAppleConfiguration covers the iOS / iPadOS managed app // configuration path through gitops apply: a configuration.path file is read // from disk, validated, and reaches SetTeamVPPApps as raw XML bytes; invalid // XML or unsupported Fleet variables are rejected; and an absent configuration // block clears any previously stored configuration. func TestGitOpsTeamVPPAppleConfiguration(t *testing.T) { testing_utils.StartAndServeVPPServer(t) xmlPath := "../../fleetctl/testdata/gitops/team_vpp_ios_config.xml" wantXML, err := os.ReadFile(xmlPath) require.NoError(t, err) cases := []struct { name string yaml string wantErr string // expectConfig is the expected value of VPPAppTeam.Configuration the // datastore receives. Empty means SetTeamVPPApps should be called with // no configuration (clear). expectConfig []byte }{ { name: "valid configuration is read, validated, and stored as raw XML", yaml: "../../fleetctl/testdata/gitops/team_vpp_ios_with_config.yml", expectConfig: wantXML, }, { name: "invalid Fleet variable is rejected", yaml: "../../fleetctl/testdata/gitops/team_vpp_ios_with_invalid_config.yml", wantErr: "unsupported variable $FLEET_VAR_NDES_SCEP_PROXY_URL", }, { name: "missing configuration clears any prior configuration", yaml: "../../fleetctl/testdata/gitops/team_vpp_ios_no_config.yml", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t) renewDate := time.Now().Add(24 * time.Hour) token, err := test.CreateVPPTokenEncoded(renewDate, "fleet", "ca") require.NoError(t, err) var capturedApps []fleet.VPPAppTeam ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, apps []fleet.VPPAppTeam, _ map[string]uint) (bool, error) { capturedApps = append(capturedApps, apps...) return false, nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) { return []fleet.VPPAppResponse{}, nil } ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { return &fleet.VPPTokenDB{ ID: 1, OrgName: "Fleet", Location: "Earth", RenewDate: renewDate, Token: string(token), Teams: nil, CountryCode: "us", }, nil } ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) { return map[string]uint{}, nil } ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, options fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil } ds.ListCertificateAuthoritiesFunc = func(ctx context.Context) ([]*fleet.CertificateAuthoritySummary, error) { return nil, nil } ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error { return nil } ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error { return nil } _, err = fleetctltest.RunAppNoChecks([]string{"gitops", "-f", c.yaml}) if c.wantErr != "" { require.ErrorContains(t, err, c.wantErr) return } require.NoError(t, err) require.True(t, ds.SetTeamVPPAppsFuncInvoked) require.Len(t, capturedApps, 1) require.Equal(t, c.expectConfig, capturedApps[0].Configuration) }) } } // TestGitOpsTeamInHouseAppleConfiguration covers the iOS / iPadOS in-house app // (.ipa) managed configuration path through gitops apply: a configuration.path // file is read from disk, validated, and reaches BatchSetInHouseAppsInstallers // as raw XML bytes; invalid XML or unsupported Fleet variables are rejected; // and an absent configuration block clears any previously stored configuration. func TestGitOpsTeamInHouseAppleConfiguration(t *testing.T) { testing_utils.StartSoftwareInstallerServer(t) testing_utils.StartAndServeVPPServer(t) xmlPath := "../../fleetctl/testdata/gitops/team_in_house_config.xml" wantXML, err := os.ReadFile(xmlPath) require.NoError(t, err) cases := []struct { name string yaml string wantErr string expectConfig []byte }{ { name: "valid configuration is read, validated, and stored as raw XML", yaml: "../../fleetctl/testdata/gitops/team_in_house_with_config.yml", expectConfig: wantXML, }, { name: "invalid Fleet variable is rejected", yaml: "../../fleetctl/testdata/gitops/team_in_house_with_invalid_config.yml", wantErr: "unsupported variable $FLEET_VAR_NDES_SCEP_CHALLENGE", }, { name: "missing configuration produces no configuration on the payload", yaml: "../../fleetctl/testdata/gitops/team_software_installer_valid_ipa.yml", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { ds, _, _ := testing_utils.SetupFullGitOpsPremiumServer(t) var capturedInstallers []*fleet.UploadSoftwareInstallerPayload ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { capturedInstallers = append(capturedInstallers, installers...) return nil } ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) { return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil } ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID *uint, url string) (*fleet.ExistingSoftwareInstaller, error) { return nil, nil } ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) { return map[string]uint{}, nil } ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, options fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil } ds.ListCertificateAuthoritiesFunc = func(ctx context.Context) ([]*fleet.CertificateAuthoritySummary, error) { return nil, nil } ds.InsertOrReplaceMDMConfigAssetFunc = func(ctx context.Context, asset fleet.MDMConfigAsset) error { return nil } ds.HardDeleteMDMConfigAssetFunc = func(ctx context.Context, assetName fleet.MDMAssetName) error { return nil } _, err := fleetctltest.RunAppNoChecks([]string{"gitops", "-f", c.yaml}) if c.wantErr != "" { require.ErrorContains(t, err, c.wantErr) return } require.NoError(t, err) require.NotEmpty(t, capturedInstallers, "expected BatchSetInHouseAppsInstallers to be called") // .ipa generates two installers (iOS + iPadOS). All of them should // carry the same Configuration value. for _, inst := range capturedInstallers { require.Equal(t, c.expectConfig, inst.Configuration) } }) } } func TestGitOpsSoftwareDownloadProgress(t *testing.T) { testing_utils.StartSoftwareInstallerServer(t) dev_mode.SetOverride("FLEET_DEV_BATCH_RETRY_INTERVAL", "1s") t.Cleanup(func() { dev_mode.ClearOverride("FLEET_DEV_BATCH_RETRY_INTERVAL") }) file := "../../fleetctl/testdata/gitops/team_software_installer_valid.yml" // The batch needs these to get as far as downloading; the harness doesn't set them. setupSoftwareMocks := func(t *testing.T) map[string]**fleet.Team { ds, _, savedTeams := testing_utils.SetupFullGitOpsPremiumServer(t) ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) { return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil } ds.GetInstallerByTeamAndURLFunc = func(ctx context.Context, teamID *uint, url string) (*fleet.ExistingSoftwareInstaller, error) { return nil, nil } ds.GetSoftwareCategoryNameToIDMapFunc = func(ctx context.Context, teamID uint, names []string) (map[string]uint, error) { return map[string]uint{}, nil } return savedTeams } t.Run("a package Fleet downloads reports its progress", func(t *testing.T) { setupSoftwareMocks(t) out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "-f", file}) require.NoError(t, err) require.Contains(t, out.String(), "[+] applying 2 software packages for fleet "+teamName+"\n") require.Contains(t, out.String(), "[+] downloading software package - ruby.deb ...\n") require.Contains(t, out.String(), "[+] downloaded software package - ruby.deb\n") require.Contains(t, out.String(), "[+] applied 2 software packages for fleet "+teamName+"\n") }) t.Run("a dry run for an existing fleet reports the same progress", func(t *testing.T) { // A dry run for a fleet that doesn't exist yet never starts a batch, so there is // nothing to download and nothing to report. savedTeams := setupSoftwareMocks(t) team := &fleet.Team{ID: 1, Name: teamName} savedTeams[teamName] = &team out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "--dry-run", "-f", file}) require.NoError(t, err) require.Contains(t, out.String(), "[+] downloading software package - ruby.deb ...\n") require.Contains(t, out.String(), "[+] downloaded software package - ruby.deb\n") require.Contains(t, out.String(), "[+] would've applied 2 software packages for fleet "+teamName+"\n") }) t.Run("a script package stays out of the progress, since nothing is downloaded for it", func(t *testing.T) { setupSoftwareMocks(t) out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "-f", "../../fleetctl/testdata/gitops/team_software_script_package.yml"}) require.NoError(t, err) // The counts prove the script package was in the batch, not just missing from it. require.Contains(t, out.String(), "[+] applying 2 software packages for fleet "+teamName+"\n") require.Contains(t, out.String(), "[+] downloaded software package - ruby.deb\n") require.NotContains(t, out.String(), "install_ruby.sh") require.Contains(t, out.String(), "[+] applied 2 software packages for fleet "+teamName+"\n") }) t.Run("a package Fleet can't download reports the failure", func(t *testing.T) { setupSoftwareMocks(t) out, err := fleetctltest.RunAppNoChecks([]string{"gitops", "-f", "../../fleetctl/testdata/gitops/team_software_installer_not_found.yml"}) require.Error(t, err) require.Contains(t, out.String(), "Error: could not download software package notfound.deb\n") require.NotContains(t, out.String(), "[+] downloaded software package - notfound.deb") }) }