package spec import ( "fmt" "os" "path/filepath" "slices" "strings" "testing" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var topLevelOptions = map[string]string{ "controls": "controls:", "reports": "reports:", "policies": "policies:", "agent_options": "agent_options:", "org_settings": ` org_settings: server_settings: server_url: https://fleet.example.com org_info: contact_url: https://example.com/contact org_logo_url: "" org_logo_url_light_background: "" org_name: Test Org secrets: `, } var teamLevelOptions = map[string]string{ "controls": "controls:", "reports": "reports:", "policies": "policies:", "agent_options": "agent_options:", "name": "name: TeamName", "settings": ` settings: secrets: `, } func createTempFile(t *testing.T, pattern, contents string) (filePath string, baseDir string) { tmpFile, err := os.CreateTemp(t.TempDir(), pattern) require.NoError(t, err) _, err = tmpFile.WriteString(contents) require.NoError(t, err) require.NoError(t, tmpFile.Close()) return tmpFile.Name(), filepath.Dir(tmpFile.Name()) } func createNamedFileOnTempDir(t *testing.T, name string, contents string) (filePath string, baseDir string) { tmpFilePath := filepath.Join(t.TempDir(), name) tmpFile, err := os.Create(tmpFilePath) require.NoError(t, err) _, err = tmpFile.WriteString(contents) require.NoError(t, err) require.NoError(t, tmpFile.Close()) return tmpFile.Name(), filepath.Dir(tmpFile.Name()) } func gitOpsFromString(t *testing.T, s string) (*GitOps, error) { path, basePath := createTempFile(t, "", s) return GitOpsFromFile(path, basePath, nil, nopLogf) } func nopLogf(_ string, _ ...interface{}) { } func premiumAppConfig() *fleet.EnrichedAppConfig { ac := &fleet.EnrichedAppConfig{} ac.License = &fleet.LicenseInfo{Tier: fleet.TierPremium} return ac } func TestValidGitOpsYaml(t *testing.T) { t.Parallel() tests := map[string]struct { environment map[string]string filePath string isTeam bool }{ "global_config_no_paths": { environment: map[string]string{ "FLEET_SECRET_FLEET_SECRET_": "fleet_secret", "FLEET_SECRET_NAME": "secret_name", "FLEET_SECRET_LENGTH": "10", "FLEET_SECRET_BANANA": "bread", }, filePath: "testdata/global_config_no_paths.yml", }, "global_config_with_paths": { environment: map[string]string{ "LINUX_OS": "linux", "DISTRIBUTED_DENYLIST_DURATION": "0", "ORG_NAME": "Fleet Device Management", "FLEET_SECRET_FLEET_SECRET_": "fleet_secret", "FLEET_SECRET_NAME": "secret_name", "FLEET_SECRET_LENGTH": "10", "FLEET_SECRET_BANANA": "bread", }, filePath: "testdata/global_config.yml", }, "team_config_no_paths": { environment: map[string]string{ "FLEET_SECRET_FLEET_SECRET_": "fleet_secret", "FLEET_SECRET_NAME": "secret_name", "FLEET_SECRET_LENGTH": "10", "FLEET_SECRET_BANANA": "bread", "FLEET_SECRET_CLEMENTINE": "not-an-orange", "FLEET_SECRET_DURIAN": "fruity", // not used "FLEET_SECRET_EGGPLANT": "parmesan", }, filePath: "testdata/team_config_no_paths.yml", isTeam: true, }, "team_config_with_paths": { environment: map[string]string{ "POLICY": "policy", "LINUX_OS": "linux", "DISTRIBUTED_DENYLIST_DURATION": "0", "ENABLE_FAILING_POLICIES_WEBHOOK": "true", "FLEET_SECRET_FLEET_SECRET_": "fleet_secret", "FLEET_SECRET_NAME": "secret_name", "FLEET_SECRET_LENGTH": "10", "FLEET_SECRET_BANANA": "bread", "FLEET_SECRET_CLEMENTINE": "not-an-orange", "FLEET_SECRET_DURIAN": "fruity", // not used "FLEET_SECRET_EGGPLANT": "parmesan", }, filePath: "testdata/team_config.yml", isTeam: true, }, "team_config_with_paths_and_only_sha256": { environment: map[string]string{ "POLICY": "policy", "LINUX_OS": "linux", "DISTRIBUTED_DENYLIST_DURATION": "0", "ENABLE_FAILING_POLICIES_WEBHOOK": "true", "FLEET_SECRET_FLEET_SECRET_": "fleet_secret", "FLEET_SECRET_NAME": "secret_name", "FLEET_SECRET_LENGTH": "10", "FLEET_SECRET_BANANA": "bread", "FLEET_SECRET_CLEMENTINE": "not-an-orange", "FLEET_SECRET_DURIAN": "fruity", // not used "FLEET_SECRET_EGGPLANT": "parmesan", }, filePath: "testdata/team_config_only_sha256.yml", isTeam: true, }, } for name, test := range tests { test := test name := name t.Run( name, func(t *testing.T) { if len(test.environment) > 0 { for k, v := range test.environment { os.Setenv(k, v) } t.Cleanup(func() { for k := range test.environment { os.Unsetenv(k) } }) } var appConfig *fleet.EnrichedAppConfig if test.isTeam { appConfig = &fleet.EnrichedAppConfig{} appConfig.License = &fleet.LicenseInfo{ Tier: fleet.TierPremium, } } gitops, err := GitOpsFromFile(test.filePath, "./testdata", appConfig, nopLogf) require.NoError(t, err) if test.isTeam { // Check team settings assert.Equal(t, "Team1", *gitops.TeamName) assert.Contains(t, gitops.TeamSettings, "webhook_settings") webhookSettings, ok := gitops.TeamSettings["webhook_settings"].(map[string]interface{}) assert.True(t, ok, "webhook_settings not found") assert.Contains(t, webhookSettings, "failing_policies_webhook") failingPoliciesWebhook, ok := webhookSettings["failing_policies_webhook"].(map[string]interface{}) assert.True(t, ok, "webhook_settings not found") assert.Contains(t, failingPoliciesWebhook, "enable_failing_policies_webhook") enableFailingPoliciesWebhook, ok := failingPoliciesWebhook["enable_failing_policies_webhook"].(bool) assert.True(t, ok) assert.True(t, enableFailingPoliciesWebhook) assert.Contains(t, gitops.TeamSettings, "host_expiry_settings") assert.Contains(t, gitops.TeamSettings, "features") assert.Contains(t, gitops.TeamSettings, "secrets") secrets, ok := gitops.TeamSettings["secrets"] assert.True(t, ok, "secrets not found") require.Len(t, secrets.([]*fleet.EnrollSecret), 2) assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret) assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret) require.Len(t, gitops.Software.Packages, 2) require.Len(t, gitops.FleetSecrets, 6) for _, pkg := range gitops.Software.Packages { if strings.Contains(pkg.URL, "MicrosoftTeams") { assert.Equal(t, "testdata/lib/uninstall.sh", pkg.UninstallScript.Path) assert.Contains(t, pkg.LabelsIncludeAny, "a") assert.Contains(t, pkg.Categories, "Communication") assert.Empty(t, pkg.LabelsExcludeAny) assert.Empty(t, pkg.LabelsIncludeAll) } else { assert.Empty(t, pkg.UninstallScript.Path) assert.Contains(t, pkg.LabelsExcludeAny, "a") assert.Empty(t, pkg.LabelsIncludeAny) assert.Empty(t, pkg.LabelsIncludeAll) } } require.Len(t, gitops.Software.FleetMaintainedApps, 2) for _, fma := range gitops.Software.FleetMaintainedApps { switch fma.Slug { case "slack/darwin": require.ElementsMatch(t, fma.Categories, []string{"Productivity", "Communication"}) require.Equal(t, "4.47.65", fma.Version) require.Empty(t, fma.PreInstallQuery) require.Empty(t, fma.PostInstallScript) require.Empty(t, fma.InstallScript) require.Empty(t, fma.UninstallScript) case "box-drive/windows": require.ElementsMatch(t, fma.Categories, []string{"Productivity", "Developer tools"}) require.Empty(t, fma.Version) require.NotEmpty(t, fma.PreInstallQuery) require.NotEmpty(t, fma.PostInstallScript) require.NotEmpty(t, fma.InstallScript) require.NotEmpty(t, fma.UninstallScript) default: assert.FailNow(t, "unexpected slug found in gitops file", "slug: %s", fma.Slug) } } } else { // Check org settings serverSettings, ok := gitops.OrgSettings["server_settings"] assert.True(t, ok, "server_settings not found") assert.Equal(t, "https://fleet.example.com", serverSettings.(map[string]interface{})["server_url"]) assert.EqualValues(t, 2000, serverSettings.(map[string]interface{})["query_report_cap"]) assert.Contains(t, gitops.OrgSettings, "org_info") orgInfo, ok := gitops.OrgSettings["org_info"].(map[string]interface{}) assert.True(t, ok) assert.Equal(t, "Fleet Device Management", orgInfo["org_name"]) assert.Contains(t, gitops.OrgSettings, "smtp_settings") assert.Contains(t, gitops.OrgSettings, "sso_settings") assert.Contains(t, gitops.OrgSettings, "integrations") assert.Contains(t, gitops.OrgSettings, "mdm") assert.Contains(t, gitops.OrgSettings, "webhook_settings") assert.Contains(t, gitops.OrgSettings, "fleet_desktop") assert.Contains(t, gitops.OrgSettings, "host_expiry_settings") assert.Contains(t, gitops.OrgSettings, "activity_expiry_settings") assert.Contains(t, gitops.OrgSettings, "features") assert.Contains(t, gitops.OrgSettings, "vulnerability_settings") assert.Contains(t, gitops.OrgSettings, "secrets") secrets, ok := gitops.OrgSettings["secrets"] assert.True(t, ok, "secrets not found") require.Len(t, secrets.([]*fleet.EnrollSecret), 2) assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret) assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret) activityExpirySettings, ok := gitops.OrgSettings["activity_expiry_settings"].(map[string]interface{}) require.True(t, ok) activityExpiryEnabled, ok := activityExpirySettings["activity_expiry_enabled"].(bool) require.True(t, ok) require.True(t, activityExpiryEnabled) activityExpiryWindow, ok := activityExpirySettings["activity_expiry_window"].(float64) require.True(t, ok) require.Equal(t, 30, int(activityExpiryWindow)) require.Len(t, gitops.FleetSecrets, 4) // Check labels require.Len(t, gitops.Labels, 2) assert.Equal(t, "Global label numero uno", gitops.Labels[0].Name) assert.Equal(t, "Global label numero dos", gitops.Labels[1].Name) assert.Equal(t, "SELECT 1 FROM osquery_info", gitops.Labels[0].Query) require.Len(t, gitops.Labels[1].Hosts, 2) assert.Equal(t, "host1", gitops.Labels[1].Hosts[0]) assert.Equal(t, "2", gitops.Labels[1].Hosts[1]) } // Check controls _, ok := gitops.Controls.MacOSSettings.(fleet.MacOSSettings) assert.True(t, ok, "macos_settings not found") _, ok = gitops.Controls.WindowsSettings.(fleet.WindowsSettings) assert.True(t, ok, "windows_settings not found") _, ok = gitops.Controls.EnableDiskEncryption.(bool) assert.True(t, ok, "enable_disk_encryption not found") _, ok = gitops.Controls.EnableRecoveryLockPassword.(bool) assert.True(t, ok, "enable_recovery_lock_password not found") _, ok = gitops.Controls.MacOSMigration.(map[string]interface{}) assert.True(t, ok, "macos_migration not found") assert.NotNil(t, gitops.Controls.MacOSSetup, "macos_setup not found") _, ok = gitops.Controls.MacOSUpdates.(map[string]interface{}) assert.True(t, ok, "macos_updates not found") _, ok = gitops.Controls.IOSUpdates.(map[string]interface{}) assert.True(t, ok, "ios_updates not found") _, ok = gitops.Controls.IPadOSUpdates.(map[string]interface{}) assert.True(t, ok, "ipados_updates not found") _, ok = gitops.Controls.WindowsEnabledAndConfigured.(bool) assert.True(t, ok, "windows_enabled_and_configured not found") _, ok = gitops.Controls.WindowsMigrationEnabled.(bool) assert.True(t, ok, "windows_migration_enabled not found") _, ok = gitops.Controls.EnableTurnOnWindowsMDMManually.(bool) assert.True(t, ok, "enable_turn_on_windows_mdm_manually not found") _, ok = gitops.Controls.WindowsEntraTenantIDs.([]any) assert.True(t, ok, "windows_entra_tenant_ids not found") _, ok = gitops.Controls.WindowsUpdates.(map[string]interface{}) assert.True(t, ok, "windows_updates not found") assert.Equal(t, "fleet_secret", gitops.FleetSecrets["FLEET_SECRET_FLEET_SECRET_"]) assert.Equal(t, "secret_name", gitops.FleetSecrets["FLEET_SECRET_NAME"]) assert.Equal(t, "10", gitops.FleetSecrets["FLEET_SECRET_LENGTH"]) assert.Equal(t, "bread", gitops.FleetSecrets["FLEET_SECRET_BANANA"]) // Check agent options assert.NotNil(t, gitops.AgentOptions) assert.Contains(t, string(*gitops.AgentOptions), "\"distributed_denylist_duration\":0") // Check reports require.Len(t, gitops.Queries, 3) assert.Equal(t, "Scheduled query stats", gitops.Queries[0].Name) assert.Equal(t, "orbit_info", gitops.Queries[1].Name) assert.Equal(t, "darwin,linux,windows", gitops.Queries[1].Platform) assert.Equal(t, "osquery_info", gitops.Queries[2].Name) // Check software if test.isTeam { require.Len(t, gitops.Software.Packages, 2) if name == "team_config_with_paths_and_only_sha256" { require.Empty(t, gitops.Software.Packages[0].URL) require.True(t, gitops.Software.Packages[0].InstallDuringSetup.Value) require.True(t, gitops.Software.Packages[1].InstallDuringSetup.Value) } else { require.Equal(t, "https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg", gitops.Software.Packages[0].URL) } require.Equal(t, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", gitops.Software.Packages[0].SHA256) require.False(t, gitops.Software.Packages[0].SelfService) require.Equal(t, "https://ftp.mozilla.org/pub/firefox/releases/129.0.2/mac/en-US/Firefox%20129.0.2.pkg", gitops.Software.Packages[1].URL) require.True(t, gitops.Software.Packages[1].SelfService) require.Len(t, gitops.Software.AppStoreApps, 1) require.Equal(t, gitops.Software.AppStoreApps[0].AppStoreID, "123456") require.False(t, gitops.Software.AppStoreApps[0].SelfService) } // Check policies expectedPoliciesCount := 5 if test.isTeam { expectedPoliciesCount = 9 } require.Len(t, gitops.Policies, expectedPoliciesCount) assert.Equal(t, "😊 Failing policy", gitops.Policies[0].Name) assert.Equal(t, "Passing policy", gitops.Policies[1].Name) assert.Equal(t, "No root logins (macOS, Linux)", gitops.Policies[2].Name) assert.Equal(t, "🔥 Failing policy", gitops.Policies[3].Name) assert.Equal(t, "linux", gitops.Policies[3].Platform) assert.Equal(t, "😊😊 Failing policy", gitops.Policies[4].Name) if test.isTeam { assert.Equal(t, "Microsoft Teams on macOS installed and up to date", gitops.Policies[5].Name) assert.NotNil(t, gitops.Policies[5].InstallSoftware) if name == "team_config_with_paths_and_only_sha256" { assert.Equal(t, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", gitops.Policies[5].InstallSoftware.HashSHA256) } else { assert.Equal(t, "./microsoft-teams.pkg.software.yml", gitops.Policies[5].InstallSoftware.PackagePath) assert.Equal(t, "https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg", gitops.Policies[5].InstallSoftwareURL) } assert.Equal(t, "Slack on macOS is installed", gitops.Policies[6].Name) assert.NotNil(t, gitops.Policies[6].InstallSoftware) assert.Equal(t, "123456", gitops.Policies[6].InstallSoftware.AppStoreID) assert.Equal(t, "Script run policy", gitops.Policies[7].Name) assert.NotNil(t, gitops.Policies[7].RunScript) assert.Equal(t, "./lib/collect-fleetd-logs.sh", gitops.Policies[7].RunScript.Path) assert.Equal(t, "🔥 Failing policy with script", gitops.Policies[8].Name) assert.NotNil(t, gitops.Policies[8].RunScript) // . or .. depending on whether with paths or without assert.Contains(t, gitops.Policies[8].RunScript.Path, "./lib/collect-fleetd-logs.sh") } }, ) } } func TestDuplicatePolicyNames(t *testing.T) { t.Parallel() config := getGlobalConfig([]string{"policies"}) config += ` policies: - name: My policy platform: linux query: SELECT 1 FROM osquery_info WHERE start_time < 0; - name: My policy platform: windows query: SELECT 1; ` _, err := gitOpsFromString(t, config) assert.ErrorContains(t, err, "duplicate policy names") } func TestManualLabelEmptyHostList(t *testing.T) { t.Parallel() config := getGlobalConfig([]string{}) config += ` labels: - name: TestLabel description: Label for testing hosts: label_membership_type: manual` gitops, err := gitOpsFromString(t, config) require.NoError(t, err) assert.NotNil(t, gitops.Labels[0].Hosts) } func TestDuplicateQueryNames(t *testing.T) { t.Parallel() config := getGlobalConfig([]string{"reports"}) config += ` reports: - name: orbit_info query: SELECT * from orbit_info; interval: 0 platform: darwin,linux,windows min_osquery_version: all observer_can_run: false automations_enabled: true logging: snapshot - name: orbit_info query: SELECT 1; interval: 300 platform: windows min_osquery_version: all observer_can_run: false automations_enabled: true logging: snapshot ` _, err := gitOpsFromString(t, config) assert.ErrorContains(t, err, "duplicate report names") } func TestUnicodeQueryNames(t *testing.T) { t.Parallel() config := getGlobalConfig([]string{"reports"}) config += ` reports: - name: 😊 orbit_info query: SELECT * from orbit_info; interval: 0 platform: darwin,linux,windows min_osquery_version: all observer_can_run: false automations_enabled: true logging: snapshot ` _, err := gitOpsFromString(t, config) assert.ErrorContains(t, err, "`name` must be in ASCII") } func TestUnicodeTeamName(t *testing.T) { t.Parallel() config := getTeamConfig([]string{"name"}) config += `name: 😊 TeamName` _, err := gitOpsFromString(t, config) assert.NoError(t, err) } func TestVarExpansion(t *testing.T) { os.Setenv("MACOS_OS", "darwin") os.Setenv("LINUX_OS", "linux") os.Setenv("EMPTY_VAR", "") t.Cleanup(func() { os.Unsetenv("MACOS_OS") os.Unsetenv("LINUX_OS") os.Unsetenv("EMPTY_VAR") }) config := getGlobalConfig([]string{"reports"}) config += ` reports: - name: orbit_info \$NOT_EXPANDED \\\$ALSO_NOT_EXPANDED query: "SELECT * from orbit_info; -- double quotes are escaped by YAML after Fleet's escaping of backslashes \\\\\$NOT_EXPANDED" interval: 0 platform: $MACOS_OS,${LINUX_OS},windows$EMPTY_VAR min_osquery_version: all observer_can_run: false automations_enabled: true logging: snapshot description: 'single quotes are not escaped by YAML \\\$NOT_EXPANDED' ` gitOps, err := gitOpsFromString(t, config) require.NoError(t, err) require.Len(t, gitOps.Queries, 1) require.Equal(t, "darwin,linux,windows", gitOps.Queries[0].Platform) require.Equal(t, `orbit_info $NOT_EXPANDED \$ALSO_NOT_EXPANDED`, gitOps.Queries[0].Name) require.Equal(t, `single quotes are not escaped by YAML \$NOT_EXPANDED`, gitOps.Queries[0].Description) require.Equal(t, `SELECT * from orbit_info; -- double quotes are escaped by YAML after Fleet's escaping of backslashes \$NOT_EXPANDED`, gitOps.Queries[0].Query) config = getGlobalConfig([]string{"reports"}) config += ` reports: - name: orbit_info $NOT_DEFINED query: SELECT * from orbit_info; interval: 0 platform: darwin,linux,windows min_osquery_version: all observer_can_run: false automations_enabled: true logging: snapshot ` _, err = gitOpsFromString(t, config) require.Error(t, err) require.Contains(t, err.Error(), "environment variable \"NOT_DEFINED\" not set") } func TestMixingGlobalAndTeamConfig(t *testing.T) { t.Parallel() // Mixing org_settings and team name config := getGlobalConfig(nil) config += "name: TeamName\n" _, err := gitOpsFromString(t, config) assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'settings'") // Mixing org_settings and settings (formerly settings) config = getGlobalConfig(nil) config += "settings:\n secrets: []\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'settings'") // Mixing org_settings and team name and settings config = getGlobalConfig(nil) config += "name: TeamName\n" config += "settings:\n secrets: []\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'settings'") } func TestInvalidGitOpsYaml(t *testing.T) { t.Parallel() // Bad YAML _, err := gitOpsFromString(t, "bad:\nbad") assert.ErrorContains(t, err, "failed to unmarshal") for _, name := range []string{"global", "team"} { t.Run( name, func(t *testing.T) { isTeam := name == "team" getConfig := getGlobalConfig if isTeam { getConfig = getTeamConfig } if isTeam { // Invalid top level key config := getConfig(nil) config += "unknown_key:\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "unknown top-level field") // Invalid team name config = getConfig([]string{"name"}) config += "name: [2]\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "expected type string but got array") // Missing team name config = getConfig([]string{"name"}) config += "name:\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "'name' is required") // Invalid settings config = getConfig([]string{"settings"}) config += "settings:\n path: [2]\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "expected type string but got array") // Invalid settings in a separate file tmpFile, err := os.CreateTemp(t.TempDir(), "*settings.yml") require.NoError(t, err) _, err = tmpFile.WriteString("[2]") require.NoError(t, err) config = getConfig([]string{"settings"}) config += fmt.Sprintf("%s:\n path: %s\n", "settings", tmpFile.Name()) _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "expected type spec.BaseItem but got array") // Invalid secrets 1 config = getConfig([]string{"settings"}) config += "settings:\n secrets: bad\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "must be a list of secret items") // Invalid secrets 2 config = getConfig([]string{"settings"}) config += "settings:\n secrets: [2]\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "must have a 'secret' key") // Missing settings is now allowed (defaults to null, clearing team settings). config = getConfig([]string{"settings"}) _, err = gitOpsFromString(t, config) assert.NoError(t, err) // settings is now allowed on "no-team.yml" for webhook settings config = getConfig([]string{"name", "settings"}) // Exclude settings with secrets config += "name: No team\n" noTeamPath1, noTeamBasePath1 := createNamedFileOnTempDir(t, "no-team.yml", config) gitops, err := GitOpsFromFile(noTeamPath1, noTeamBasePath1, nil, nopLogf) assert.NoError(t, err) assert.NotNil(t, gitops) // No team with valid webhook_settings should work config = getConfig([]string{"name", "settings"}) config += "name: No team\nsettings:\n webhook_settings:\n failing_policies_webhook:\n enable_failing_policies_webhook: true\n" noTeamPath2, noTeamBasePath2 := createNamedFileOnTempDir(t, "no-team.yml", config) gitops, err = GitOpsFromFile(noTeamPath2, noTeamBasePath2, nil, nopLogf) assert.NoError(t, err) assert.NotNil(t, gitops) // No team with invalid settings option should fail config = getConfig([]string{"name", "settings"}) config += "name: No team\nsettings:\n features:\n enable_host_users: false\n" noTeamPath3, noTeamBasePath3 := createNamedFileOnTempDir(t, "no-team.yml", config) _, err = GitOpsFromFile(noTeamPath3, noTeamBasePath3, nil, nopLogf) assert.ErrorContains(t, err, "unsupported settings option 'features' in no-team.yml - only 'webhook_settings' is allowed") // No team with multiple settings options (one valid, one invalid) should fail config = getConfig([]string{"name", "settings"}) config += "name: No team\nsettings:\n webhook_settings:\n failing_policies_webhook:\n enable_failing_policies_webhook: true\n secrets:\n - secret: test\n" noTeamPath4, noTeamBasePath4 := createNamedFileOnTempDir(t, "no-team.yml", config) _, err = GitOpsFromFile(noTeamPath4, noTeamBasePath4, nil, nopLogf) assert.ErrorContains(t, err, "unsupported settings option 'secrets' in no-team.yml - only 'webhook_settings' is allowed") // No team with host_status_webhook in webhook_settings should fail config = getConfig([]string{"name", "settings"}) config += "name: No team\nsettings:\n webhook_settings:\n host_status_webhook:\n enable_host_status_webhook: true\n failing_policies_webhook:\n enable_failing_policies_webhook: true\n" noTeamPath5a, noTeamBasePath5a := createNamedFileOnTempDir(t, "no-team.yml", config) _, err = GitOpsFromFile(noTeamPath5a, noTeamBasePath5a, nil, nopLogf) assert.ErrorContains(t, err, "unsupported webhook_settings option 'host_status_webhook' in no-team.yml - only 'failing_policies_webhook' is allowed") // No team with vulnerabilities_webhook in webhook_settings should fail config = getConfig([]string{"name", "settings"}) config += "name: No team\nsettings:\n webhook_settings:\n vulnerabilities_webhook:\n enable_vulnerabilities_webhook: true\n" noTeamPath5b, noTeamBasePath5b := createNamedFileOnTempDir(t, "no-team.yml", config) _, err = GitOpsFromFile(noTeamPath5b, noTeamBasePath5b, nil, nopLogf) assert.ErrorContains(t, err, "unsupported webhook_settings option 'vulnerabilities_webhook' in no-team.yml - only 'failing_policies_webhook' is allowed") // 'No team' file with invalid name. config = getConfig([]string{"name", "settings"}) config += "name: No team\n" noTeamPath6, noTeamBasePath6 := createNamedFileOnTempDir(t, "foobar.yml", config) _, err = GitOpsFromFile(noTeamPath6, noTeamBasePath6, nil, nopLogf) assert.ErrorContains(t, err, fmt.Sprintf("file `%s` for No Team must be named `no-team.yml`", noTeamPath6)) // no-team.yml with a non-"No Team" name should fail. config = getConfig([]string{"name", "settings"}) config += "name: SomeOtherTeam\nsettings:\n secrets:\n" noTeamPath7, noTeamBasePath7 := createNamedFileOnTempDir(t, "no-team.yml", config) _, err = GitOpsFromFile(noTeamPath7, noTeamBasePath7, nil, nopLogf) assert.ErrorContains(t, err, fmt.Sprintf("file %q must have team name 'No Team'", noTeamPath7)) // unassigned.yml with a non-"Unassigned" name should fail. config = getConfig([]string{"name", "settings"}) config += "name: SomeOtherTeam\nsettings:\n secrets:\n" unassignedPathBadName, unassignedBasePathBadName := createNamedFileOnTempDir(t, "unassigned.yml", config) _, err = GitOpsFromFile(unassignedPathBadName, unassignedBasePathBadName, nil, nopLogf) assert.ErrorContains(t, err, fmt.Sprintf("file %q must have team name 'Unassigned'", unassignedPathBadName)) // no-team.yml with "Unassigned" name should fail (wrong name for this file). config = getConfig([]string{"name", "settings"}) config += "name: Unassigned\n" noTeamPath8, noTeamBasePath8 := createNamedFileOnTempDir(t, "no-team.yml", config) _, err = GitOpsFromFile(noTeamPath8, noTeamBasePath8, nil, nopLogf) assert.ErrorContains(t, err, fmt.Sprintf("file %q must have team name 'No Team'", noTeamPath8)) // unassigned.yml with "No team" name should fail (wrong name for this file). config = getConfig([]string{"name", "settings"}) config += "name: No team\n" unassignedPathNoTeam, unassignedBasePathNoTeam := createNamedFileOnTempDir(t, "unassigned.yml", config) _, err = GitOpsFromFile(unassignedPathNoTeam, unassignedBasePathNoTeam, nil, nopLogf) assert.ErrorContains(t, err, fmt.Sprintf("file %q must have team name 'Unassigned'", unassignedPathNoTeam)) // 'Unassigned' team in unassigned.yml should work and coerce to "No team" internally. config = getConfig([]string{"name", "settings"}) config += "name: Unassigned\n" unassignedPath1, unassignedBasePath1 := createNamedFileOnTempDir(t, "unassigned.yml", config) gitops, err = GitOpsFromFile(unassignedPath1, unassignedBasePath1, nil, nopLogf) assert.NoError(t, err) assert.NotNil(t, gitops) assert.True(t, gitops.IsNoTeam(), "unassigned.yml should be treated as no-team after coercion") assert.Equal(t, "No team", *gitops.TeamName) // 'Unassigned' team with wrong filename should fail. config = getConfig([]string{"name", "settings"}) config += "name: Unassigned\n" unassignedPath2, unassignedBasePath2 := createNamedFileOnTempDir(t, "foobar.yml", config) _, err = GitOpsFromFile(unassignedPath2, unassignedBasePath2, nil, nopLogf) assert.ErrorContains(t, err, fmt.Sprintf("file `%s` for unassigned hosts must be named `unassigned.yml`", unassignedPath2)) // 'Unassigned' (case-insensitive) in unassigned.yml should work. config = getConfig([]string{"name", "settings"}) config += "name: unassigned\n" unassignedPath3, unassignedBasePath3 := createNamedFileOnTempDir(t, "unassigned.yml", config) gitops, err = GitOpsFromFile(unassignedPath3, unassignedBasePath3, nil, nopLogf) assert.NoError(t, err) assert.NotNil(t, gitops) assert.True(t, gitops.IsNoTeam()) // 'Unassigned' with webhook settings in unassigned.yml should work. config = getConfig([]string{"name", "settings"}) config += "name: Unassigned\nsettings:\n webhook_settings:\n failing_policies_webhook:\n enable_failing_policies_webhook: true\n" unassignedPath4, unassignedBasePath4 := createNamedFileOnTempDir(t, "unassigned.yml", config) gitops, err = GitOpsFromFile(unassignedPath4, unassignedBasePath4, nil, nopLogf) assert.NoError(t, err) assert.NotNil(t, gitops) // 'Unassigned' with invalid settings option should fail with unassigned.yml in message. config = getConfig([]string{"name", "settings"}) config += "name: Unassigned\nsettings:\n features:\n enable_host_users: false\n" unassignedPath5, unassignedBasePath5 := createNamedFileOnTempDir(t, "unassigned.yml", config) _, err = GitOpsFromFile(unassignedPath5, unassignedBasePath5, nil, nopLogf) assert.ErrorContains(t, err, "unsupported settings option 'features' in unassigned.yml") // Missing secrets -- should be a no-op (existing secrets preserved) config = getConfig([]string{"settings"}) config += "settings:\n" result, err := gitOpsFromString(t, config) assert.NoError(t, err) _, hasSecrets := result.TeamSettings["secrets"] assert.False(t, hasSecrets, "secrets should not be set when omitted from config") } else { // 'software' is not allowed in global config config := getConfig(nil) config += "software:\n packages:\n - url: https://example.com\n" path1, basePath1 := createTempFile(t, "", config) appConfig := fleet.EnrichedAppConfig{} appConfig.License = &fleet.LicenseInfo{ Tier: fleet.TierPremium, } _, err = GitOpsFromFile(path1, basePath1, &appConfig, nopLogf) assert.ErrorContains(t, err, "'software' cannot be set on global file") // Invalid org_settings config = getConfig([]string{"org_settings"}) config += "org_settings:\n path: [2]\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "expected type string but got array") // Invalid org_settings in a separate file tmpFile, err := os.CreateTemp(t.TempDir(), "*org_settings.yml") require.NoError(t, err) _, err = tmpFile.WriteString("[2]") require.NoError(t, err) config = getConfig([]string{"org_settings"}) config += fmt.Sprintf("%s:\n path: %s\n", "org_settings", tmpFile.Name()) _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "expected type spec.BaseItem but got array") // Invalid secrets 1 config = getConfig([]string{"org_settings"}) config += "org_settings:\n secrets: bad\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "must be a list of secret items") // Invalid secrets 2 config = getConfig([]string{"org_settings"}) config += "org_settings:\n secrets: [2]\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "must have a 'secret' key") // Invalid secrets 3 (using wrong type in one key) config = getConfig([]string{"org_settings"}) config += "org_settings:\n secrets: \n - secret: some secret\n - secret: 123\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "each item in 'secrets' must have a 'secret' key") // Missing secrets -- should be a no-op (existing secrets preserved) config = getConfig([]string{"org_settings"}) config += "org_settings:\n" result, err := gitOpsFromString(t, config) assert.NoError(t, err) _, hasSecrets := result.OrgSettings["secrets"] assert.False(t, hasSecrets, "secrets should not be set when omitted from config") // Empty secrets (valid, will remove all secrets) config = getConfig([]string{"org_settings"}) config += "org_settings:\n secrets: \n" _, err = gitOpsFromString(t, config) assert.NoError(t, err) // Bad label spec (float instead of string in hosts) config = getConfig([]string{"labels"}) config += "labels:\n - name: TestLabel\n description: Label for testing\n hosts:\n - 2.5\n label_membership_type: manual\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "hosts must be strings or integers, got float 2.5") } // Invalid agent_options config := getConfig([]string{"agent_options"}) config += "agent_options:\n path: [2]\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "expected type string but got array") // Invalid agent_options in a separate file tmpFile, err := os.CreateTemp(t.TempDir(), "*agent_options.yml") require.NoError(t, err) _, err = tmpFile.WriteString("[2]") require.NoError(t, err) config = getConfig([]string{"agent_options"}) config += fmt.Sprintf("%s:\n path: %s\n", "agent_options", tmpFile.Name()) _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "expected type spec.BaseItem but got array") // Invalid controls config = getConfig([]string{"controls"}) config += "controls:\n path: [2]\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "expected type string but got array") // Invalid controls in a separate file tmpFile, err = os.CreateTemp(t.TempDir(), "*controls.yml") require.NoError(t, err) _, err = tmpFile.WriteString("[2]") require.NoError(t, err) config = getConfig([]string{"controls"}) config += fmt.Sprintf("%s:\n path: %s\n", "controls", tmpFile.Name()) _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "expected type spec.GitOpsControls but got array") // Invalid policies config = getConfig([]string{"policies"}) config += "policies:\n path: [2]\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "expected type []spec.Policy but got object") // Invalid policies in a separate file tmpFile, err = os.CreateTemp(t.TempDir(), "*policies.yml") require.NoError(t, err) _, err = tmpFile.WriteString("[2]") require.NoError(t, err) config = getConfig([]string{"policies"}) config += fmt.Sprintf("%s:\n - path: %s\n", "policies", tmpFile.Name()) _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "expected type spec.Policy but got number") // Policy name missing config = getConfig([]string{"policies"}) config += "policies:\n - query: SELECT 1;\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "name is required") // Policy query missing config = getConfig([]string{"policies"}) config += "policies:\n - name: Test Policy\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "query is required") // Invalid reports config = getConfig([]string{"reports"}) config += "reports:\n path: [2]\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "expected type []spec.Query but got object") // Invalid reports in a separate file tmpFile, err = os.CreateTemp(t.TempDir(), "*reports.yml") require.NoError(t, err) _, err = tmpFile.WriteString("[2]") require.NoError(t, err) config = getConfig([]string{"reports"}) config += fmt.Sprintf("%s:\n - path: %s\n", "reports", tmpFile.Name()) _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "expected type spec.Query but got number") // Report name missing config = getConfig([]string{"reports"}) config += "reports:\n - query: SELECT 1;\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "`name` is required") // Report SQL missing config = getConfig([]string{"reports"}) config += "reports:\n - name: Test Query\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "`query` is required") }, ) } } func TestTopLevelGitOpsValidation(t *testing.T) { t.Parallel() tests := map[string]struct { optsToExclude []string shouldPass bool isTeam bool }{ "all_present_global": { optsToExclude: []string{}, shouldPass: true, }, "all_present_team": { optsToExclude: []string{}, shouldPass: true, isTeam: true, }, // Top-level keys besides "name" and "org_settings" are now optional. // A file must have either "name" (team) or "org_settings" (global). "missing_all_global": { optsToExclude: []string{"controls", "reports", "policies", "agent_options", "org_settings"}, }, "missing_reports": { optsToExclude: []string{"reports"}, shouldPass: true, }, "missing_policies": { optsToExclude: []string{"policies"}, shouldPass: true, }, "missing_agent_options": { optsToExclude: []string{"agent_options"}, shouldPass: true, }, "missing_org_settings": { optsToExclude: []string{"org_settings"}, }, "missing_name": { optsToExclude: []string{"name"}, isTeam: true, }, "missing_settings": { optsToExclude: []string{"settings"}, shouldPass: true, isTeam: true, }, } for name, test := range tests { t.Run( name, func(t *testing.T) { var config string if test.isTeam { config = getTeamConfig(test.optsToExclude) } else { config = getGlobalConfig(test.optsToExclude) } _, err := gitOpsFromString(t, config) if test.shouldPass { assert.NoError(t, err) } else { assert.ErrorContains(t, err, "is required") } }, ) } } func TestGitOpsNullArrays(t *testing.T) { t.Parallel() config := getGlobalConfig([]string{"reports", "policies"}) config += "reports: null\npolicies: ~\n" gitops, err := gitOpsFromString(t, config) assert.NoError(t, err) assert.Nil(t, gitops.Queries) assert.Nil(t, gitops.Policies) } func TestGitOpsPaths(t *testing.T) { t.Parallel() tests := map[string]struct { isArray bool isTeam bool goodConfig string }{ "org_settings": { isArray: false, goodConfig: "secrets: []\n", }, "settings": { isArray: false, isTeam: true, goodConfig: "secrets: []\n", }, "controls": { isArray: false, goodConfig: "windows_enabled_and_configured: true\n", }, "reports": { isArray: true, goodConfig: "[]", }, "policies": { isArray: true, goodConfig: "[]", }, "agent_options": { isArray: false, goodConfig: "name: value\n", }, } for name, test := range tests { test := test name := name t.Run( name, func(t *testing.T) { t.Parallel() getConfig := getGlobalConfig if test.isTeam { getConfig = getTeamConfig } // Test an absolute top level path tmpDir := t.TempDir() tmpFile, err := os.CreateTemp(tmpDir, "*good.yml") require.NoError(t, err) _, err = tmpFile.WriteString(test.goodConfig) require.NoError(t, err) config := getConfig([]string{name}) if test.isArray { config += fmt.Sprintf("%s:\n - path: %s\n", name, tmpFile.Name()) } else { config += fmt.Sprintf("%s:\n path: %s\n", name, tmpFile.Name()) } _, err = gitOpsFromString(t, config) assert.NoError(t, err) // Test a relative top level path config = getConfig([]string{name}) mainTmpFile, err := os.CreateTemp(tmpDir, "*main.yml") require.NoError(t, err) dir, file := filepath.Split(tmpFile.Name()) if test.isArray { config += fmt.Sprintf("%s:\n - path: ./%s\n", name, file) } else { config += fmt.Sprintf("%s:\n path: ./%s\n", name, file) } err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644) require.NoError(t, err) _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil, nopLogf) assert.NoError(t, err) // Test a bad path config = getConfig([]string{name}) if test.isArray { config += fmt.Sprintf("%s:\n - path: ./%s\n", name, "doesNotExist.yml") } else { config += fmt.Sprintf("%s:\n path: ./%s\n", name, "doesNotExist.yml") } err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644) require.NoError(t, err) _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil, nopLogf) assert.ErrorContains(t, err, "no such file or directory") // Test a bad file -- cannot be unmarshalled tmpFileBad, err := os.CreateTemp(t.TempDir(), "*invalid.yml") require.NoError(t, err) _, err = tmpFileBad.WriteString("bad:\nbad") require.NoError(t, err) config = getConfig([]string{name}) if test.isArray { config += fmt.Sprintf("%s:\n - path: %s\n", name, tmpFileBad.Name()) } else { config += fmt.Sprintf("%s:\n path: %s\n", name, tmpFileBad.Name()) } _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "failed to unmarshal") // Test a nested path -- bad tmpFileBad, err = os.CreateTemp(filepath.Dir(mainTmpFile.Name()), "*bad.yml") require.NoError(t, err) if test.isArray { _, err = tmpFileBad.WriteString(fmt.Sprintf("- path: %s\n", tmpFile.Name())) } else { _, err = tmpFileBad.WriteString(fmt.Sprintf("path: %s\n", tmpFile.Name())) } require.NoError(t, err) config = getConfig([]string{name}) dir, file = filepath.Split(tmpFileBad.Name()) if test.isArray { config += fmt.Sprintf("%s:\n - path: ./%s\n", name, file) } else { config += fmt.Sprintf("%s:\n path: ./%s\n", name, file) } err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644) require.NoError(t, err) _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil, nopLogf) assert.ErrorContains(t, err, "nested paths are not supported") }, ) } } func TestGitOpsGlobalPolicyWithInstallSoftware(t *testing.T) { t.Parallel() config := getGlobalConfig([]string{"policies"}) config += ` policies: - name: Some policy query: SELECT 1; install_software: package_path: ./some_path.yml ` _, err := gitOpsFromString(t, config) assert.ErrorContains(t, err, "install_software can only be set on team policies") } func TestGitOpsGlobalPolicyWithRunScript(t *testing.T) { t.Parallel() config := getGlobalConfig([]string{"policies"}) config += ` policies: - name: Some policy query: SELECT 1; run_script: path: ./some_path.sh ` _, err := gitOpsFromString(t, config) assert.ErrorContains(t, err, "run_script can only be set on team policies") } func TestGitOpsTeamPolicyWithInvalidInstallSoftware(t *testing.T) { t.Parallel() config := getTeamConfig([]string{"policies"}) config += ` policies: - name: Some policy query: SELECT 1; install_software: package_path: ./some_path.yml ` _, err := gitOpsFromString(t, config) assert.ErrorContains(t, err, "failed to read install_software.package_path file") config = getTeamConfig([]string{"policies"}) config += ` policies: - name: Some policy query: SELECT 1; install_software: package_path: ` _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "install_software must include either a package_path, an app_store_id or a hash_sha256") config = getTeamConfig([]string{"policies"}) config += ` policies: - name: Some policy query: SELECT 1; install_software: package_path: ./some_path.yml app_store_id: "123456" ` _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "must have only one of package_path or app_store_id") // Software has a URL that's too big tooBigURL := fmt.Sprintf("https://ftp.mozilla.org/%s", strings.Repeat("a", 4000-23)) config = getTeamConfig([]string{"software"}) config += fmt.Sprintf(` software: packages: - url: %s `, tooBigURL) appConfig := fleet.EnrichedAppConfig{} appConfig.License = &fleet.LicenseInfo{ Tier: fleet.TierPremium, } path, basePath := createTempFile(t, "", config) _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) assert.ErrorContains(t, err, fmt.Sprintf("software URL \"%s\" is too long, must be 4000 characters or less", tooBigURL)) // Software URL isn't a valid URL config = getTeamConfig([]string{"software"}) invalidURL := "1.2.3://" config += fmt.Sprintf(` software: packages: - url: %s `, invalidURL) path, basePath = createTempFile(t, "", config) _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) assert.ErrorContains(t, err, fmt.Sprintf("%s is not a valid URL", invalidURL)) // Software URL refers to a .exe but doesn't have (un)install scripts specified config = getTeamConfig([]string{"software"}) exeURL := "https://download-installer.cdn.mozilla.net/pub/firefox/releases/136.0.4/win64/en-US/Firefox%20Setup%20136.0.4.exe?foo=bar" config += fmt.Sprintf(` software: packages: - url: %s `, exeURL) path, basePath = createTempFile(t, "", config) _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) assert.ErrorContains(t, err, fmt.Sprintf("software URL %s refers to an .exe package, which requires both install_script and uninstall_script", exeURL)) // Software URL refers to a .tar.gz but doesn't have (un)install scripts specified (URL doesn't exist as Firefox is all .tar.xz) config = getTeamConfig([]string{"software"}) tgzURL := "https://download-installer.cdn.mozilla.net/pub/firefox/releases/137.0.2/linux-x86_64/en-US/firefox-137.0.2.tar.gz?foo=baz" config += fmt.Sprintf(` software: packages: - url: %s `, tgzURL) path, basePath = createTempFile(t, "", config) _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) assert.ErrorContains(t, err, fmt.Sprintf("software URL %s refers to a .tar.gz archive, which requires both install_script and uninstall_script", tgzURL)) // Policy references a VPP app not present on the team config = getTeamConfig([]string{"policies"}) config += ` policies: - name: Some policy query: SELECT 1; install_software: app_store_id: "123456" ` _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "not found on team") // Policy references a software installer not present in the team. config = getTeamConfig([]string{"policies"}) config += ` policies: - path: ./team_install_software.policies.yml software: packages: - url: https://ftp.mozilla.org/pub/firefox/releases/129.0.2/mac/en-US/Firefox%20129.0.2.pkg self_service: true ` path, basePath = createTempFile(t, "", config) err = file.Copy( filepath.Join("testdata", "team_install_software.policies.yml"), filepath.Join(basePath, "team_install_software.policies.yml"), 0o755, ) require.NoError(t, err) err = file.Copy( filepath.Join("testdata", "microsoft-teams.pkg.software.yml"), filepath.Join(basePath, "microsoft-teams.pkg.software.yml"), 0o755, ) require.NoError(t, err) _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) assert.ErrorContains(t, err, "install_software.package_path URL https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg not found on team", ) // Policy references a software installer file that has an invalid yaml. config = getTeamConfig([]string{"policies"}) config += ` policies: - path: ./team_install_software.policies.yml software: packages: - url: https://ftp.mozilla.org/pub/firefox/releases/129.0.2/mac/en-US/Firefox%20129.0.2.pkg self_service: true ` path, basePath = createTempFile(t, "", config) err = file.Copy( filepath.Join("testdata", "team_install_software.policies.yml"), filepath.Join(basePath, "team_install_software.policies.yml"), 0o755, ) require.NoError(t, err) err = os.WriteFile( // nolint:gosec filepath.Join(basePath, "microsoft-teams.pkg.software.yml"), []byte("invalid yaml"), 0o755, ) require.NoError(t, err) appConfig = fleet.EnrichedAppConfig{} appConfig.License = &fleet.LicenseInfo{ Tier: fleet.TierPremium, } _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) assert.ErrorContains(t, err, "file \"./microsoft-teams.pkg.software.yml\" does not contain a valid software package definition") // Policy references a software installer file that has multiple pieces of software specified config = getTeamConfig([]string{"policies"}) config += ` policies: - path: ./multipkg.policies.yml software: packages: - path: ./multiple-packages.yml ` path, basePath = createTempFile(t, "", config) err = file.Copy( filepath.Join("testdata", "multipkg.policies.yml"), filepath.Join(basePath, "multipkg.policies.yml"), 0o755, ) require.NoError(t, err) err = file.Copy( filepath.Join("testdata", "software", "multiple-packages.yml"), filepath.Join(basePath, "multiple-packages.yml"), 0o755, ) require.NoError(t, err) appConfig = fleet.EnrichedAppConfig{} appConfig.License = &fleet.LicenseInfo{ Tier: fleet.TierPremium, } _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) assert.ErrorContains(t, err, "contains multiple packages, so cannot be used as a target for policy automation") } func TestGitOpsWithStrayScriptEntryWithNoPath(t *testing.T) { t.Parallel() config := getTeamConfig([]string{"controls"}) config += ` controls: scripts: - ` _, err := gitOpsFromString(t, config) assert.ErrorContains(t, err, `check for a stray "-"`) } func TestGitOpsTeamPolicyWithInvalidRunScript(t *testing.T) { t.Parallel() config := getTeamConfig([]string{"policies"}) config += ` policies: - name: Some policy query: SELECT 1; run_script: path: ./some_path.sh ` _, err := gitOpsFromString(t, config) assert.ErrorContains(t, err, "script file does not exist") config = getTeamConfig([]string{"policies"}) config += ` policies: - name: Some policy query: SELECT 1; run_script: path: ` _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "empty run_script path") // Policy references a script not present in the team. config = getTeamConfig([]string{"policies"}) config += ` policies: - path: ./policies/script-policy.yml software: controls: scripts: - path: ./policies/policies2.yml ` path, basePath := createTempFile(t, "", config) err = file.Copy( filepath.Join("testdata", "policies", "script-policy.yml"), filepath.Join(basePath, "policies", "script-policy.yml"), 0o755, ) require.NoError(t, err) err = file.Copy( filepath.Join("testdata", "lib", "collect-fleetd-logs.sh"), filepath.Join(basePath, "lib", "collect-fleetd-logs.sh"), 0o755, ) require.NoError(t, err) appConfig := fleet.EnrichedAppConfig{} appConfig.License = &fleet.LicenseInfo{ Tier: fleet.TierPremium, } _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) assert.ErrorContains(t, err, "was not defined in controls for TeamName", ) } func getGlobalConfig(optsToExclude []string) string { return getBaseConfig(topLevelOptions, optsToExclude) } func getTeamConfig(optsToExclude []string) string { return getBaseConfig(teamLevelOptions, optsToExclude) } func getBaseConfig(options map[string]string, optsToExclude []string) string { var config string for key, value := range options { if !slices.Contains(optsToExclude, key) { config += value + "\n" } } return config } func TestSoftwarePackagesUnmarshalMulti(t *testing.T) { t.Parallel() config := getTeamConfig([]string{"software"}) config += ` software: packages: - path: software/single-package.yml - path: software/multiple-packages.yml ` path, basePath := createTempFile(t, "", config) for _, f := range []string{"single-package.yml", "multiple-packages.yml"} { err := file.Copy( filepath.Join("testdata", "software", f), filepath.Join(basePath, "software", f), os.FileMode(0o755), ) require.NoError(t, err) } appConfig := fleet.EnrichedAppConfig{} appConfig.License = &fleet.LicenseInfo{ Tier: fleet.TierPremium, } _, err := GitOpsFromFile(path, basePath, &appConfig, nopLogf) require.NoError(t, err) } func TestSoftwarePackagesPathWithInline(t *testing.T) { t.Parallel() config := getTeamConfig([]string{"software"}) config += ` software: packages: - path: software/single-package.yml icon: path: ./foo/bar.png ` path, basePath := createTempFile(t, "", config) err := file.Copy( filepath.Join("testdata", "software", "single-package.yml"), filepath.Join(basePath, "software", "single-package.yml"), os.FileMode(0o755), ) require.NoError(t, err) appConfig := fleet.EnrichedAppConfig{} appConfig.License = &fleet.LicenseInfo{ Tier: fleet.TierPremium, } _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) assert.ErrorContains(t, err, "the software package defined in software/single-package.yml must not have icons, scripts, queries, URL, or hash specified at the team level") } func TestIllegalFleetSecret(t *testing.T) { t.Parallel() config := getGlobalConfig([]string{"policies"}) config += ` policies: - name: $FLEET_SECRET_POLICY platform: linux query: SELECT 1 FROM osquery_info WHERE start_time < 0; - name: My policy platform: windows query: SELECT 1; ` _, err := gitOpsFromString(t, config) assert.ErrorContains(t, err, "variables with \"FLEET_SECRET_\" prefix are only allowed") } func TestInvalidSoftwareInstallerHash(t *testing.T) { appConfig := &fleet.EnrichedAppConfig{} appConfig.License = &fleet.LicenseInfo{ Tier: fleet.TierPremium, } _, err := GitOpsFromFile("testdata/team_config_invalid_sha.yml", "./testdata", appConfig, nopLogf) assert.ErrorContains(t, err, "must be a valid lower-case hex-encoded (64-character) SHA-256 hash value") } func TestSoftwareDisplayNameValidation(t *testing.T) { t.Parallel() appConfig := &fleet.EnrichedAppConfig{} appConfig.License = &fleet.LicenseInfo{ Tier: fleet.TierPremium, } // Create a string with 256 'a' characters (exceeds 255 limit) longDisplayName := strings.Repeat("a", 256) t.Run("package_display_name_too_long", func(t *testing.T) { config := getTeamConfig([]string{"name", "software"}) // Use hash instead of URL to avoid script validation before display_name validation config += `name: Test Team software: packages: - hash_sha256: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" display_name: "` + longDisplayName + `" ` path, basePath := createTempFile(t, "", config) _, err := GitOpsFromFile(path, basePath, appConfig, nopLogf) assert.ErrorContains(t, err, "display_name is too long (max 255 characters)") }) t.Run("app_store_display_name_too_long", func(t *testing.T) { config := getTeamConfig([]string{"name", "software"}) config += `name: Test Team software: app_store_apps: - app_store_id: "12345" display_name: "` + longDisplayName + `" ` path, basePath := createTempFile(t, "", config) _, err := GitOpsFromFile(path, basePath, appConfig, nopLogf) assert.ErrorContains(t, err, "display_name is too long (max 255 characters)") }) t.Run("valid_display_name", func(t *testing.T) { config := getTeamConfig([]string{"name", "software"}) // Use hash instead of URL to avoid network calls, and no scripts required config += `name: Test Team software: packages: - hash_sha256: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" display_name: "Custom Package Name" app_store_apps: - app_store_id: "12345" display_name: "Custom VPP App Name" ` path, basePath := createTempFile(t, "", config) result, err := GitOpsFromFile(path, basePath, appConfig, nopLogf) require.NoError(t, err) require.Len(t, result.Software.Packages, 1) assert.Equal(t, "Custom Package Name", result.Software.Packages[0].DisplayName) require.Len(t, result.Software.AppStoreApps, 1) assert.Equal(t, "Custom VPP App Name", result.Software.AppStoreApps[0].DisplayName) }) } func TestWebhookPolicyIDsValidation(t *testing.T) { t.Parallel() appConfig := &fleet.EnrichedAppConfig{} appConfig.License = &fleet.LicenseInfo{ Tier: fleet.TierPremium, } t.Run("no_team_invalid_policy_ids_as_number", func(t *testing.T) { config := getTeamConfig([]string{"name", "settings"}) config += `name: No team settings: webhook_settings: failing_policies_webhook: enable_failing_policies_webhook: true destination_url: https://webhook.site/test policy_ids: 567 host_batch_size: 0 software: packages: [] policies: [] ` noTeamPath, noTeamBasePath := createNamedFileOnTempDir(t, "no-team.yml", config) _, err := GitOpsFromFile(noTeamPath, noTeamBasePath, appConfig, nopLogf) assert.ErrorContains(t, err, "policy_ids' must be an array") }) t.Run("no_team_invalid_policy_ids_as_string", func(t *testing.T) { config := getTeamConfig([]string{"name", "settings"}) config += `name: No team settings: webhook_settings: failing_policies_webhook: enable_failing_policies_webhook: true destination_url: https://webhook.site/test policy_ids: "567" host_batch_size: 0 software: packages: [] policies: [] ` noTeamPath, noTeamBasePath := createNamedFileOnTempDir(t, "no-team.yml", config) _, err := GitOpsFromFile(noTeamPath, noTeamBasePath, appConfig, nopLogf) assert.ErrorContains(t, err, "policy_ids' must be an array") }) t.Run("no_team_valid_policy_ids_as_array", func(t *testing.T) { config := getTeamConfig([]string{"name", "settings"}) config += `name: No team settings: webhook_settings: failing_policies_webhook: enable_failing_policies_webhook: true destination_url: https://webhook.site/test policy_ids: [567, 890] host_batch_size: 0 software: packages: [] policies: [] ` noTeamPath, noTeamBasePath := createNamedFileOnTempDir(t, "no-team.yml", config) gitops, err := GitOpsFromFile(noTeamPath, noTeamBasePath, appConfig, nopLogf) assert.NoError(t, err) assert.NotNil(t, gitops) assert.True(t, gitops.IsNoTeam()) }) t.Run("no_team_valid_policy_ids_as_empty_array", func(t *testing.T) { config := getTeamConfig([]string{"name", "settings"}) config += `name: No team settings: webhook_settings: failing_policies_webhook: enable_failing_policies_webhook: true destination_url: https://webhook.site/test policy_ids: [] host_batch_size: 0 software: packages: [] policies: [] ` noTeamPath, noTeamBasePath := createNamedFileOnTempDir(t, "no-team.yml", config) gitops, err := GitOpsFromFile(noTeamPath, noTeamBasePath, appConfig, nopLogf) assert.NoError(t, err) assert.NotNil(t, gitops) }) t.Run("no_team_valid_policy_ids_as_yaml_list", func(t *testing.T) { config := getTeamConfig([]string{"name", "settings"}) config += `name: No team settings: webhook_settings: failing_policies_webhook: enable_failing_policies_webhook: true destination_url: https://webhook.site/test policy_ids: - 567 - 890 host_batch_size: 0 software: packages: [] policies: [] ` noTeamPath, noTeamBasePath := createNamedFileOnTempDir(t, "no-team.yml", config) gitops, err := GitOpsFromFile(noTeamPath, noTeamBasePath, appConfig, nopLogf) assert.NoError(t, err) assert.NotNil(t, gitops) }) t.Run("regular_team_invalid_policy_ids_as_number", func(t *testing.T) { config := getTeamConfig([]string{"settings"}) config += `settings: secrets: - secret: test123 webhook_settings: failing_policies_webhook: enable_failing_policies_webhook: true destination_url: https://webhook.site/test policy_ids: 567 host_batch_size: 0 ` _, err := gitOpsFromString(t, config) assert.ErrorContains(t, err, "policy_ids' must be an array") }) t.Run("regular_team_valid_policy_ids_as_array", func(t *testing.T) { config := getTeamConfig([]string{"settings"}) config += `settings: secrets: - secret: test123 webhook_settings: failing_policies_webhook: enable_failing_policies_webhook: true destination_url: https://webhook.site/test policy_ids: [567, 890] host_batch_size: 0 ` gitops, err := gitOpsFromString(t, config) assert.NoError(t, err) assert.NotNil(t, gitops) assert.NotNil(t, gitops.TeamSettings["webhook_settings"]) }) } func TestContainsGlobMeta(t *testing.T) { t.Parallel() tests := []struct { input string want bool }{ {"./scripts/foo.sh", false}, {"./scripts/*.sh", true}, {"./scripts/**/*.sh", true}, {"./scripts/[abc].sh", true}, {"./scripts/{a,b}.sh", true}, {"./scripts/foo?.sh", true}, {"", false}, } for _, tt := range tests { assert.Equal(t, tt.want, containsGlobMeta(tt.input), "containsGlobMeta(%q)", tt.input) } } func TestResolveScriptPathsGlob(t *testing.T) { t.Parallel() // requireErrorContains is a helper that asserts at least one error contains substr. requireErrorContains := func(t *testing.T, errs []error, substr string) { t.Helper() require.NotEmpty(t, errs, "expected errors but got none") var found bool for _, err := range errs { if strings.Contains(err.Error(), substr) { found = true break } } assert.True(t, found, "expected an error containing %q, got: %v", substr, errs) } t.Run("basic_glob", func(t *testing.T) { t.Parallel() dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "a.sh"), []byte("#!/bin/bash"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "b.sh"), []byte("#!/bin/bash"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "c.ps1"), []byte("# powershell"), 0o644)) items := []BaseItem{{Paths: ptr.String("*.sh")}} result, errs := resolveScriptPaths(items, dir, nopLogf) require.Empty(t, errs) require.Len(t, result, 2) assert.Equal(t, filepath.Join(dir, "a.sh"), *result[0].Path) assert.Equal(t, filepath.Join(dir, "b.sh"), *result[1].Path) // Paths field should not be set on expanded items assert.Nil(t, result[0].Paths) assert.Nil(t, result[1].Paths) }) t.Run("recursive_glob", func(t *testing.T) { t.Parallel() dir := t.TempDir() subdir := filepath.Join(dir, "sub") require.NoError(t, os.MkdirAll(subdir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(dir, "top.sh"), []byte("#!/bin/bash"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(subdir, "nested.sh"), []byte("#!/bin/bash"), 0o644)) items := []BaseItem{{Paths: ptr.String("**/*.sh")}} result, errs := resolveScriptPaths(items, dir, nopLogf) require.Empty(t, errs) require.Len(t, result, 2) // Results are sorted assert.Equal(t, filepath.Join(subdir, "nested.sh"), *result[0].Path) assert.Equal(t, filepath.Join(dir, "top.sh"), *result[1].Path) }) t.Run("mixed_path_and_paths", func(t *testing.T) { t.Parallel() dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "single.sh"), []byte("#!/bin/bash"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "glob1.ps1"), []byte("# ps1"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "glob2.ps1"), []byte("# ps1"), 0o644)) items := []BaseItem{ {Path: ptr.String("single.sh")}, {Paths: ptr.String("*.ps1")}, } result, errs := resolveScriptPaths(items, dir, nopLogf) require.Empty(t, errs) require.Len(t, result, 3) assert.Equal(t, filepath.Join(dir, "single.sh"), *result[0].Path) assert.Equal(t, filepath.Join(dir, "glob1.ps1"), *result[1].Path) assert.Equal(t, filepath.Join(dir, "glob2.ps1"), *result[2].Path) }) t.Run("paths_without_glob_error", func(t *testing.T) { t.Parallel() items := []BaseItem{{Paths: ptr.String("scripts/foo.sh")}} _, errs := resolveScriptPaths(items, "/tmp", nopLogf) requireErrorContains(t, errs, `does not contain glob characters`) }) t.Run("path_with_glob_error", func(t *testing.T) { t.Parallel() items := []BaseItem{{Path: ptr.String("scripts/*.sh")}} _, errs := resolveScriptPaths(items, "/tmp", nopLogf) requireErrorContains(t, errs, `contains glob characters`) }) t.Run("both_path_and_paths_error", func(t *testing.T) { t.Parallel() items := []BaseItem{{Path: ptr.String("foo.sh"), Paths: ptr.String("*.sh")}} _, errs := resolveScriptPaths(items, "/tmp", nopLogf) requireErrorContains(t, errs, `cannot have both "path" and "paths"`) }) t.Run("neither_path_nor_paths_error", func(t *testing.T) { t.Parallel() items := []BaseItem{{}} _, errs := resolveScriptPaths(items, "/tmp", nopLogf) requireErrorContains(t, errs, `no "path" or "paths" field`) }) t.Run("no_matches_warning", func(t *testing.T) { t.Parallel() dir := t.TempDir() var warnings []string logFn := func(format string, args ...any) { warnings = append(warnings, fmt.Sprintf(format, args...)) } items := []BaseItem{{Paths: ptr.String("*.sh")}} result, errs := resolveScriptPaths(items, dir, logFn) require.Empty(t, errs) assert.Empty(t, result) require.Len(t, warnings, 1) assert.Contains(t, warnings[0], "matched no script") }) t.Run("duplicate_basenames_error", func(t *testing.T) { t.Parallel() dir := t.TempDir() sub1 := filepath.Join(dir, "sub1") sub2 := filepath.Join(dir, "sub2") require.NoError(t, os.MkdirAll(sub1, 0o755)) require.NoError(t, os.MkdirAll(sub2, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(sub1, "dup.sh"), []byte("#!/bin/bash"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(sub2, "dup.sh"), []byte("#!/bin/bash"), 0o644)) items := []BaseItem{{Paths: ptr.String("**/*.sh")}} _, errs := resolveScriptPaths(items, dir, nopLogf) requireErrorContains(t, errs, "duplicate script basename") }) t.Run("duplicate_basenames_across_items_error", func(t *testing.T) { t.Parallel() dir := t.TempDir() sub := filepath.Join(dir, "sub") require.NoError(t, os.MkdirAll(sub, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(dir, "script.sh"), []byte("#!/bin/bash"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(sub, "script.sh"), []byte("#!/bin/bash"), 0o644)) items := []BaseItem{ {Path: ptr.String("script.sh")}, {Paths: ptr.String("sub/*.sh")}, } _, errs := resolveScriptPaths(items, dir, nopLogf) requireErrorContains(t, errs, "duplicate script basename") }) t.Run("non_script_files_skipped_with_warning", func(t *testing.T) { t.Parallel() dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "good.sh"), []byte("#!/bin/bash"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.txt"), []byte("text"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.py"), []byte("python"), 0o644)) var warnings []string logFn := func(format string, args ...any) { warnings = append(warnings, fmt.Sprintf(format, args...)) } items := []BaseItem{{Paths: ptr.String("*")}} result, errs := resolveScriptPaths(items, dir, logFn) require.Empty(t, errs) require.Len(t, result, 1) assert.Equal(t, filepath.Join(dir, "good.sh"), *result[0].Path) assert.Len(t, warnings, 2) }) // Results are only sorted for the sake of tests, // but having an explicit test protects against regression. t.Run("results_sorted", func(t *testing.T) { t.Parallel() dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "z.sh"), []byte("#!/bin/bash"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "a.sh"), []byte("#!/bin/bash"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "m.sh"), []byte("#!/bin/bash"), 0o644)) items := []BaseItem{{Paths: ptr.String("*.sh")}} result, errs := resolveScriptPaths(items, dir, nopLogf) require.Empty(t, errs) require.Len(t, result, 3) assert.Equal(t, filepath.Join(dir, "a.sh"), *result[0].Path) assert.Equal(t, filepath.Join(dir, "m.sh"), *result[1].Path) assert.Equal(t, filepath.Join(dir, "z.sh"), *result[2].Path) }) t.Run("multiple_errors_collected", func(t *testing.T) { t.Parallel() items := []BaseItem{{}, {Path: ptr.String("scripts/*.sh")}, {Paths: ptr.String("noglob.sh")}} _, errs := resolveScriptPaths(items, "", nil) require.Len(t, errs, 3) assert.Contains(t, errs[0].Error(), `no "path" or "paths"`) assert.Contains(t, errs[1].Error(), `contains glob characters`) assert.Contains(t, errs[2].Error(), `does not contain glob characters`) }) } func TestGitOpsGlobScripts(t *testing.T) { t.Parallel() dir := t.TempDir() scriptsDir := filepath.Join(dir, "scripts") require.NoError(t, os.MkdirAll(scriptsDir, 0o755)) scriptsSubDir := filepath.Join(scriptsDir, "sub") require.NoError(t, os.MkdirAll(scriptsSubDir, 0o755)) // Create script files require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, "alpha.sh"), []byte("#!/bin/bash\necho alpha"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, "beta.sh"), []byte("#!/bin/bash\necho beta"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, "gamma.ps1"), []byte("Write-Host gamma"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(scriptsSubDir, "delta.sh"), []byte("nada"), 0o644)) // Write a gitops YAML file that uses paths: glob config := getGlobalConfig([]string{"controls"}) config += `controls: scripts: - paths: scripts/*.sh - path: scripts/gamma.ps1 ` yamlPath := filepath.Join(dir, "gitops.yml") require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) result, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf) require.NoError(t, err) require.Len(t, result.Controls.Scripts, 3) // Glob results come first (sorted), then the explicit path assert.Equal(t, filepath.Join(scriptsDir, "alpha.sh"), *result.Controls.Scripts[0].Path) assert.Equal(t, filepath.Join(scriptsDir, "beta.sh"), *result.Controls.Scripts[1].Path) assert.Equal(t, filepath.Join(scriptsDir, "gamma.ps1"), *result.Controls.Scripts[2].Path) } func TestUnknownKeyDetection(t *testing.T) { t.Parallel() t.Run("unknown key in controls", func(t *testing.T) { t.Parallel() config := ` name: TeamName settings: secrets: agent_options: controls: macos_updates: minimum_version: "14.0" deadline: "2024-01-01" unknown_control_field: true reports: policies: software: ` path, basePath := createTempFile(t, "", config) _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) require.Error(t, err) assert.Contains(t, err.Error(), "unknown_control_field") }) t.Run("unknown key in controls macos_updates (any-field)", func(t *testing.T) { t.Parallel() config := ` name: TeamName settings: secrets: agent_options: controls: macos_updates: minimum_version: "14.0" deadlinee: "2024-01-01" reports: policies: software: ` path, basePath := createTempFile(t, "", config) _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) require.Error(t, err) assert.Contains(t, err.Error(), "deadlinee") assert.Contains(t, err.Error(), `did you mean "deadline"?`) assert.Contains(t, err.Error(), "controls.macos_updates") }) t.Run("unknown key in query entry", func(t *testing.T) { t.Parallel() config := ` name: TeamName settings: secrets: agent_options: controls: reports: - name: test_query query: SELECT 1; unknown_query_field: true policies: software: ` path, basePath := createTempFile(t, "", config) _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) require.Error(t, err) assert.Contains(t, err.Error(), "unknown_query_field") }) t.Run("unknown key in policy entry", func(t *testing.T) { t.Parallel() config := ` name: TeamName settings: secrets: agent_options: controls: reports: policies: - name: test_policy query: SELECT 1; unknown_policy_field: true software: ` path, basePath := createTempFile(t, "", config) _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) require.Error(t, err) assert.Contains(t, err.Error(), "unknown_policy_field") }) t.Run("unknown key in label entry", func(t *testing.T) { t.Parallel() config := ` name: TeamName settings: secrets: agent_options: controls: labels: - name: test_label query: SELECT 1 label_membership_type: dynamic unknown_label_field: true reports: policies: software: ` path, basePath := createTempFile(t, "", config) _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) require.Error(t, err) assert.Contains(t, err.Error(), "unknown_label_field") }) t.Run("unknown key in software section", func(t *testing.T) { t.Parallel() config := ` name: TeamName settings: secrets: agent_options: controls: reports: policies: software: unknown_software_field: true ` path, basePath := createTempFile(t, "", config) _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) require.Error(t, err) assert.Contains(t, err.Error(), "unknown_software_field") }) t.Run("multiple unknown keys reported at once", func(t *testing.T) { t.Parallel() config := ` name: TeamName settings: secrets: agent_options: controls: bad_control_key: true reports: - name: test_query query: SELECT 1; bad_query_key: true policies: - name: test_policy query: SELECT 1; bad_policy_key: true software: ` path, basePath := createTempFile(t, "", config) _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) require.Error(t, err) assert.Contains(t, err.Error(), "bad_control_key") assert.Contains(t, err.Error(), "bad_query_key") assert.Contains(t, err.Error(), "bad_policy_key") }) t.Run("multiple unknown keys within a single section", func(t *testing.T) { t.Parallel() config := ` name: TeamName settings: secrets: agent_options: controls: macos_updates: minimum_version: "14.0" deadlinee: "2024-01-01" update_new_hostss: true bad_control_key: true reports: policies: software: ` path, basePath := createTempFile(t, "", config) _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) require.Error(t, err) assert.Contains(t, err.Error(), "deadlinee") assert.Contains(t, err.Error(), "update_new_hostss") assert.Contains(t, err.Error(), "bad_control_key") }) t.Run("valid config no unknown key errors", func(t *testing.T) { t.Parallel() config := ` name: TeamName settings: secrets: agent_options: controls: macos_updates: minimum_version: "14.0" deadline: "2024-01-01" reports: - name: test_query query: SELECT 1; interval: 3600 policies: - name: test_policy query: SELECT 1; description: A test policy software: ` path, basePath := createTempFile(t, "", config) _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) require.NoError(t, err) }) t.Run("allow-unknown-keys option logs instead of erroring", func(t *testing.T) { t.Parallel() config := ` name: TeamName settings: secrets: agent_options: controls: unknown_control_field: true reports: policies: software: ` path, basePath := createTempFile(t, "", config) var logMessages []string sawExpectErrorMsg := false logFn := func(format string, a ...any) { msg := fmt.Sprintf(format, a...) if strings.Contains(msg, "unknown_control_field") { sawExpectErrorMsg = true } logMessages = append(logMessages, msg) } _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), logFn, GitOpsOptions{AllowUnknownKeys: true}) require.NoError(t, err) // Should have logged a warning about the unknown key require.NotEmpty(t, logMessages) assert.True(t, sawExpectErrorMsg, "expected warning about unknown_control_field in log messages: %v", logMessages) }) t.Run("unknown key in controls on no-team path", func(t *testing.T) { t.Parallel() config := ` name: No team controls: unknown_control_field: true policies: ` path, basePath := createNamedFileOnTempDir(t, "no-team.yml", config) _, err := GitOpsFromFile(path, basePath, nil, nopLogf) require.Error(t, err) assert.Contains(t, err.Error(), "unknown_control_field") }) t.Run("unknown key in software package via path", func(t *testing.T) { t.Parallel() config := getTeamConfig([]string{"software"}) config += ` software: packages: - path: pkg.yml ` path, basePath := createTempFile(t, "", config) pkgYAML := ` url: https://example.com/pkg.pkg hash_sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa unknown_pkg_field: bad ` require.NoError(t, os.WriteFile(filepath.Join(basePath, "pkg.yml"), []byte(pkgYAML), 0o644)) _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) require.Error(t, err) assert.Contains(t, err.Error(), "unknown_pkg_field") }) t.Run("unknown key in software package array via path", func(t *testing.T) { t.Parallel() config := getTeamConfig([]string{"software"}) config += ` software: packages: - path: pkgs.yml ` path, basePath := createTempFile(t, "", config) pkgYAML := ` - url: https://example.com/pkg.pkg hash_sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa unknown_array_field: bad ` require.NoError(t, os.WriteFile(filepath.Join(basePath, "pkgs.yml"), []byte(pkgYAML), 0o644)) _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) require.Error(t, err) assert.Contains(t, err.Error(), "unknown_array_field") }) t.Run("unknown key in policy install_software package_path", func(t *testing.T) { t.Parallel() config := getTeamConfig([]string{"policies", "software"}) config += ` software: packages: - url: https://example.com/pkg.pkg hash_sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa policies: - name: Test policy query: SELECT 1; install_software: package_path: pkg.yml ` path, basePath := createTempFile(t, "", config) pkgYAML := ` url: https://example.com/pkg.pkg hash_sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa unknown_policy_pkg_field: bad ` require.NoError(t, os.WriteFile(filepath.Join(basePath, "pkg.yml"), []byte(pkgYAML), 0o644)) _, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf) require.Error(t, err) assert.Contains(t, err.Error(), "unknown_policy_pkg_field") }) } // TestControlsNewKeyNames verifies that the new multi-platform key names // (apple_settings, setup_experience, configuration_profiles, apple_setup_assistant) // are accepted in controls parsing and produce the same result as the old names. func TestControlsNewKeyNames(t *testing.T) { t.Parallel() // Test with inline controls using new key names t.Run("inline_new_names", func(t *testing.T) { t.Parallel() dir := t.TempDir() profileDir := filepath.Join(dir, "lib") require.NoError(t, os.Mkdir(profileDir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(profileDir, "macos-password.mobileconfig"), []byte(""), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(profileDir, "windows-screenlock.xml"), []byte(""), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(profileDir, "collect-fleetd-logs.sh"), []byte("#!/bin/bash"), 0o644)) config := ` controls: apple_settings: configuration_profiles: - path: ./lib/macos-password.mobileconfig windows_settings: configuration_profiles: - path: ./lib/windows-screenlock.xml scripts: - path: ./lib/collect-fleetd-logs.sh enable_disk_encryption: true setup_experience: bootstrap_package: null enable_end_user_authentication: false apple_setup_assistant: null macos_updates: deadline: null minimum_version: null windows_enabled_and_configured: true reports: policies: agent_options: org_settings: server_settings: server_url: https://fleet.example.com org_info: contact_url: https://example.com/contact org_logo_url: "" org_logo_url_light_background: "" org_name: Test Org secrets: ` yamlPath := filepath.Join(dir, "gitops.yml") require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) gitops, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf) require.NoError(t, err) // Verify controls parsed correctly with new key names macSettings, ok := gitops.Controls.MacOSSettings.(fleet.MacOSSettings) require.True(t, ok, "macos_settings (via apple_settings) not parsed") require.Len(t, macSettings.CustomSettings, 1) winSettings, ok := gitops.Controls.WindowsSettings.(fleet.WindowsSettings) require.True(t, ok, "windows_settings not parsed") require.True(t, winSettings.CustomSettings.Valid) require.Len(t, winSettings.CustomSettings.Value, 1) require.NotNil(t, gitops.Controls.MacOSSetup, "macos_setup (via setup_experience) not parsed") diskEnc, ok := gitops.Controls.EnableDiskEncryption.(bool) require.True(t, ok) require.True(t, diskEnc) }) // Test with external controls file using new key names t.Run("external_file_new_names", func(t *testing.T) { t.Parallel() dir := t.TempDir() profileDir := filepath.Join(dir, "lib") require.NoError(t, os.Mkdir(profileDir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(profileDir, "macos-password.mobileconfig"), []byte(""), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(profileDir, "windows-screenlock.xml"), []byte(""), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(profileDir, "collect-fleetd-logs.sh"), []byte("#!/bin/bash"), 0o644)) controlsYAML := ` apple_settings: configuration_profiles: - path: ./lib/macos-password.mobileconfig windows_settings: configuration_profiles: - path: ./lib/windows-screenlock.xml scripts: - path: ./lib/collect-fleetd-logs.sh enable_disk_encryption: true setup_experience: bootstrap_package: null enable_end_user_authentication: false apple_setup_assistant: null macos_updates: deadline: null minimum_version: null windows_enabled_and_configured: true ` controlsPath := filepath.Join(dir, "controls.yml") require.NoError(t, os.WriteFile(controlsPath, []byte(controlsYAML), 0o644)) config := ` controls: path: ./controls.yml reports: policies: agent_options: org_settings: server_settings: server_url: https://fleet.example.com org_info: contact_url: https://example.com/contact org_logo_url: "" org_logo_url_light_background: "" org_name: Test Org secrets: ` yamlPath := filepath.Join(dir, "gitops.yml") require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) gitops, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf) require.NoError(t, err) // Verify controls parsed correctly from external file with new key names macSettings, ok := gitops.Controls.MacOSSettings.(fleet.MacOSSettings) require.True(t, ok, "macos_settings (via apple_settings in external file) not parsed") require.Len(t, macSettings.CustomSettings, 1) winSettings, ok := gitops.Controls.WindowsSettings.(fleet.WindowsSettings) require.True(t, ok, "windows_settings not parsed") require.True(t, winSettings.CustomSettings.Valid) require.Len(t, winSettings.CustomSettings.Value, 1) require.NotNil(t, gitops.Controls.MacOSSetup, "macos_setup (via setup_experience in external file) not parsed") }) // Test that duplicate settings with old and new key names produce an error t.Run("duplicate_old_and_new_keys_error_apple_settings", func(t *testing.T) { dir := t.TempDir() profileDir := filepath.Join(dir, "lib") require.NoError(t, os.Mkdir(profileDir, 0o755)) config := ` reports: policies: agent_options: org_settings: server_settings: org_info: secrets: controls: apple_settings: configuration_profiles: - path: ./lib/macos-password.mobileconfig macos_settings: ` yamlPath := filepath.Join(dir, "gitops.yml") require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) _, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf) require.Error(t, err) require.Contains(t, err.Error(), "Conflicting field names") require.Contains(t, err.Error(), "apple_settings") require.Contains(t, err.Error(), "`macos_settings` (deprecated)") }) t.Run("duplicate_old_and_new_keys_error_apple_custom_settings", func(t *testing.T) { dir := t.TempDir() profileDir := filepath.Join(dir, "lib") require.NoError(t, os.Mkdir(profileDir, 0o755)) config := ` reports: policies: agent_options: org_settings: server_settings: org_info: secrets: controls: apple_settings: configuration_profiles: - path: ./lib/macos-password.mobileconfig custom_settings: - path: ./lib/macos-password.mobileconfig ` yamlPath := filepath.Join(dir, "gitops.yml") require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) _, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf) require.Error(t, err) require.Contains(t, err.Error(), "Conflicting field names") require.Contains(t, err.Error(), "configuration_profiles") require.Contains(t, err.Error(), "`custom_settings` (deprecated)") }) t.Run("duplicate_old_and_new_keys_error_windows_custom_settings", func(t *testing.T) { dir := t.TempDir() profileDir := filepath.Join(dir, "lib") require.NoError(t, os.Mkdir(profileDir, 0o755)) config := ` reports: policies: agent_options: org_settings: server_settings: org_info: secrets: controls: windows_settings: configuration_profiles: - path: ./lib/foo custom_settings: - path: ./lib/bar ` yamlPath := filepath.Join(dir, "gitops.yml") require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) _, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf) require.Error(t, err) require.Contains(t, err.Error(), "Conflicting field names") require.Contains(t, err.Error(), "configuration_profiles") require.Contains(t, err.Error(), "`custom_settings` (deprecated)") }) t.Run("duplicate_old_and_new_keys_error_android_custom_settings", func(t *testing.T) { dir := t.TempDir() profileDir := filepath.Join(dir, "lib") require.NoError(t, os.Mkdir(profileDir, 0o755)) config := ` reports: policies: agent_options: org_settings: server_settings: org_info: secrets: controls: android_settings: configuration_profiles: - path: ./lib/foo custom_settings: - path: ./lib/bar ` yamlPath := filepath.Join(dir, "gitops.yml") require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) _, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf) require.Error(t, err) require.Contains(t, err.Error(), "Conflicting field names") require.Contains(t, err.Error(), "configuration_profiles") require.Contains(t, err.Error(), "`custom_settings` (deprecated)") }) t.Run("duplicate_old_and_new_keys_error_setup_experience", func(t *testing.T) { dir := t.TempDir() profileDir := filepath.Join(dir, "lib") require.NoError(t, os.Mkdir(profileDir, 0o755)) config := ` reports: policies: agent_options: org_settings: server_settings: org_info: secrets: controls: setup_experience: macos_setup: ` yamlPath := filepath.Join(dir, "gitops.yml") require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) _, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf) require.Error(t, err) require.Contains(t, err.Error(), "Conflicting field names") require.Contains(t, err.Error(), "setup_experience") require.Contains(t, err.Error(), "`macos_setup` (deprecated)") }) t.Run("duplicate_keys_external_file", func(t *testing.T) { t.Parallel() dir := t.TempDir() profileDir := filepath.Join(dir, "lib") require.NoError(t, os.Mkdir(profileDir, 0o755)) controlsYAML := ` apple_settings: macos_settings: ` controlsPath := filepath.Join(dir, "controls.yml") require.NoError(t, os.WriteFile(controlsPath, []byte(controlsYAML), 0o644)) config := ` controls: path: ./controls.yml reports: policies: agent_options: org_settings: secrets: ` yamlPath := filepath.Join(dir, "gitops.yml") require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) _, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf) require.Error(t, err) require.Contains(t, err.Error(), "Conflicting field names") require.Contains(t, err.Error(), "apple_settings") require.Contains(t, err.Error(), "`macos_settings` (deprecated)") }) } func TestSoftwarePackagesScriptPath(t *testing.T) { t.Parallel() appConfig := &fleet.EnrichedAppConfig{} appConfig.License = &fleet.LicenseInfo{ Tier: fleet.TierPremium, } t.Run("valid_sh_script_path", func(t *testing.T) { config := getTeamConfig([]string{"software"}) config += ` software: packages: - path: software/install-app.sh categories: - Utilities self_service: true ` path, basePath := createTempFile(t, "", config) err := file.Copy( filepath.Join("testdata", "software", "install-app.sh"), filepath.Join(basePath, "software", "install-app.sh"), os.FileMode(0o755), ) require.NoError(t, err) result, err := GitOpsFromFile(path, basePath, appConfig, nopLogf) require.NoError(t, err) require.Len(t, result.Software.Packages, 1) assert.True(t, strings.HasSuffix(result.Software.Packages[0].InstallScript.Path, "install-app.sh")) assert.Equal(t, []string{"Utilities"}, result.Software.Packages[0].Categories) assert.True(t, result.Software.Packages[0].SelfService) assert.Empty(t, result.Software.Packages[0].URL) assert.Empty(t, result.Software.Packages[0].SHA256) }) t.Run("valid_ps1_script_path", func(t *testing.T) { config := getTeamConfig([]string{"software"}) config += ` software: packages: - path: software/install-app.ps1 self_service: false ` path, basePath := createTempFile(t, "", config) // Copy the test script file err := file.Copy( filepath.Join("testdata", "software", "install-app.ps1"), filepath.Join(basePath, "software", "install-app.ps1"), os.FileMode(0o755), ) require.NoError(t, err) result, err := GitOpsFromFile(path, basePath, appConfig, nopLogf) require.NoError(t, err) require.Len(t, result.Software.Packages, 1) assert.True(t, strings.HasSuffix(result.Software.Packages[0].InstallScript.Path, "install-app.ps1")) }) t.Run("invalid_extension_error", func(t *testing.T) { config := getTeamConfig([]string{"software"}) config += ` software: packages: - path: software/install-app.txt ` path, basePath := createTempFile(t, "", config) // Create a .txt file err := os.MkdirAll(filepath.Join(basePath, "software"), 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(basePath, "software", "install-app.txt"), []byte("test"), 0o644) require.NoError(t, err) _, err = GitOpsFromFile(path, basePath, appConfig, nopLogf) assert.ErrorContains(t, err, "unsupported extension") assert.ErrorContains(t, err, "only .yml, .yaml, .sh, or .ps1 files are supported") }) t.Run("script_with_team_options", func(t *testing.T) { config := getTeamConfig([]string{"software"}) config += ` software: packages: - path: software/install-app.sh categories: - Browsers - Productivity self_service: true setup_experience: true labels_include_any: - include_label ` path, basePath := createTempFile(t, "", config) err := file.Copy( filepath.Join("testdata", "software", "install-app.sh"), filepath.Join(basePath, "software", "install-app.sh"), os.FileMode(0o755), ) require.NoError(t, err) result, err := GitOpsFromFile(path, basePath, appConfig, nopLogf) require.NoError(t, err) require.Len(t, result.Software.Packages, 1) pkg := result.Software.Packages[0] assert.Equal(t, []string{"Browsers", "Productivity"}, pkg.Categories) assert.True(t, pkg.SelfService) assert.True(t, pkg.InstallDuringSetup.Value) assert.Equal(t, []string{"include_label"}, pkg.LabelsIncludeAny) }) t.Run("mixed_yaml_and_script_paths", func(t *testing.T) { config := getTeamConfig([]string{"software"}) config += ` software: packages: - path: software/single-package.yml - path: software/install-app.sh self_service: true ` path, basePath := createTempFile(t, "", config) err := file.Copy( filepath.Join("testdata", "software", "single-package.yml"), filepath.Join(basePath, "software", "single-package.yml"), os.FileMode(0o755), ) require.NoError(t, err) err = file.Copy( filepath.Join("testdata", "software", "install-app.sh"), filepath.Join(basePath, "software", "install-app.sh"), os.FileMode(0o755), ) require.NoError(t, err) result, err := GitOpsFromFile(path, basePath, appConfig, nopLogf) require.NoError(t, err) require.Len(t, result.Software.Packages, 2) assert.NotEmpty(t, result.Software.Packages[0].SHA256) assert.True(t, strings.HasSuffix(result.Software.Packages[1].InstallScript.Path, "install-app.sh")) assert.True(t, result.Software.Packages[1].SelfService) }) } func TestParsePolicyInstallSoftware(t *testing.T) { t.Parallel() teamName := "test-team" t.Run("wrapErrs prefixes errors", func(t *testing.T) { t.Parallel() policy := &Policy{ GitOpsPolicySpec: GitOpsPolicySpec{ PolicySpec: fleet.PolicySpec{Name: "my policy"}, InstallSoftware: &PolicyInstallSoftware{ // no package_path, app_store_id, or hash_sha256 }, }, } errs := parsePolicyInstallSoftware(".", &teamName, policy, nil, nil) require.Len(t, errs, 1) assert.Equal(t, errs[0].Error(), `failed to parse policy install_software "my policy": install_software must include either a package_path, an app_store_id or a hash_sha256`) }) t.Run("unknown key in package_path file", func(t *testing.T) { t.Parallel() dir := t.TempDir() sha := "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" content := fmt.Sprintf("hash_sha256: %s\nbad_field: oops\n", sha) path := filepath.Join(dir, "pkg.yml") require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) policy := &Policy{ GitOpsPolicySpec: GitOpsPolicySpec{ PolicySpec: fleet.PolicySpec{Name: "typo policy"}, InstallSoftware: &PolicyInstallSoftware{ PackagePath: path, }, }, } packages := []*fleet.SoftwarePackageSpec{{SHA256: sha}} errs := parsePolicyInstallSoftware(".", &teamName, policy, packages, nil) require.Len(t, errs, 1) var unknownErr *ParseUnknownKeyError require.ErrorAs(t, errs[0], &unknownErr) assert.Equal(t, "bad_field", unknownErr.Field) }) }