Files
fleet/cmd/fleetctl/integrationtest/gitops/software_test.go
T
Jonathan Katz a3338d032e Self service categories - GitOps support (#46671)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #46392 
A few things in this PR:
- updated the conversion from old default category to the new ones with
the emoji included that was introduced in the feature branch. It takes
into account what exists in the database now so if an admin wants to add
for example "Productivity" without the emoji as a category it won't get
overwritten.
- updated a few places to ignore missing categories rather than error
(what we do for adding a single FMA currently)
- updated permissions for "gitops" users 
- added everything needed for gitops, generate-gitops support using the
existing endpoints from the last PR.

Didn't add logs like "[+] applied X self service categories" since it
wasn't mentioned in the docs, but wouldn't be too hard to add.


# Checklist for submitter


## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

## New Fleet configuration settings

- [x] Verified that the setting is exported via `fleetctl
generate-gitops`
- [x] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [x] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [ ] Verified that any relevant UI is disabled when GitOps mode is
enabled
    - Currently missing, at least on this branch
2026-06-03 15:23:44 -04:00

1409 lines
54 KiB
Go

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/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,
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)
}
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 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,
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))
}
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) {
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)
}
})
}
}