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/testing_utils" "github.com/fleetdm/fleet/v4/pkg/file" "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 should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .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, }, 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.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) { return []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 = fleetctl.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.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) { return []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 := fleetctl.RunAppNoChecks([]string{"gitops", "-f", "../../fleetctl/testdata/gitops/team_software_installer_valid_env_query.yml"}) require.NoError(t, err) } 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, }, 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.GetSoftwareCategoryIDsFunc = func(ctx context.Context, 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 } 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 = fleetctl.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 should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .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, }, 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.GetSoftwareCategoryIDsFunc = func(ctx context.Context, 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 } 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 = fleetctl.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, }, 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.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) { return []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 = fleetctl.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, } 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.GetSoftwareCategoryIDsFunc = func(ctx context.Context, 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 } buf, err := fleetctl.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, } 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.GetSoftwareCategoryIDsFunc = func(ctx context.Context, 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 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 := fleetctl.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)) } 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: "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) { assert.ErrorContains(t, err, "token with location 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) { assert.ErrorContains(t, err, "token with location 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."}, {Location: "Acme Inc."}}, 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 := fleetctl.RunAppNoChecks(append(args, "--dry-run")) tt.dryRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err) if t.Failed() { t.FailNow() } // Real run out, err = fleetctl.RunAppNoChecks(args) tt.realRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err) // Second real run, now that all the teams are saved out, err = fleetctl.RunAppNoChecks(args) tt.realRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err) }) } }