5449 lines
182 KiB
Go
5449 lines
182 KiB
Go
package spec
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/file"
|
|
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/ghodss/yaml"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// yamlToRawJSON converts a YAML string into the map[string]json.RawMessage that parse functions expect.
|
|
func yamlToRawJSON(t *testing.T, yamlStr string) map[string]json.RawMessage {
|
|
t.Helper()
|
|
j, err := yaml.YAMLToJSON([]byte(yamlStr))
|
|
require.NoError(t, err)
|
|
var top map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(j, &top))
|
|
return top
|
|
}
|
|
|
|
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.Value, "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.Value, []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.Value, []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]any)["server_url"])
|
|
assert.EqualValues(t, 2000, serverSettings.(map[string]any)["report_cap"])
|
|
assert.Contains(t, gitops.OrgSettings, "org_info")
|
|
orgInfo, ok := gitops.OrgSettings["org_info"].(map[string]any)
|
|
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.WindowsEntraClientIDs.([]any)
|
|
assert.True(t, ok, "windows_entra_client_ids not found")
|
|
_, ok = gitops.Controls.WindowsUpdates.(map[string]interface{})
|
|
assert.True(t, ok, "windows_updates not found")
|
|
_, ok = gitops.Controls.AppleRequireHardwareAttestation.(bool)
|
|
assert.True(t, ok, "apple_require_hardware_attestation 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.Other.HashSHA256)
|
|
} else {
|
|
assert.Equal(t, "./microsoft-teams.pkg.software.yml", gitops.Policies[5].InstallSoftware.Other.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.Other.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 TestGitOpsHostNameTemplate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("valid string parses", func(t *testing.T) {
|
|
config := getTeamConfig([]string{"controls"})
|
|
config += "controls:\n name_template: \"iPad $FLEET_VAR_HOST_HARDWARE_SERIAL\"\n"
|
|
gitops, err := gitOpsFromString(t, config)
|
|
require.NoError(t, err)
|
|
nameTemplate, ok := gitops.Controls.NameTemplate.(string)
|
|
require.True(t, ok, "name_template should be a string")
|
|
require.Equal(t, "iPad $FLEET_VAR_HOST_HARDWARE_SERIAL", nameTemplate)
|
|
})
|
|
|
|
t.Run("integer value rejected", func(t *testing.T) {
|
|
config := getTeamConfig([]string{"controls"})
|
|
config += "controls:\n name_template: 42\n"
|
|
_, err := gitOpsFromString(t, config)
|
|
require.ErrorContains(t, err, "name_template")
|
|
require.ErrorContains(t, err, "must be a string")
|
|
})
|
|
|
|
t.Run("map value rejected", func(t *testing.T) {
|
|
config := getTeamConfig([]string{"controls"})
|
|
config += "controls:\n name_template:\n foo: bar\n"
|
|
_, err := gitOpsFromString(t, config)
|
|
require.ErrorContains(t, err, "name_template")
|
|
require.ErrorContains(t, err, "must be a string")
|
|
})
|
|
|
|
t.Run("null value treated as absent", func(t *testing.T) {
|
|
config := getTeamConfig([]string{"controls"})
|
|
config += "controls:\n name_template:\n"
|
|
gitops, err := gitOpsFromString(t, config)
|
|
require.NoError(t, err)
|
|
require.Nil(t, gitops.Controls.NameTemplate)
|
|
})
|
|
|
|
t.Run("Set returns true when only name_template present", func(t *testing.T) {
|
|
c := GitOpsControls{NameTemplate: "iPad $FLEET_VAR_HOST_HARDWARE_SERIAL"}
|
|
require.True(t, c.Set())
|
|
})
|
|
}
|
|
|
|
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)
|
|
require.NotNil(t, gitops.Labels[0].Hosts)
|
|
assert.Empty(t, gitops.Labels[0].Hosts)
|
|
}
|
|
|
|
func TestManualLabelNullHostsKey(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)
|
|
// hosts key present with null value should produce a non-nil empty slice
|
|
// (meaning "clear all hosts"), distinct from a nil slice (key omitted,
|
|
// meaning "preserve existing membership").
|
|
require.NotNil(t, gitops.Labels[0].Hosts)
|
|
assert.Empty(t, gitops.Labels[0].Hosts)
|
|
}
|
|
|
|
func TestLabelInvalidFieldCombinations(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
label string
|
|
wantErrs []string
|
|
notWantErrs []string
|
|
}{
|
|
{
|
|
name: "manual label with query",
|
|
label: `
|
|
labels:
|
|
- name: bad
|
|
label_membership_type: manual
|
|
query: SELECT 1`,
|
|
wantErrs: []string{`label "bad" is declared as manual but contains a query`},
|
|
},
|
|
{
|
|
name: "manual label with criteria",
|
|
label: `
|
|
labels:
|
|
- name: bad
|
|
label_membership_type: manual
|
|
criteria:
|
|
vital: end_user_idp_group
|
|
operator: "="
|
|
value: Engineering`,
|
|
wantErrs: []string{`label "bad" is declared as manual but contains criteria`},
|
|
},
|
|
{
|
|
name: "manual label with platform",
|
|
label: `
|
|
labels:
|
|
- name: bad
|
|
label_membership_type: manual
|
|
platform: darwin`,
|
|
wantErrs: []string{`label "bad" is declared as manual but contains a platform`},
|
|
},
|
|
{
|
|
name: "manual label with all invalid fields",
|
|
label: `
|
|
labels:
|
|
- name: bad
|
|
label_membership_type: manual
|
|
query: SELECT 1
|
|
platform: darwin
|
|
criteria:
|
|
vital: end_user_idp_group
|
|
operator: "="
|
|
value: Engineering`,
|
|
wantErrs: []string{
|
|
`label "bad" is declared as manual but contains a query`,
|
|
`label "bad" is declared as manual but contains criteria`,
|
|
`label "bad" is declared as manual but contains a platform`,
|
|
},
|
|
},
|
|
{
|
|
name: "dynamic label with criteria",
|
|
label: `
|
|
labels:
|
|
- name: bad
|
|
label_membership_type: dynamic
|
|
query: SELECT 1
|
|
criteria:
|
|
vital: end_user_idp_group
|
|
operator: "="
|
|
value: Engineering`,
|
|
wantErrs: []string{`label "bad" is declared as dynamic but contains criteria`},
|
|
},
|
|
{
|
|
name: "dynamic label with hosts",
|
|
label: `
|
|
labels:
|
|
- name: bad
|
|
label_membership_type: dynamic
|
|
query: SELECT 1
|
|
hosts:
|
|
- host1`,
|
|
wantErrs: []string{`label "bad" is declared as dynamic but contains hosts`},
|
|
},
|
|
{
|
|
name: "dynamic label missing query",
|
|
label: `
|
|
labels:
|
|
- name: bad
|
|
label_membership_type: dynamic`,
|
|
wantErrs: []string{`label "bad" is declared as dynamic but is missing a query`},
|
|
},
|
|
{
|
|
name: "dynamic label with invalid platform",
|
|
label: `
|
|
labels:
|
|
- name: bad
|
|
label_membership_type: dynamic
|
|
query: SELECT 1
|
|
platform: invalidplatform`,
|
|
wantErrs: []string{`label "bad" has invalid platform: "invalidplatform"`},
|
|
},
|
|
{
|
|
name: "dynamic label with valid platform is ok",
|
|
label: `
|
|
labels:
|
|
- name: ok
|
|
label_membership_type: dynamic
|
|
query: SELECT 1
|
|
platform: darwin`,
|
|
},
|
|
{
|
|
name: "host_vitals label with query",
|
|
label: `
|
|
labels:
|
|
- name: bad
|
|
label_membership_type: host_vitals
|
|
query: SELECT 1
|
|
criteria:
|
|
vital: end_user_idp_group
|
|
operator: "="
|
|
value: Engineering`,
|
|
wantErrs: []string{`label "bad" is declared as host_vitals but contains a query`},
|
|
},
|
|
{
|
|
name: "host_vitals label with platform",
|
|
label: `
|
|
labels:
|
|
- name: bad
|
|
label_membership_type: host_vitals
|
|
platform: darwin
|
|
criteria:
|
|
vital: end_user_idp_group
|
|
operator: "="
|
|
value: Engineering`,
|
|
wantErrs: []string{`label "bad" is declared as host_vitals but contains a platform`},
|
|
},
|
|
{
|
|
name: "host_vitals label with hosts",
|
|
label: `
|
|
labels:
|
|
- name: bad
|
|
label_membership_type: host_vitals
|
|
criteria:
|
|
vital: end_user_idp_group
|
|
operator: "="
|
|
value: Engineering
|
|
hosts:
|
|
- host1`,
|
|
wantErrs: []string{`label "bad" is declared as host_vitals but contains hosts`},
|
|
},
|
|
{
|
|
name: "host_vitals label missing criteria",
|
|
label: `
|
|
labels:
|
|
- name: bad
|
|
label_membership_type: host_vitals`,
|
|
wantErrs: []string{`label "bad" is declared as host_vitals but is missing criteria`},
|
|
},
|
|
{
|
|
name: "valid manual label",
|
|
label: `
|
|
labels:
|
|
- name: ok
|
|
label_membership_type: manual
|
|
hosts:
|
|
- host1`,
|
|
},
|
|
{
|
|
name: "valid dynamic label",
|
|
label: `
|
|
labels:
|
|
- name: ok
|
|
label_membership_type: dynamic
|
|
query: SELECT 1`,
|
|
},
|
|
{
|
|
name: "valid host_vitals label",
|
|
label: `
|
|
labels:
|
|
- name: ok
|
|
label_membership_type: host_vitals
|
|
criteria:
|
|
vital: end_user_idp_group
|
|
operator: "="
|
|
value: Engineering`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
config := getGlobalConfig([]string{})
|
|
config += tt.label
|
|
_, err := gitOpsFromString(t, config)
|
|
if len(tt.wantErrs) == 0 {
|
|
require.NoError(t, err)
|
|
} else {
|
|
require.Error(t, err)
|
|
for _, wantErr := range tt.wantErrs {
|
|
require.ErrorContains(t, err, wantErr)
|
|
}
|
|
}
|
|
for _, notWantErr := range tt.notWantErrs {
|
|
if err != nil {
|
|
assert.NotContains(t, err.Error(), notWantErr)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 TestWhitespaceOnlyTeamName(t *testing.T) {
|
|
t.Parallel()
|
|
config := getTeamConfig([]string{"name"})
|
|
config += `name: " "`
|
|
_, err := gitOpsFromString(t, config)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "team 'name' is required")
|
|
}
|
|
|
|
func TestMissingNameErrorMessages(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Empty default.yml should report the generic missing-name guidance.
|
|
defaultPath, defaultBase := createNamedFileOnTempDir(t, "default.yml", "")
|
|
_, err := GitOpsFromFile(defaultPath, defaultBase, nil, nopLogf)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "No `name` was provided")
|
|
assert.Contains(t, err.Error(), "add `org_settings:` as a top-level key.")
|
|
assert.Contains(t, err.Error(), "Otherwise, use `name` to specify the fleet name.")
|
|
|
|
// Empty no-team.yml should report the No Team name requirement.
|
|
noTeamPath, noTeamBase := createNamedFileOnTempDir(t, "no-team.yml", "")
|
|
_, err = GitOpsFromFile(noTeamPath, noTeamBase, nil, nopLogf)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "`name` must be `No Team` for `no-team.yml`")
|
|
|
|
// Empty unassigned.yml should report the Unassigned name requirement.
|
|
unassignedPath, unassignedBase := createNamedFileOnTempDir(t, "unassigned.yml", "")
|
|
_, err = GitOpsFromFile(unassignedPath, unassignedBase, nil, nopLogf)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "`name` must be `Unassigned` for `unassigned.yml`")
|
|
|
|
// Any other team file missing `name` should report the generic requirement.
|
|
teamPath, teamBase := createNamedFileOnTempDir(t, "workstations.yml", "")
|
|
_, err = GitOpsFromFile(teamPath, teamBase, nil, nopLogf)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "No `name` was provided")
|
|
assert.Contains(t, err.Error(), "add `org_settings:` as a top-level key.")
|
|
assert.Contains(t, err.Error(), "Otherwise, use `name` to specify the fleet name.")
|
|
}
|
|
|
|
func TestPaddedTeamNameIsTrimmed(t *testing.T) {
|
|
t.Parallel()
|
|
config := getTeamConfig([]string{"name"})
|
|
config += `name: " Team Name "`
|
|
gitOps, err := gitOpsFromString(t, config)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gitOps.TeamName)
|
|
require.Equal(t, "Team Name", *gitOps.TeamName)
|
|
}
|
|
|
|
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; if you intended the literal string $NOT_DEFINED then please escape it as \$NOT_DEFINED.`)
|
|
}
|
|
|
|
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 fleet.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)
|
|
require.ErrorContains(t, err, "`name` must be `No Team` for `no-team.yml`")
|
|
|
|
// 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)
|
|
require.ErrorContains(t, err, "`name` must be `Unassigned` for `unassigned.yml`")
|
|
|
|
// 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)
|
|
require.ErrorContains(t, err, "`name` must be `No Team` for `no-team.yml`")
|
|
|
|
// 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)
|
|
require.ErrorContains(t, err, "`name` must be `Unassigned` for `unassigned.yml`")
|
|
|
|
// '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 fleet.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 fleet.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, "add `org_settings:` as a top-level key")
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
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 TestGitOpsOrgLogo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// New mode-aware path keys are accepted under org_info.
|
|
t.Run("path keys accepted", func(t *testing.T) {
|
|
config := getGlobalConfig([]string{"org_settings"})
|
|
config += `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
org_logo_path_dark_mode: ./dark.png
|
|
org_logo_path_light_mode: ./light.png
|
|
secrets:
|
|
`
|
|
gitops, err := gitOpsFromString(t, config)
|
|
require.NoError(t, err)
|
|
orgInfo := gitops.OrgSettings["org_info"].(map[string]any)
|
|
assert.Equal(t, "./dark.png", orgInfo["org_logo_path_dark_mode"])
|
|
assert.Equal(t, "./light.png", orgInfo["org_logo_path_light_mode"])
|
|
})
|
|
|
|
// Setting both a path and a URL for the same mode is rejected at parse time.
|
|
t.Run("path and url mutually exclusive", func(t *testing.T) {
|
|
for _, mode := range []string{"dark", "light"} {
|
|
config := getGlobalConfig([]string{"org_settings"})
|
|
config += fmt.Sprintf(`
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
org_logo_path_%[1]s_mode: ./logo.png
|
|
org_logo_url_%[1]s_mode: https://example.com/logo.png
|
|
secrets:
|
|
`, mode)
|
|
_, err := gitOpsFromString(t, config)
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "cannot specify both")
|
|
require.ErrorContains(t, err, mode)
|
|
}
|
|
})
|
|
|
|
// Deprecated org_logo_url and org_logo_url_light_background keys are migrated to the new mode-aware names.
|
|
t.Run("deprecated URL keys are renamed", func(t *testing.T) {
|
|
config := getGlobalConfig([]string{"org_settings"})
|
|
config += `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
org_logo_url: https://example.com/dark-logo.png
|
|
org_logo_url_light_background: https://example.com/light-logo.png
|
|
secrets:
|
|
`
|
|
gitops, err := gitOpsFromString(t, config)
|
|
require.NoError(t, err)
|
|
orgInfo := gitops.OrgSettings["org_info"].(map[string]any)
|
|
assert.Equal(t, "https://example.com/dark-logo.png", orgInfo["org_logo_url_dark_mode"])
|
|
assert.Equal(t, "https://example.com/light-logo.png", orgInfo["org_logo_url_light_mode"])
|
|
_, hasOldDark := orgInfo["org_logo_url"]
|
|
_, hasOldLight := orgInfo["org_logo_url_light_background"]
|
|
assert.False(t, hasOldDark, "deprecated org_logo_url should be removed after migration")
|
|
assert.False(t, hasOldLight, "deprecated org_logo_url_light_background should be removed after migration")
|
|
})
|
|
|
|
// Setting both an old and new URL key for the same mode errors out
|
|
// (handled by the generic deprecated-key migrator).
|
|
t.Run("old + new URL keys conflict", func(t *testing.T) {
|
|
config := getGlobalConfig([]string{"org_settings"})
|
|
config += `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
org_logo_url: https://example.com/old.png
|
|
org_logo_url_dark_mode: https://example.com/new.png
|
|
secrets:
|
|
`
|
|
_, err := gitOpsFromString(t, config)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "org_logo_url")
|
|
})
|
|
}
|
|
|
|
// TestGitOpsOrgSettingsNestedPathResolution covers issue #45661: relative paths
|
|
// inside a nested org_settings file (loaded via org_settings.path) must be
|
|
// resolved against that file's directory, not the main file's baseDir.
|
|
func TestGitOpsOrgSettingsNestedPathResolution(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Build a layout where the org_settings file sits in a subdirectory and
|
|
// references assets up one level — the exact shape that fails without the fix.
|
|
setup := func(t *testing.T, orgSettingsBody string) (*GitOps, string) {
|
|
t.Helper()
|
|
tmpDir := t.TempDir()
|
|
|
|
settingsDir := filepath.Join(tmpDir, "settings")
|
|
require.NoError(t, os.MkdirAll(settingsDir, 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(settingsDir, "org.yml"),
|
|
[]byte(orgSettingsBody),
|
|
0o644,
|
|
))
|
|
|
|
mainBody := getGlobalConfig([]string{"org_settings"})
|
|
mainBody += "\norg_settings:\n path: ./settings/org.yml\n"
|
|
mainPath := filepath.Join(tmpDir, "default.yml")
|
|
require.NoError(t, os.WriteFile(mainPath, []byte(mainBody), 0o644))
|
|
|
|
gitops, err := GitOpsFromFile(mainPath, tmpDir, nil, nopLogf)
|
|
require.NoError(t, err)
|
|
return gitops, tmpDir
|
|
}
|
|
|
|
t.Run("org_logo_path keys resolve relative to nested file", func(t *testing.T) {
|
|
gitops, tmpDir := setup(t, `
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
org_logo_path_dark_mode: ../assets/dark.png
|
|
org_logo_path_light_mode: ../assets/light.png
|
|
secrets:
|
|
`)
|
|
orgInfo := gitops.OrgSettings["org_info"].(map[string]any)
|
|
assert.Equal(t, filepath.Join(tmpDir, "assets/dark.png"), orgInfo["org_logo_path_dark_mode"])
|
|
assert.Equal(t, filepath.Join(tmpDir, "assets/light.png"), orgInfo["org_logo_path_light_mode"])
|
|
})
|
|
|
|
t.Run("end_user_license_agreement resolves relative to nested file", func(t *testing.T) {
|
|
gitops, tmpDir := setup(t, `
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
mdm:
|
|
end_user_license_agreement: ../docs/eula.pdf
|
|
secrets:
|
|
`)
|
|
mdm := gitops.OrgSettings["mdm"].(map[string]any)
|
|
assert.Equal(t, filepath.Join(tmpDir, "docs/eula.pdf"), mdm["end_user_license_agreement"])
|
|
})
|
|
|
|
t.Run("absolute paths in nested file are untouched", func(t *testing.T) {
|
|
abs := "/etc/fleet/logo.png"
|
|
gitops, _ := setup(t, fmt.Sprintf(`
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
org_logo_path_dark_mode: %s
|
|
secrets:
|
|
`, abs))
|
|
orgInfo := gitops.OrgSettings["org_info"].(map[string]any)
|
|
assert.Equal(t, abs, orgInfo["org_logo_path_dark_mode"])
|
|
})
|
|
|
|
t.Run("inline org_settings unchanged", func(t *testing.T) {
|
|
// Regression guard: when org_settings is inline (no path:), values are NOT
|
|
// re-anchored at parse time — they continue through the existing
|
|
// client_appconfig.go resolution path.
|
|
config := getGlobalConfig([]string{"org_settings"})
|
|
config += `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
org_logo_path_dark_mode: ./dark.png
|
|
secrets:
|
|
`
|
|
gitops, err := gitOpsFromString(t, config)
|
|
require.NoError(t, err)
|
|
orgInfo := gitops.OrgSettings["org_info"].(map[string]any)
|
|
assert.Equal(t, "./dark.png", orgInfo["org_logo_path_dark_mode"])
|
|
})
|
|
}
|
|
|
|
// TestGitOpsControlsResolveFilePathsAbs verifies that control file paths are
|
|
// resolved to absolute paths against the no-team file's own dir. This is what
|
|
// lets relative paths (e.g. ../lib/...) survive being grafted onto the global
|
|
// config and applied under a different baseDir. The bug it guards against:
|
|
// using filepath.Join with a relative baseDir leaves the path relative, so the
|
|
// apply-side resolveApplyRelativePath re-anchors it a second time (the
|
|
// "generated/generated/..." double-anchor from issue #45661).
|
|
func TestGitOpsControlsResolveFilePathsAbs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("apply-time paths with ../ become absolute, not double-anchored", func(t *testing.T) {
|
|
controls := GitOpsControls{
|
|
MacOSSetup: &fleet.MacOSSetup{
|
|
MacOSSetupAssistant: optjson.SetString("../lib/no-team/macos_enrollment.json"),
|
|
Script: optjson.SetString("../lib/no-team/setup.sh"),
|
|
Software: optjson.SetSlice([]*fleet.MacOSSetupSoftware{
|
|
{PackagePath: "../lib/no-team/app.pkg"},
|
|
}),
|
|
},
|
|
}
|
|
|
|
// "fleets" mirrors the issue layout where unassigned.yml lives in fleets/.
|
|
controls.ResolveFilePathsAbs("fleets")
|
|
|
|
for _, got := range []string{
|
|
controls.MacOSSetup.MacOSSetupAssistant.Value,
|
|
controls.MacOSSetup.Script.Value,
|
|
controls.MacOSSetup.Software.Value[0].PackagePath,
|
|
} {
|
|
assert.True(t, filepath.IsAbs(got), "expected absolute path, got %q", got)
|
|
// ../ from fleets/ climbs out of fleets/, so the result must not contain it.
|
|
assert.NotContains(t, got, "fleets/lib", "path should not retain the fleets/lib segment: %q", got)
|
|
}
|
|
assert.True(t, strings.HasSuffix(controls.MacOSSetup.MacOSSetupAssistant.Value, "lib/no-team/macos_enrollment.json"))
|
|
})
|
|
|
|
t.Run("parse-time paths and bootstrap URL are left untouched", func(t *testing.T) {
|
|
// controls.scripts are already resolved at parse time, and bootstrap_package
|
|
// is a URL — re-anchoring either here would double-anchor / corrupt them.
|
|
scriptPath := "../lib/no-team/script.sh"
|
|
controls := GitOpsControls{
|
|
MacOSSetup: &fleet.MacOSSetup{
|
|
BootstrapPackage: optjson.SetString("https://example.com/bootstrap.pkg"),
|
|
},
|
|
Scripts: []fleet.BaseItem{{Path: &scriptPath}},
|
|
}
|
|
|
|
controls.ResolveFilePathsAbs("fleets")
|
|
|
|
assert.Equal(t, "https://example.com/bootstrap.pkg", controls.MacOSSetup.BootstrapPackage.Value)
|
|
assert.Equal(t, "../lib/no-team/script.sh", *controls.Scripts[0].Path)
|
|
})
|
|
|
|
t.Run("same-dir relative path is anchored exactly once", func(t *testing.T) {
|
|
// Reproduces the user's error: default.yml and no-team.yml both in generated/,
|
|
// path lib/no-team/... — before the fix this double-anchored to
|
|
// generated/generated/lib/no-team/macos_enrollment.json at apply time.
|
|
controls := GitOpsControls{
|
|
MacOSSetup: &fleet.MacOSSetup{
|
|
MacOSSetupAssistant: optjson.SetString("lib/no-team/macos_enrollment.json"),
|
|
},
|
|
}
|
|
controls.ResolveFilePathsAbs("generated")
|
|
|
|
got := controls.MacOSSetup.MacOSSetupAssistant.Value
|
|
assert.True(t, filepath.IsAbs(got), "expected absolute path, got %q", got)
|
|
assert.True(t, strings.HasSuffix(got, "generated/lib/no-team/macos_enrollment.json"), "got %q", got)
|
|
assert.Equal(t, 1, strings.Count(got, "generated/lib"), "the generated/ segment must appear once, not be double-anchored: %q", got)
|
|
})
|
|
|
|
t.Run("absolute and empty paths are untouched", func(t *testing.T) {
|
|
controls := GitOpsControls{
|
|
MacOSSetup: &fleet.MacOSSetup{
|
|
MacOSSetupAssistant: optjson.SetString("/etc/fleet/macos_enrollment.json"),
|
|
Script: optjson.SetString(""),
|
|
},
|
|
}
|
|
controls.ResolveFilePathsAbs("fleets")
|
|
assert.Equal(t, "/etc/fleet/macos_enrollment.json", controls.MacOSSetup.MacOSSetupAssistant.Value)
|
|
assert.Empty(t, controls.MacOSSetup.Script.Value)
|
|
})
|
|
|
|
t.Run("nil MacOSSetup is a no-op", func(t *testing.T) {
|
|
controls := GitOpsControls{}
|
|
assert.NotPanics(t, func() { controls.ResolveFilePathsAbs("fleets") })
|
|
})
|
|
}
|
|
|
|
// TestGitOpsModeYaml exercises the parse-time validator for the
|
|
// `org_settings.gitops` block: rejects `exceptions`, enforces the
|
|
// repository_url requirement and scheme rules, and accepts well-formed
|
|
// inputs.
|
|
func TestGitOpsModeYaml(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
withGitops := func(body string) string {
|
|
config := getGlobalConfig([]string{"org_settings"})
|
|
config += `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
secrets:
|
|
gitops:
|
|
` + body
|
|
return config
|
|
}
|
|
|
|
t.Run("https URL with mode enabled is accepted", func(t *testing.T) {
|
|
config := withGitops(" gitops_mode_enabled: true\n repository_url: https://github.com/example/fleet-config\n")
|
|
gitops, err := gitOpsFromString(t, config)
|
|
require.NoError(t, err)
|
|
gitopsBlock, _ := gitops.OrgSettings["gitops"].(map[string]any)
|
|
require.NotNil(t, gitopsBlock)
|
|
assert.Equal(t, true, gitopsBlock["gitops_mode_enabled"])
|
|
assert.Equal(t, "https://github.com/example/fleet-config", gitopsBlock["repository_url"])
|
|
})
|
|
|
|
t.Run("http URL with mode enabled is accepted", func(t *testing.T) {
|
|
config := withGitops(" gitops_mode_enabled: true\n repository_url: http://internal.example.com/fleet-config\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("mode enabled without repository_url is rejected", func(t *testing.T) {
|
|
config := withGitops(" gitops_mode_enabled: true\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "repository_url is required when gitops_mode_enabled is true")
|
|
})
|
|
|
|
t.Run("mode enabled with empty repository_url is rejected", func(t *testing.T) {
|
|
config := withGitops(" gitops_mode_enabled: true\n repository_url: \"\"\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "repository_url is required when gitops_mode_enabled is true")
|
|
})
|
|
|
|
t.Run("repository_url missing scheme is rejected", func(t *testing.T) {
|
|
config := withGitops(" gitops_mode_enabled: true\n repository_url: github.com/example/fleet-config\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "must include protocol")
|
|
})
|
|
|
|
t.Run("exceptions block is rejected", func(t *testing.T) {
|
|
config := withGitops(" exceptions:\n labels: true\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "org_settings.gitops.exceptions is not supported via GitOps")
|
|
})
|
|
|
|
t.Run("absent gitops block is accepted", func(t *testing.T) {
|
|
config := getGlobalConfig(nil)
|
|
_, err := gitOpsFromString(t, config)
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
func TestGitOpsSSOValidation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
withSSO := func(body string) string {
|
|
config := getGlobalConfig([]string{"org_settings"})
|
|
config += `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
secrets:
|
|
sso_settings:
|
|
` + body
|
|
return config
|
|
}
|
|
|
|
t.Run("sso_settings absent is accepted", func(t *testing.T) {
|
|
config := getGlobalConfig(nil)
|
|
_, err := gitOpsFromString(t, config)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("enable_sso true with both metadata fields empty is rejected", func(t *testing.T) {
|
|
config := withSSO(" enable_sso: true\n idp_name: Okta\n entity_id: https://example.com\n metadata: \"\"\n metadata_url: \"\"\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "When org_settings.sso_settings.enable_sso is true, either metadata or metadata_url must be set")
|
|
})
|
|
|
|
t.Run("enable_sso true with both metadata fields absent is rejected", func(t *testing.T) {
|
|
config := withSSO(" enable_sso: true\n idp_name: Okta\n entity_id: https://example.com\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "When org_settings.sso_settings.enable_sso is true, either metadata or metadata_url must be set")
|
|
})
|
|
|
|
t.Run("enable_sso true with metadata only is accepted", func(t *testing.T) {
|
|
config := withSSO(" enable_sso: true\n idp_name: Okta\n entity_id: https://example.com\n metadata: \"<xml/>\"\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("enable_sso true with metadata_url only is accepted", func(t *testing.T) {
|
|
config := withSSO(" enable_sso: true\n idp_name: Okta\n entity_id: https://example.com\n metadata_url: https://idp.example.com/metadata\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("enable_sso false with empty metadata is accepted", func(t *testing.T) {
|
|
config := withSSO(" enable_sso: false\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("enable_sso true with empty idp_name is rejected", func(t *testing.T) {
|
|
config := withSSO(" enable_sso: true\n idp_name: \"\"\n entity_id: https://example.com\n metadata_url: https://idp.example.com/metadata\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "When org_settings.sso_settings.enable_sso is true, idp_name must be set")
|
|
})
|
|
|
|
t.Run("enable_sso true with empty entity_id is rejected", func(t *testing.T) {
|
|
config := withSSO(" enable_sso: true\n idp_name: Okta\n entity_id: \"\"\n metadata_url: https://idp.example.com/metadata\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "When org_settings.sso_settings.enable_sso is true, entity_id must be set")
|
|
})
|
|
|
|
t.Run("enable_sso true missing idp_name and entity_id reports both", func(t *testing.T) {
|
|
config := withSSO(" enable_sso: true\n metadata_url: https://idp.example.com/metadata\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "idp_name must be set")
|
|
require.ErrorContains(t, err, "entity_id must be set")
|
|
})
|
|
|
|
// Note: MDM end-user authentication validation lives at the gitops
|
|
// CLI group level (cmd/fleetctl/fleetctl/gitops.go), not here in the
|
|
// per-file parser, because the enable flag and the IdP settings can
|
|
// live in different files (controls in team files, org_settings in
|
|
// the global file only). See TestValidateGitOpsGroupEUA.
|
|
|
|
// Regression guard: the YAML that `fleetctl generate-gitops` produces
|
|
// renders the metadata fields as `metadata: # TODO: ...` (a YAML key with
|
|
// a trailing comment, which parses to nil/empty). This is exactly the
|
|
// state that nearly locked out the reporter in issue #43371 — and is
|
|
// what the validation here is designed to catch. If this test ever
|
|
// stops failing, the safety net is gone.
|
|
t.Run("generate-gitops rendered TODO comment form is rejected", func(t *testing.T) {
|
|
config := withSSO(" enable_sso: true\n idp_name: Okta\n entity_id: https://example.com\n metadata: # TODO: Add your SSO metadata here\n metadata_url: # TODO: Add your SSO metadata URL here\n")
|
|
_, err := gitOpsFromString(t, config)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "When org_settings.sso_settings.enable_sso is true, either metadata or metadata_url must be set")
|
|
})
|
|
}
|
|
|
|
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, a hash_sha256 or a fleet_maintained_app_slug")
|
|
|
|
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, app_store_id, hash_sha256 or fleet_maintained_app_slug")
|
|
|
|
// 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 TestMultiPackageFieldPlacement(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const hashA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
const hashB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
|
|
|
// writeConfig writes the fleet-level file plus a package YAML file and returns
|
|
// the parsed result (or error).
|
|
setup := func(t *testing.T, fleetLevel string, packageFile string) (*GitOps, error) {
|
|
config := getTeamConfig([]string{"software"})
|
|
config += "software:\n packages:\n - path: software/pkgs.yml\n" + fleetLevel
|
|
path, basePath := createTempFile(t, "", config)
|
|
require.NoError(t, os.MkdirAll(filepath.Join(basePath, "software"), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(basePath, "software", "pkgs.yml"), []byte(packageFile), 0o644))
|
|
return GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf)
|
|
}
|
|
|
|
t.Run("happy path keeps per-package fields and inherits fleet-level setup_experience", func(t *testing.T) {
|
|
gitops, err := setup(t,
|
|
" setup_experience: true\n",
|
|
fmt.Sprintf(`- hash_sha256: %s
|
|
self_service: true
|
|
labels_include_all: [macOS]
|
|
- hash_sha256: %s
|
|
categories: ["Productivity"]
|
|
labels_include_all: [macOS, IT team]
|
|
`, hashA, hashB),
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, gitops.Software.Packages, 2)
|
|
|
|
first := gitops.Software.Packages[0]
|
|
assert.True(t, first.SelfService)
|
|
assert.Equal(t, []string{"macOS"}, first.LabelsIncludeAll)
|
|
assert.True(t, first.InstallDuringSetup.Valid && first.InstallDuringSetup.Value)
|
|
|
|
second := gitops.Software.Packages[1]
|
|
assert.False(t, second.SelfService)
|
|
assert.Equal(t, []string{"Productivity"}, second.Categories.Value)
|
|
assert.Equal(t, []string{"macOS", "IT team"}, second.LabelsIncludeAll)
|
|
assert.True(t, second.InstallDuringSetup.Valid && second.InstallDuringSetup.Value)
|
|
})
|
|
|
|
// self_service and categories set once at the fleet level apply to every package
|
|
// that omits them.
|
|
t.Run("fleet-level self_service and categories inherit to all packages", func(t *testing.T) {
|
|
gitops, err := setup(t,
|
|
" self_service: true\n categories: [\"Productivity\"]\n",
|
|
fmt.Sprintf(`- hash_sha256: %s
|
|
- hash_sha256: %s
|
|
`, hashA, hashB),
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, gitops.Software.Packages, 2)
|
|
for _, pkg := range gitops.Software.Packages {
|
|
assert.True(t, pkg.SelfService)
|
|
assert.Equal(t, []string{"Productivity"}, pkg.Categories.Value)
|
|
}
|
|
})
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
fleetLevel string
|
|
packageFile string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "self_service in both fleet-level and package file",
|
|
fleetLevel: " self_service: true\n",
|
|
packageFile: fmt.Sprintf(`- hash_sha256: %s
|
|
self_service: true
|
|
- hash_sha256: %s
|
|
`, hashA, hashB),
|
|
wantErr: "self_service and categories can be specified either in the fleet-level file or in the package YAML file",
|
|
},
|
|
{
|
|
name: "setup_experience in a package file",
|
|
fleetLevel: "",
|
|
packageFile: fmt.Sprintf(`- hash_sha256: %s
|
|
setup_experience: true
|
|
- hash_sha256: %s
|
|
`, hashA, hashB),
|
|
wantErr: "setup_experience can be specified only in the fleet-level file",
|
|
},
|
|
{
|
|
name: "labels in the fleet-level file",
|
|
fleetLevel: " labels_include_all: [macOS]\n",
|
|
packageFile: fmt.Sprintf(`- hash_sha256: %s
|
|
- hash_sha256: %s
|
|
`, hashA, hashB),
|
|
wantErr: "Labels can be specified only in the package-level file when adding multiple packages",
|
|
},
|
|
{
|
|
name: "categories in both fleet-level and package file",
|
|
fleetLevel: " categories: [\"Productivity\"]\n",
|
|
packageFile: fmt.Sprintf(`- hash_sha256: %s
|
|
categories: ["Dev tools"]
|
|
- hash_sha256: %s
|
|
`, hashA, hashB),
|
|
wantErr: "self_service and categories can be specified either in the fleet-level file or in the package YAML file",
|
|
},
|
|
{
|
|
name: "self_service in both, single package",
|
|
fleetLevel: " self_service: true\n",
|
|
packageFile: fmt.Sprintf(`- hash_sha256: %s
|
|
self_service: true
|
|
`, hashA),
|
|
wantErr: "self_service and categories can be specified either in the fleet-level file or in the package YAML file",
|
|
},
|
|
{
|
|
name: "labels in both fleet-level and package file, single package",
|
|
fleetLevel: " labels_include_all: [macOS]\n",
|
|
packageFile: fmt.Sprintf(`- hash_sha256: %s
|
|
labels_include_all: [Windows]
|
|
`, hashA),
|
|
wantErr: "Labels can be specified either in the fleet-level file or in the package YAML file",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := setup(t, tc.fleetLevel, tc.packageFile)
|
|
assert.ErrorContains(t, err, tc.wantErr)
|
|
})
|
|
}
|
|
|
|
// The setup_experience and fleet-level label rules only apply to a file with
|
|
// multiple packages. A single package can set setup_experience in the file and
|
|
// inherit labels from the fleet-level entry.
|
|
t.Run("single package may set setup_experience and inherit fleet-level labels", func(t *testing.T) {
|
|
gitops, err := setup(t,
|
|
" labels_include_all: [macOS]\n",
|
|
fmt.Sprintf(`- hash_sha256: %s
|
|
setup_experience: true
|
|
`, hashA),
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, gitops.Software.Packages, 1)
|
|
pkg := gitops.Software.Packages[0]
|
|
assert.True(t, pkg.InstallDuringSetup.Valid && pkg.InstallDuringSetup.Value)
|
|
assert.Equal(t, []string{"macOS"}, pkg.LabelsIncludeAll)
|
|
})
|
|
|
|
// A package file written as a single object is just a one-element list, so its
|
|
// per-package fields are preserved the same way.
|
|
t.Run("single object file keeps its per-package fields", func(t *testing.T) {
|
|
gitops, err := setup(t, "", fmt.Sprintf(`hash_sha256: %s
|
|
self_service: true
|
|
labels_include_all: [macOS]
|
|
`, hashA))
|
|
require.NoError(t, err)
|
|
require.Len(t, gitops.Software.Packages, 1)
|
|
assert.True(t, gitops.Software.Packages[0].SelfService)
|
|
assert.Equal(t, []string{"macOS"}, gitops.Software.Packages[0].LabelsIncludeAll)
|
|
})
|
|
|
|
// A hash-only package (no URL) is identified by its hash, not an empty string.
|
|
t.Run("conflict error identifies a hash-only package by its hash", func(t *testing.T) {
|
|
_, err := setup(t,
|
|
" self_service: true\n",
|
|
fmt.Sprintf(`- hash_sha256: %s
|
|
self_service: true
|
|
- hash_sha256: %s
|
|
`, hashA, hashB),
|
|
)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), hashA)
|
|
assert.NotContains(t, err.Error(), `("")`)
|
|
})
|
|
|
|
// When a package has neither url nor hash, it is identified by the package file path
|
|
// rather than an empty string (url/hash are required but validated later).
|
|
t.Run("conflict error falls back to the file path when url and hash are absent", func(t *testing.T) {
|
|
_, err := setup(t,
|
|
" self_service: true\n",
|
|
fmt.Sprintf(`- self_service: true
|
|
- hash_sha256: %s
|
|
`, hashA),
|
|
)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "pkgs.yml")
|
|
assert.NotContains(t, err.Error(), `("")`)
|
|
})
|
|
|
|
// The fleet-level labels rule is file-scope, so it reports once regardless of how
|
|
// many packages the file lists.
|
|
t.Run("labels error is reported once for multiple packages", func(t *testing.T) {
|
|
_, err := setup(t,
|
|
" labels_include_all: [macOS]\n",
|
|
fmt.Sprintf(`- hash_sha256: %s
|
|
- hash_sha256: %s
|
|
- hash_sha256: %s
|
|
`, hashA, hashB, hashA[:63]+"c"),
|
|
)
|
|
require.Error(t, err)
|
|
assert.Equal(t, 1, strings.Count(err.Error(), "Labels can be specified only"))
|
|
})
|
|
}
|
|
|
|
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 TestScriptOnlyPackagesPathWithInline(t *testing.T) {
|
|
t.Parallel()
|
|
config := getTeamConfig([]string{"software"})
|
|
config += `
|
|
software:
|
|
packages:
|
|
- path: software/script-only.sh
|
|
icon:
|
|
path: ./foo/bar.png
|
|
`
|
|
|
|
path, basePath := createTempFile(t, "", config)
|
|
|
|
err := file.Copy(
|
|
filepath.Join("testdata", "software", "script-only.sh"),
|
|
filepath.Join(basePath, "software", "script-only.sh"),
|
|
os.FileMode(0o755),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
appConfig := fleet.EnrichedAppConfig{}
|
|
appConfig.License = &fleet.LicenseInfo{
|
|
Tier: fleet.TierPremium,
|
|
}
|
|
gitops, err := GitOpsFromFile(path, basePath, &appConfig, nopLogf)
|
|
require.NoError(t, err)
|
|
require.Len(t, gitops.Software.Packages, 1)
|
|
assert.Equal(t, filepath.Join(basePath, "foo", "bar.png"), gitops.Software.Packages[0].Icon.Path)
|
|
}
|
|
|
|
func TestScriptOnlyPackagesWithAdvancedOptions(t *testing.T) {
|
|
t.Parallel()
|
|
config := getTeamConfig([]string{"software"})
|
|
config += `
|
|
software:
|
|
packages:
|
|
- path: software/script-only.sh
|
|
self_service: true
|
|
uninstall_script:
|
|
path: software/uninstall.sh
|
|
post_install_script:
|
|
path: software/post-install.sh
|
|
pre_install_query:
|
|
path: software/preinstall-query.yml
|
|
`
|
|
|
|
path, basePath := createTempFile(t, "", config)
|
|
|
|
// The .sh file's contents become the install script; the sibling scripts and
|
|
// query are specified inline in the team YAML for script-only packages.
|
|
copies := []struct{ src, dst string }{
|
|
{filepath.Join("testdata", "software", "script-only.sh"), filepath.Join(basePath, "software", "script-only.sh")},
|
|
{filepath.Join("testdata", "software", "install-app.sh"), filepath.Join(basePath, "software", "uninstall.sh")},
|
|
{filepath.Join("testdata", "software", "install-app.sh"), filepath.Join(basePath, "software", "post-install.sh")},
|
|
}
|
|
for _, c := range copies {
|
|
require.NoError(t, file.Copy(c.src, c.dst, os.FileMode(0o755)))
|
|
}
|
|
require.NoError(t, file.Copy(
|
|
filepath.Join("testdata", "lib", "preinstall-query.yml"),
|
|
filepath.Join(basePath, "software", "preinstall-query.yml"),
|
|
os.FileMode(0o644),
|
|
))
|
|
|
|
appConfig := fleet.EnrichedAppConfig{}
|
|
appConfig.License = &fleet.LicenseInfo{
|
|
Tier: fleet.TierPremium,
|
|
}
|
|
gitops, err := GitOpsFromFile(path, basePath, &appConfig, nopLogf)
|
|
require.NoError(t, err)
|
|
require.Len(t, gitops.Software.Packages, 1)
|
|
|
|
pkg := gitops.Software.Packages[0]
|
|
assert.Equal(t, filepath.Join(basePath, "software", "script-only.sh"), pkg.InstallScript.Path)
|
|
assert.Equal(t, filepath.Join(basePath, "software", "uninstall.sh"), pkg.UninstallScript.Path)
|
|
assert.Equal(t, filepath.Join(basePath, "software", "post-install.sh"), pkg.PostInstallScript.Path)
|
|
assert.Equal(t, filepath.Join(basePath, "software", "preinstall-query.yml"), pkg.PreInstallQuery.Path)
|
|
}
|
|
|
|
func TestScriptOnlyPackageRejectsURLAtTeamLevel(t *testing.T) {
|
|
t.Parallel()
|
|
config := getTeamConfig([]string{"software"})
|
|
config += `
|
|
software:
|
|
packages:
|
|
- path: software/script-only.sh
|
|
url: https://example.com/script-only.sh
|
|
`
|
|
|
|
path, basePath := createTempFile(t, "", config)
|
|
require.NoError(t, file.Copy(
|
|
filepath.Join("testdata", "software", "script-only.sh"),
|
|
filepath.Join(basePath, "software", "script-only.sh"),
|
|
os.FileMode(0o755),
|
|
))
|
|
|
|
appConfig := fleet.EnrichedAppConfig{}
|
|
appConfig.License = &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
_, err := GitOpsFromFile(path, basePath, &appConfig, nopLogf)
|
|
// The message must not claim scripts/queries are forbidden — they're allowed
|
|
// for script-only packages.
|
|
require.ErrorContains(t, err, "must not have install_script, 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 TestExpandBaseItems(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.yml"), []byte(""), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "b.yml"), []byte(""), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "c.txt"), []byte(""), 0o644))
|
|
|
|
items := []fleet.BaseItem{{Paths: ptr.String("*.yml")}} //nolint:modernize
|
|
result, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{})
|
|
require.Empty(t, errs)
|
|
require.Len(t, result, 2)
|
|
assert.Equal(t, filepath.Join(dir, "a.yml"), *result[0].Path)
|
|
assert.Equal(t, filepath.Join(dir, "b.yml"), *result[1].Path)
|
|
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.yml"), []byte(""), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(subdir, "nested.yml"), []byte(""), 0o644))
|
|
|
|
items := []fleet.BaseItem{{Paths: ptr.String("**/*.yml")}} //nolint:modernize
|
|
result, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{})
|
|
require.Empty(t, errs)
|
|
require.Len(t, result, 2)
|
|
assert.Equal(t, filepath.Join(subdir, "nested.yml"), *result[0].Path)
|
|
assert.Equal(t, filepath.Join(dir, "top.yml"), *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.yml"), []byte(""), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "glob1.yaml"), []byte(""), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "glob2.yaml"), []byte(""), 0o644))
|
|
|
|
items := []fleet.BaseItem{
|
|
{Path: ptr.String("single.yml")}, //nolint:modernize
|
|
{Paths: ptr.String("*.yaml")}, //nolint:modernize
|
|
}
|
|
result, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{})
|
|
require.Empty(t, errs)
|
|
require.Len(t, result, 3)
|
|
assert.Equal(t, filepath.Join(dir, "single.yml"), *result[0].Path)
|
|
assert.Equal(t, filepath.Join(dir, "glob1.yaml"), *result[1].Path)
|
|
assert.Equal(t, filepath.Join(dir, "glob2.yaml"), *result[2].Path)
|
|
})
|
|
|
|
t.Run("paths_without_glob_error", func(t *testing.T) {
|
|
t.Parallel()
|
|
items := []fleet.BaseItem{{Paths: ptr.String("foo.yml")}} //nolint:modernize
|
|
_, errs := expandBaseItems(items, "/tmp", "test", GlobExpandOptions{})
|
|
requireErrorContains(t, errs, `does not contain glob characters`)
|
|
})
|
|
|
|
t.Run("path_with_glob_error", func(t *testing.T) {
|
|
t.Parallel()
|
|
items := []fleet.BaseItem{{Path: ptr.String("*.yml")}} //nolint:modernize
|
|
_, errs := expandBaseItems(items, "/tmp", "test", GlobExpandOptions{})
|
|
requireErrorContains(t, errs, `contains glob characters`)
|
|
})
|
|
|
|
// Filenames containing glob metacharacters should be accepted by "path:"
|
|
// when the literal file exists on disk. Common with Windows MDM CSP
|
|
// profile names like "[AllowSpotlightCollection].xml". Regression test for
|
|
// fleetdm/fleet#43598.
|
|
t.Run("path_with_literal_glob_meta_chars_existing_file", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
filenames := []string{
|
|
"AllowRebootless -[Updates].xml",
|
|
"profile{a,b}.xml",
|
|
}
|
|
for _, name := range filenames {
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(""), 0o644))
|
|
}
|
|
|
|
items := make([]fleet.BaseItem, 0, len(filenames))
|
|
for _, name := range filenames {
|
|
items = append(items, fleet.BaseItem{Path: ptr.String(name)}) //nolint:modernize
|
|
}
|
|
result, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{})
|
|
require.Empty(t, errs)
|
|
require.Len(t, result, len(filenames))
|
|
for i, name := range filenames {
|
|
assert.Equal(t, filepath.Join(dir, name), *result[i].Path)
|
|
}
|
|
})
|
|
|
|
t.Run("path_with_glob_meta_chars_missing_file_error", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
items := []fleet.BaseItem{{Path: ptr.String("does-not-[exist].xml")}} //nolint:modernize
|
|
_, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{})
|
|
requireErrorContains(t, errs, `contains glob characters`)
|
|
})
|
|
|
|
t.Run("both_path_and_paths_error", func(t *testing.T) {
|
|
t.Parallel()
|
|
items := []fleet.BaseItem{{Path: ptr.String("foo.yml"), Paths: ptr.String("*.yml")}} //nolint:modernize
|
|
_, errs := expandBaseItems(items, "/tmp", "test", GlobExpandOptions{})
|
|
requireErrorContains(t, errs, `cannot have both "path" and "paths"`)
|
|
})
|
|
|
|
t.Run("inline_items_passed_through", func(t *testing.T) {
|
|
t.Parallel()
|
|
items := []fleet.BaseItem{{}}
|
|
result, errs := expandBaseItems(items, "/tmp", "test", GlobExpandOptions{})
|
|
require.Empty(t, errs)
|
|
require.Len(t, result, 1)
|
|
assert.Nil(t, result[0].Path)
|
|
assert.Nil(t, result[0].Paths)
|
|
})
|
|
|
|
t.Run("require_file_reference_error", func(t *testing.T) {
|
|
t.Parallel()
|
|
items := []fleet.BaseItem{{}}
|
|
_, errs := expandBaseItems(items, "/tmp", "test", GlobExpandOptions{
|
|
RequireFileReference: true,
|
|
})
|
|
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 := []fleet.BaseItem{{Paths: ptr.String("*.yml")}} //nolint:modernize
|
|
result, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{LogFn: logFn})
|
|
require.Empty(t, errs)
|
|
assert.Empty(t, result)
|
|
require.Len(t, warnings, 1)
|
|
assert.Contains(t, warnings[0], "matched no test")
|
|
})
|
|
|
|
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.yml"), []byte(""), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(sub2, "dup.yml"), []byte(""), 0o644))
|
|
|
|
items := []fleet.BaseItem{{Paths: ptr.String("**/*.yml")}} //nolint:modernize
|
|
_, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{
|
|
RequireUniqueBasenames: true,
|
|
})
|
|
requireErrorContains(t, errs, "duplicate test 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, "item.yml"), []byte(""), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(sub, "item.yml"), []byte(""), 0o644))
|
|
|
|
items := []fleet.BaseItem{
|
|
{Path: ptr.String("item.yml")}, //nolint:modernize
|
|
{Paths: ptr.String("sub/*.yml")}, //nolint:modernize
|
|
}
|
|
_, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{
|
|
RequireUniqueBasenames: true,
|
|
})
|
|
requireErrorContains(t, errs, `duplicate test basename "item.yml"`)
|
|
requireErrorContains(t, errs, `sub/*.yml`)
|
|
})
|
|
|
|
t.Run("allowed_extensions_filter", 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 := []fleet.BaseItem{{Paths: ptr.String("*")}} //nolint:modernize
|
|
result, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{
|
|
AllowedExtensions: map[string]bool{".sh": true},
|
|
LogFn: 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)
|
|
})
|
|
|
|
t.Run("results_sorted", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "z.yml"), []byte(""), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "a.yml"), []byte(""), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "m.yml"), []byte(""), 0o644))
|
|
|
|
items := []fleet.BaseItem{{Paths: ptr.String("*.yml")}} //nolint:modernize
|
|
result, errs := expandBaseItems(items, dir, "test", GlobExpandOptions{})
|
|
require.Empty(t, errs)
|
|
require.Len(t, result, 3)
|
|
assert.Equal(t, filepath.Join(dir, "a.yml"), *result[0].Path)
|
|
assert.Equal(t, filepath.Join(dir, "m.yml"), *result[1].Path)
|
|
assert.Equal(t, filepath.Join(dir, "z.yml"), *result[2].Path)
|
|
})
|
|
|
|
t.Run("multiple_errors_collected", func(t *testing.T) {
|
|
t.Parallel()
|
|
items := []fleet.BaseItem{{Path: ptr.String("*.yml")}, {Paths: ptr.String("noglob.yml")}} //nolint:modernize
|
|
_, errs := expandBaseItems(items, "", "test", GlobExpandOptions{})
|
|
require.Len(t, errs, 2)
|
|
assert.Contains(t, errs[0].Error(), `contains glob characters`)
|
|
assert.Contains(t, errs[1].Error(), `does not contain glob characters`)
|
|
})
|
|
}
|
|
|
|
func TestResolveScriptPaths(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("path_resolves", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "script.sh"), []byte("#!/bin/bash"), 0o644))
|
|
|
|
items := []fleet.BaseItem{{Path: ptr.String("script.sh")}} //nolint:modernize
|
|
result, errs := resolveScriptPaths(items, dir, nopLogf)
|
|
require.Empty(t, errs)
|
|
require.Len(t, result, 1)
|
|
assert.Equal(t, filepath.Join(dir, "script.sh"), *result[0].Path)
|
|
})
|
|
|
|
t.Run("glob_expands", 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))
|
|
|
|
items := []fleet.BaseItem{{Paths: ptr.String("*.sh")}} //nolint:modernize
|
|
result, errs := resolveScriptPaths(items, dir, nopLogf)
|
|
require.Empty(t, errs)
|
|
require.Len(t, result, 2)
|
|
})
|
|
|
|
t.Run("glob_filters_non_script_extensions", 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.ps1"), []byte("Write-Host"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "c.py"), []byte("#!/usr/bin/env python3"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "d.txt"), []byte("not a script"), 0o644))
|
|
|
|
items := []fleet.BaseItem{{Paths: ptr.String("*")}} //nolint:modernize
|
|
result, errs := resolveScriptPaths(items, dir, nopLogf)
|
|
require.Empty(t, errs)
|
|
require.Len(t, result, 3)
|
|
got := make([]string, 0, len(result))
|
|
for _, r := range result {
|
|
got = append(got, filepath.Base(*r.Path))
|
|
}
|
|
assert.Equal(t, []string{"a.sh", "b.ps1", "c.py"}, got)
|
|
})
|
|
|
|
t.Run("inline_not_allowed", func(t *testing.T) {
|
|
t.Parallel()
|
|
items := []fleet.BaseItem{{}}
|
|
_, errs := resolveScriptPaths(items, "/tmp", nopLogf)
|
|
require.NotEmpty(t, errs)
|
|
assert.Contains(t, errs[0].Error(), `no "path" or "paths" field`)
|
|
})
|
|
}
|
|
|
|
func TestParseLabelsGlob(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("inline_and_path", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
// Write a label file referenced by path.
|
|
labelFile := filepath.Join(dir, "labels", "from-file.yml")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(labelFile), 0o755))
|
|
require.NoError(t, os.WriteFile(labelFile, []byte("- name: FileLabel\n label_membership_type: manual\n"), 0o644))
|
|
|
|
top := yamlToRawJSON(t, `
|
|
labels:
|
|
- name: InlineLabel
|
|
label_membership_type: manual
|
|
- path: labels/from-file.yml
|
|
`)
|
|
result := &GitOps{}
|
|
multiErr := parseLabels(top, result, dir, nopLogf, "test.yml", nil)
|
|
require.Nil(t, multiErr.ErrorOrNil())
|
|
require.Len(t, result.Labels, 2)
|
|
assert.Equal(t, "InlineLabel", result.Labels[0].Name)
|
|
assert.Equal(t, "FileLabel", result.Labels[1].Name)
|
|
})
|
|
|
|
t.Run("glob_expands", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
labelsDir := filepath.Join(dir, "labels")
|
|
require.NoError(t, os.MkdirAll(labelsDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(labelsDir, "a.yml"), []byte("- name: LabelA\n label_membership_type: manual\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(labelsDir, "b.yml"), []byte("- name: LabelB\n label_membership_type: manual\n"), 0o644))
|
|
|
|
top := yamlToRawJSON(t, `
|
|
labels:
|
|
- paths: "labels/*.yml"
|
|
`)
|
|
result := &GitOps{}
|
|
multiErr := parseLabels(top, result, dir, nopLogf, "test.yml", nil)
|
|
require.Nil(t, multiErr.ErrorOrNil())
|
|
require.Len(t, result.Labels, 2)
|
|
assert.Equal(t, "LabelA", result.Labels[0].Name)
|
|
assert.Equal(t, "LabelB", result.Labels[1].Name)
|
|
})
|
|
}
|
|
|
|
func TestLabelsIgnoredInNoTeamFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("no-team.yml", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
config := "name: No team\nlabels:\n - name: test-label\n query: \"SELECT 1;\"\n description: test\nsoftware:\npolicies:\n"
|
|
noTeamPath, noTeamBasePath := createNamedFileOnTempDir(t, "no-team.yml", config)
|
|
|
|
var logMessages []string
|
|
captureLogf := func(format string, a ...any) {
|
|
logMessages = append(logMessages, fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
gitops, err := GitOpsFromFile(noTeamPath, noTeamBasePath, nil, captureLogf)
|
|
require.NoError(t, err)
|
|
|
|
// LabelsPresent should be true (the key was in the YAML), but labels should not be parsed.
|
|
assert.True(t, gitops.LabelsPresent, "labels should be marked as present when explicitly set in no-team file")
|
|
assert.Empty(t, gitops.Labels, "labels should not be parsed in no-team file")
|
|
|
|
// A warning should have been logged.
|
|
assert.Contains(t, strings.Join(logMessages, "\n"), "'labels' is not supported in no-team.yml")
|
|
})
|
|
|
|
t.Run("unassigned.yml", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
config := "name: Unassigned\nlabels:\n - name: test-label\n query: \"SELECT 1;\"\n description: test\nsoftware:\npolicies:\n"
|
|
unassignedPath, unassignedBasePath := createNamedFileOnTempDir(t, "unassigned.yml", config)
|
|
|
|
var logMessages []string
|
|
captureLogf := func(format string, a ...any) {
|
|
logMessages = append(logMessages, fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
gitops, err := GitOpsFromFile(unassignedPath, unassignedBasePath, nil, captureLogf)
|
|
require.NoError(t, err)
|
|
|
|
assert.True(t, gitops.LabelsPresent, "labels should be marked as present when explicitly set in unassigned file")
|
|
assert.Empty(t, gitops.Labels, "labels should not be parsed in unassigned file")
|
|
|
|
assert.Contains(t, strings.Join(logMessages, "\n"), "'labels' is not supported in unassigned.yml")
|
|
})
|
|
}
|
|
|
|
func TestParsePoliciesGlob(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("inline_and_path", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
policyFile := filepath.Join(dir, "policies", "from-file.yml")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(policyFile), 0o755))
|
|
require.NoError(t, os.WriteFile(policyFile, []byte("- name: FilePolicy\n query: SELECT 1;\n"), 0o644))
|
|
|
|
top := yamlToRawJSON(t, `
|
|
policies:
|
|
- name: InlinePolicy
|
|
query: SELECT 1;
|
|
- path: policies/from-file.yml
|
|
`)
|
|
result := &GitOps{}
|
|
multiErr := parsePolicies(top, result, dir, nopLogf, "test.yml", nil)
|
|
require.Nil(t, multiErr.ErrorOrNil())
|
|
require.Len(t, result.Policies, 2)
|
|
assert.Equal(t, "InlinePolicy", result.Policies[0].Name)
|
|
assert.Equal(t, "FilePolicy", result.Policies[1].Name)
|
|
})
|
|
|
|
t.Run("glob_expands", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
policiesDir := filepath.Join(dir, "policies")
|
|
require.NoError(t, os.MkdirAll(policiesDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(policiesDir, "a.yml"), []byte("- name: PolicyA\n query: SELECT 1;\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(policiesDir, "b.yml"), []byte("- name: PolicyB\n query: SELECT 1;\n"), 0o644))
|
|
|
|
top := yamlToRawJSON(t, `
|
|
policies:
|
|
- paths: "policies/*.yml"
|
|
`)
|
|
result := &GitOps{}
|
|
multiErr := parsePolicies(top, result, dir, nopLogf, "test.yml", nil)
|
|
require.Nil(t, multiErr.ErrorOrNil())
|
|
require.Len(t, result.Policies, 2)
|
|
assert.Equal(t, "PolicyA", result.Policies[0].Name)
|
|
assert.Equal(t, "PolicyB", result.Policies[1].Name)
|
|
})
|
|
}
|
|
|
|
func TestParseReportsGlob(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("inline_and_path", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
reportFile := filepath.Join(dir, "reports", "from-file.yml")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(reportFile), 0o755))
|
|
require.NoError(t, os.WriteFile(reportFile, []byte("- name: FileReport\n query: SELECT 1;\n"), 0o644))
|
|
|
|
top := yamlToRawJSON(t, `
|
|
reports:
|
|
- name: InlineReport
|
|
query: SELECT 1;
|
|
- path: reports/from-file.yml
|
|
`)
|
|
teamName := "TestTeam"
|
|
result := &GitOps{TeamName: &teamName}
|
|
multiErr := parseReports(top, result, dir, nopLogf, "test.yml", nil)
|
|
require.Nil(t, multiErr.ErrorOrNil())
|
|
require.Len(t, result.Queries, 2)
|
|
assert.Equal(t, "InlineReport", result.Queries[0].Name)
|
|
assert.Equal(t, "FileReport", result.Queries[1].Name)
|
|
})
|
|
|
|
t.Run("glob_expands", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
reportsDir := filepath.Join(dir, "reports")
|
|
require.NoError(t, os.MkdirAll(reportsDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(reportsDir, "a.yml"), []byte("- name: ReportA\n query: SELECT 1;\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(reportsDir, "b.yml"), []byte("- name: ReportB\n query: SELECT 1;\n"), 0o644))
|
|
|
|
top := yamlToRawJSON(t, `
|
|
reports:
|
|
- paths: "reports/*.yml"
|
|
`)
|
|
teamName := "TestTeam"
|
|
result := &GitOps{TeamName: &teamName}
|
|
multiErr := parseReports(top, result, dir, nopLogf, "test.yml", nil)
|
|
require.Nil(t, multiErr.ErrorOrNil())
|
|
require.Len(t, result.Queries, 2)
|
|
assert.Equal(t, "ReportA", result.Queries[0].Name)
|
|
assert.Equal(t, "ReportB", result.Queries[1].Name)
|
|
})
|
|
}
|
|
|
|
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 TestGitOpsGlobProfiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("macos_profiles", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
profilesDir := filepath.Join(dir, "profiles")
|
|
require.NoError(t, os.MkdirAll(profilesDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "alpha.mobileconfig"), []byte("<plist></plist>"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "beta.json"), []byte("{}"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "gamma.mobileconfig"), []byte("<plist></plist>"), 0o644))
|
|
|
|
config := getGlobalConfig([]string{"controls"})
|
|
config += `controls:
|
|
apple_settings:
|
|
configuration_profiles:
|
|
- paths: profiles/*.mobileconfig
|
|
- path: profiles/beta.json
|
|
`
|
|
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)
|
|
macSettings, ok := result.Controls.MacOSSettings.(fleet.MacOSSettings)
|
|
require.True(t, ok)
|
|
require.Len(t, macSettings.CustomSettings, 3)
|
|
|
|
// Glob results come first (sorted), then the explicit path
|
|
assert.Contains(t, macSettings.CustomSettings[0].Path, "alpha.mobileconfig")
|
|
assert.Contains(t, macSettings.CustomSettings[1].Path, "gamma.mobileconfig")
|
|
assert.Contains(t, macSettings.CustomSettings[2].Path, "beta.json")
|
|
})
|
|
|
|
t.Run("windows_profiles", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
profilesDir := filepath.Join(dir, "profiles")
|
|
require.NoError(t, os.MkdirAll(profilesDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "alpha.xml"), []byte("<xml/>"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "beta.xml"), []byte("<xml/>"), 0o644))
|
|
|
|
config := getGlobalConfig([]string{"controls"})
|
|
config += `controls:
|
|
windows_settings:
|
|
configuration_profiles:
|
|
- paths: profiles/*.xml
|
|
`
|
|
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)
|
|
winSettings, ok := result.Controls.WindowsSettings.(fleet.WindowsSettings)
|
|
require.True(t, ok)
|
|
require.True(t, winSettings.CustomSettings.Valid)
|
|
require.Len(t, winSettings.CustomSettings.Value, 2)
|
|
|
|
assert.Contains(t, winSettings.CustomSettings.Value[0].Path, "alpha.xml")
|
|
assert.Contains(t, winSettings.CustomSettings.Value[1].Path, "beta.xml")
|
|
})
|
|
|
|
t.Run("android_profiles", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
profilesDir := filepath.Join(dir, "profiles")
|
|
require.NoError(t, os.MkdirAll(profilesDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "skip.xml"), []byte("<xml/>"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "beta.json"), []byte("{}"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "skip.txt"), []byte("nope"), 0o644))
|
|
|
|
config := getGlobalConfig([]string{"controls"})
|
|
config += `controls:
|
|
android_settings:
|
|
configuration_profiles:
|
|
- paths: profiles/*
|
|
`
|
|
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)
|
|
androidSettings, ok := result.Controls.AndroidSettings.(fleet.AndroidSettings)
|
|
require.True(t, ok)
|
|
require.True(t, androidSettings.CustomSettings.Valid)
|
|
require.Len(t, androidSettings.CustomSettings.Value, 1)
|
|
|
|
// Sorted alphabetically by path
|
|
assert.Contains(t, androidSettings.CustomSettings.Value[0].Path, "beta.json")
|
|
})
|
|
|
|
t.Run("macos_profiles_with_labels", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
profilesDir := filepath.Join(dir, "profiles")
|
|
require.NoError(t, os.MkdirAll(profilesDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "a.mobileconfig"), []byte("<plist></plist>"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(profilesDir, "b.mobileconfig"), []byte("<plist></plist>"), 0o644))
|
|
|
|
config := getGlobalConfig([]string{"controls"})
|
|
config += `controls:
|
|
apple_settings:
|
|
configuration_profiles:
|
|
- paths: profiles/*.mobileconfig
|
|
labels_include_all:
|
|
- MyLabel
|
|
`
|
|
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)
|
|
macSettings, ok := result.Controls.MacOSSettings.(fleet.MacOSSettings)
|
|
require.True(t, ok)
|
|
require.Len(t, macSettings.CustomSettings, 2)
|
|
for _, p := range macSettings.CustomSettings {
|
|
assert.Equal(t, []string{"MyLabel"}, p.LabelsIncludeAll)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGitOpsOSUpdatesProfileConflict(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const appleDecl = `{"Type":"com.apple.configuration.softwareupdate.enforcement.specific","Identifier":"x","Payload":{}}`
|
|
const windowsProfile = `<Replace><Item><Target><LocURI>./Vendor/MSFT/Policy/Config/Update/x</LocURI></Target></Item></Replace>`
|
|
|
|
t.Run("macos updates configured with software update declaration fails", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "su.json"), []byte(appleDecl), 0o644))
|
|
|
|
config := getGlobalConfig([]string{"controls"})
|
|
config += `controls:
|
|
macos_updates:
|
|
minimum_version: "14.0"
|
|
deadline: "2024-01-01"
|
|
apple_settings:
|
|
configuration_profiles:
|
|
- path: su.json
|
|
`
|
|
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)
|
|
assert.Contains(t, err.Error(), fleet.OSUpdatesAlreadyConfiguredErrorMessage)
|
|
})
|
|
|
|
t.Run("windows updates configured with software update profile fails", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "su.xml"), []byte(windowsProfile), 0o644))
|
|
|
|
config := getGlobalConfig([]string{"controls"})
|
|
config += `controls:
|
|
windows_updates:
|
|
deadline_days: 5
|
|
grace_period_days: 1
|
|
windows_settings:
|
|
configuration_profiles:
|
|
- path: su.xml
|
|
`
|
|
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)
|
|
assert.Contains(t, err.Error(), fleet.OSUpdatesAlreadyConfiguredErrorMessage)
|
|
})
|
|
|
|
t.Run("software update declaration without configured os updates is allowed", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "su.json"), []byte(appleDecl), 0o644))
|
|
|
|
config := getGlobalConfig([]string{"controls"})
|
|
config += `controls:
|
|
apple_settings:
|
|
configuration_profiles:
|
|
- path: su.json
|
|
`
|
|
yamlPath := filepath.Join(dir, "gitops.yml")
|
|
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
|
|
|
|
_, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("macos updates configured with non-conflicting profile is allowed", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "plain.mobileconfig"), []byte("<plist></plist>"), 0o644))
|
|
|
|
config := getGlobalConfig([]string{"controls"})
|
|
config += `controls:
|
|
macos_updates:
|
|
minimum_version: "14.0"
|
|
deadline: "2024-01-01"
|
|
apple_settings:
|
|
configuration_profiles:
|
|
- path: plain.mobileconfig
|
|
`
|
|
yamlPath := filepath.Join(dir, "gitops.yml")
|
|
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
|
|
|
|
_, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
func TestGitOpsAppleAccountProvisioning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const aapControls = `
|
|
controls:
|
|
apple_account_provisioning:
|
|
oauth_idp_token_url: https://idp.example.com/oauth2/v1/token
|
|
oauth_idp_client_id: client-id
|
|
oauth_idp_client_secret: super-secret
|
|
`
|
|
|
|
t.Run("parsed in global config", func(t *testing.T) {
|
|
t.Parallel()
|
|
config := getGlobalConfig([]string{"controls"}) + aapControls
|
|
path, basePath := createTempFile(t, "", config)
|
|
gitops, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gitops.Controls.AppleAccountProvisioning)
|
|
aap := gitops.Controls.AppleAccountProvisioning
|
|
assert.Equal(t, "https://idp.example.com/oauth2/v1/token", aap.OAuthIdPTokenURL.Value)
|
|
assert.Equal(t, "client-id", aap.OAuthIdPClientID.Value)
|
|
assert.Equal(t, "super-secret", aap.OAuthIdPClientSecret.Value)
|
|
assert.True(t, gitops.Controls.Set())
|
|
})
|
|
|
|
t.Run("nil when omitted", func(t *testing.T) {
|
|
t.Parallel()
|
|
config := getGlobalConfig(nil)
|
|
path, basePath := createTempFile(t, "", config)
|
|
gitops, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, gitops.Controls.AppleAccountProvisioning)
|
|
})
|
|
|
|
t.Run("rejected in a specific team's file", func(t *testing.T) {
|
|
t.Parallel()
|
|
config := getTeamConfig([]string{"controls"}) + aapControls
|
|
path, basePath := createTempFile(t, "", config)
|
|
_, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "apple_account_provisioning can only be configured in the global configuration")
|
|
})
|
|
}
|
|
|
|
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 org_settings", func(t *testing.T) {
|
|
t.Parallel()
|
|
config := `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
unknown_org_field: true
|
|
secrets:
|
|
controls:
|
|
agent_options:
|
|
reports:
|
|
policies:
|
|
`
|
|
path, basePath := createTempFile(t, "", config)
|
|
_, err := GitOpsFromFile(path, basePath, nil, nopLogf)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "unknown_org_field")
|
|
})
|
|
|
|
t.Run("unknown nested key in org_settings", func(t *testing.T) {
|
|
t.Parallel()
|
|
config := `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
unknown_server_field: true
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
secrets:
|
|
controls:
|
|
agent_options:
|
|
reports:
|
|
policies:
|
|
`
|
|
path, basePath := createTempFile(t, "", config)
|
|
_, err := GitOpsFromFile(path, basePath, nil, nopLogf)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), `unknown key "org_settings.server_settings.unknown_server_field"`)
|
|
})
|
|
|
|
t.Run("unknown key in org_settings with typo suggestion", func(t *testing.T) {
|
|
t.Parallel()
|
|
config := `
|
|
org_settings:
|
|
server_settigns:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
secrets:
|
|
controls:
|
|
agent_options:
|
|
reports:
|
|
policies:
|
|
`
|
|
path, basePath := createTempFile(t, "", config)
|
|
_, err := GitOpsFromFile(path, basePath, nil, nopLogf)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "org_settings.server_settigns")
|
|
assert.Contains(t, err.Error(), `did you mean "server_settings"?`)
|
|
})
|
|
|
|
t.Run("unknown key in fleet settings", func(t *testing.T) {
|
|
t.Parallel()
|
|
config := `
|
|
name: FleetName
|
|
settings:
|
|
secrets:
|
|
unknown_fleet_field: true
|
|
agent_options:
|
|
controls:
|
|
reports:
|
|
policies:
|
|
software:
|
|
`
|
|
path, basePath := createTempFile(t, "", config)
|
|
_, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "unknown_fleet_field")
|
|
})
|
|
|
|
t.Run("unknown nested key in fleet settings webhook_settings", func(t *testing.T) {
|
|
t.Parallel()
|
|
config := `
|
|
name: FleetName
|
|
settings:
|
|
secrets:
|
|
webhook_settings:
|
|
unknown_webhook_field: true
|
|
agent_options:
|
|
controls:
|
|
reports:
|
|
policies:
|
|
software:
|
|
`
|
|
path, basePath := createTempFile(t, "", config)
|
|
_, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), `unknown key "settings.webhook_settings.unknown_webhook_field"`)
|
|
})
|
|
|
|
t.Run("unknown key in org_settings via path", func(t *testing.T) {
|
|
t.Parallel()
|
|
config := `
|
|
org_settings:
|
|
path: org_settings.yml
|
|
controls:
|
|
agent_options:
|
|
reports:
|
|
policies:
|
|
`
|
|
path, basePath := createTempFile(t, "", config)
|
|
orgSettingsYAML := `
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
unknown_org_path_field: true
|
|
secrets:
|
|
`
|
|
require.NoError(t, os.WriteFile(filepath.Join(basePath, "org_settings.yml"), []byte(orgSettingsYAML), 0o644))
|
|
_, err := GitOpsFromFile(path, basePath, nil, nopLogf)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), `unknown key "org_settings.unknown_org_path_field" in "org_settings.yml"`)
|
|
})
|
|
|
|
t.Run("unknown key in fleet settings via path", func(t *testing.T) {
|
|
t.Parallel()
|
|
config := `
|
|
name: FleetName
|
|
settings:
|
|
path: fleet_settings.yml
|
|
agent_options:
|
|
controls:
|
|
reports:
|
|
policies:
|
|
software:
|
|
`
|
|
path, basePath := createTempFile(t, "", config)
|
|
fleetSettingsYAML := `
|
|
secrets:
|
|
unknown_fleet_path_field: true
|
|
`
|
|
require.NoError(t, os.WriteFile(filepath.Join(basePath, "fleet_settings.yml"), []byte(fleetSettingsYAML), 0o644))
|
|
_, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), `unknown key "settings.unknown_fleet_path_field" in "fleet_settings.yml"`)
|
|
})
|
|
|
|
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")
|
|
})
|
|
}
|
|
|
|
// TestControlsAppleSettingsAssets verifies that controls.apple_settings.assets
|
|
// are parsed like profiles: paths are resolved and any Fleet secrets referenced
|
|
// in the asset files are collected.
|
|
func TestControlsAppleSettingsAssets(t *testing.T) {
|
|
t.Setenv("FLEET_SECRET_WALLPAPER", "s3cret")
|
|
|
|
dir := t.TempDir()
|
|
assetsDir := filepath.Join(dir, "lib", "assets")
|
|
require.NoError(t, os.MkdirAll(assetsDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(assetsDir, "wallpaper.json"),
|
|
[]byte(`{"Type":"com.apple.asset.data","Identifier":"com.example.wallpaper","Payload":{"Reference":{"DataURL":"https://example.com/$FLEET_SECRET_WALLPAPER"}}}`), 0o644))
|
|
|
|
config := `
|
|
controls:
|
|
apple_settings:
|
|
assets:
|
|
- path: ./lib/assets/wallpaper.json
|
|
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)
|
|
|
|
macSettings, ok := gitops.Controls.MacOSSettings.(fleet.MacOSSettings)
|
|
require.True(t, ok, "apple_settings not parsed")
|
|
require.Len(t, macSettings.Assets, 1)
|
|
require.True(t, filepath.IsAbs(macSettings.Assets[0].Path), "asset path should be resolved to absolute")
|
|
require.Contains(t, gitops.FleetSecrets, "FLEET_SECRET_WALLPAPER")
|
|
}
|
|
|
|
// TestControlsNewKeyNames verifies that the new multi-platform key names
|
|
// (apple_settings, setup_experience, configuration_profiles, apple_setup_assistant,
|
|
// macos_bootstrap_package, apple_enable_release_device_manually, macos_script, macos_manual_agent_install)
|
|
// 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("<plist></plist>"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(profileDir, "windows-screenlock.xml"), []byte("<xml/>"), 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:
|
|
macos_bootstrap_package: null
|
|
enable_end_user_authentication: false
|
|
apple_setup_assistant: null
|
|
apple_enable_release_device_manually: null
|
|
macos_manual_agent_install: 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("<plist></plist>"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(profileDir, "windows-screenlock.xml"), []byte("<xml/>"), 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:
|
|
macos_bootstrap_package: null
|
|
enable_end_user_authentication: false
|
|
apple_setup_assistant: null
|
|
apple_enable_release_device_manually: null
|
|
macos_manual_agent_install: 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(), "cannot specify both")
|
|
require.Contains(t, err.Error(), "apple_settings")
|
|
require.Contains(t, err.Error(), "'controls.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(), "cannot specify both")
|
|
require.Contains(t, err.Error(), "configuration_profiles")
|
|
require.Contains(t, err.Error(), "'controls.apple_settings.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(), "cannot specify both")
|
|
require.Contains(t, err.Error(), "configuration_profiles")
|
|
require.Contains(t, err.Error(), "'controls.windows_settings.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(), "cannot specify both")
|
|
require.Contains(t, err.Error(), "configuration_profiles")
|
|
require.Contains(t, err.Error(), "'controls.android_settings.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(), "cannot specify both")
|
|
require.Contains(t, err.Error(), "setup_experience")
|
|
require.Contains(t, err.Error(), "'controls.macos_setup' (deprecated)")
|
|
})
|
|
|
|
t.Run("duplicate_old_and_new_keys_error_bootstrap_package", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
config := `
|
|
reports:
|
|
policies:
|
|
agent_options:
|
|
org_settings:
|
|
server_settings:
|
|
org_info:
|
|
secrets:
|
|
controls:
|
|
setup_experience:
|
|
bootstrap_package: ""
|
|
macos_bootstrap_package: ""
|
|
`
|
|
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(), "cannot specify both")
|
|
require.Contains(t, err.Error(), "macos_bootstrap_package")
|
|
require.Contains(t, err.Error(), "'controls.setup_experience.bootstrap_package' (deprecated)")
|
|
})
|
|
|
|
t.Run("duplicate_old_and_new_keys_error_enable_release_device_manually", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
config := `
|
|
reports:
|
|
policies:
|
|
agent_options:
|
|
org_settings:
|
|
server_settings:
|
|
org_info:
|
|
secrets:
|
|
controls:
|
|
setup_experience:
|
|
enable_release_device_manually: false
|
|
apple_enable_release_device_manually: false
|
|
`
|
|
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(), "cannot specify both")
|
|
require.Contains(t, err.Error(), "apple_enable_release_device_manually")
|
|
require.Contains(t, err.Error(), "'controls.setup_experience.enable_release_device_manually' (deprecated)")
|
|
})
|
|
|
|
t.Run("duplicate_old_and_new_keys_error_script", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
config := `
|
|
reports:
|
|
policies:
|
|
agent_options:
|
|
org_settings:
|
|
server_settings:
|
|
org_info:
|
|
secrets:
|
|
controls:
|
|
setup_experience:
|
|
script: null
|
|
macos_script: null
|
|
`
|
|
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(), "cannot specify both")
|
|
require.Contains(t, err.Error(), "macos_script")
|
|
require.Contains(t, err.Error(), "'controls.setup_experience.script' (deprecated)")
|
|
})
|
|
|
|
t.Run("duplicate_old_and_new_keys_error_manual_agent_install", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
config := `
|
|
reports:
|
|
policies:
|
|
agent_options:
|
|
org_settings:
|
|
server_settings:
|
|
org_info:
|
|
secrets:
|
|
controls:
|
|
setup_experience:
|
|
manual_agent_install: false
|
|
macos_manual_agent_install: false
|
|
`
|
|
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(), "cannot specify both")
|
|
require.Contains(t, err.Error(), "macos_manual_agent_install")
|
|
require.Contains(t, err.Error(), "'controls.setup_experience.manual_agent_install' (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(), "cannot specify both")
|
|
require.Contains(t, err.Error(), "apple_settings")
|
|
require.Contains(t, err.Error(), "`macos_settings` (deprecated)")
|
|
})
|
|
}
|
|
|
|
// TestAppleBusinessKeyRename verifies that the new mdm.apple_business key is
|
|
// accepted in org_settings, that the deprecated mdm.apple_business_manager key
|
|
// still works, and that specifying both raises a conflict error.
|
|
func TestAppleBusinessKeyRename(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
baseConfig := func(mdmSection string) string {
|
|
return `
|
|
controls:
|
|
reports:
|
|
policies:
|
|
agent_options:
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://fleet.example.com
|
|
org_info:
|
|
contact_url: https://example.com/contact
|
|
org_name: Test Org
|
|
secrets:
|
|
mdm:` + mdmSection + `
|
|
`
|
|
}
|
|
|
|
t.Run("new_key_accepted", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
yaml := baseConfig(`
|
|
apple_business:
|
|
- organization_name: Test Org
|
|
macos_fleet: "Workstations"
|
|
ios_fleet: "Phones"
|
|
ipados_fleet: "Tablets"`)
|
|
yamlPath := filepath.Join(dir, "gitops.yml")
|
|
require.NoError(t, os.WriteFile(yamlPath, []byte(yaml), 0o644))
|
|
|
|
_, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("old_key_still_accepted", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
yaml := baseConfig(`
|
|
apple_business_manager:
|
|
- organization_name: Test Org
|
|
macos_fleet: "Workstations"
|
|
ios_fleet: "Phones"
|
|
ipados_fleet: "Tablets"`)
|
|
yamlPath := filepath.Join(dir, "gitops.yml")
|
|
require.NoError(t, os.WriteFile(yamlPath, []byte(yaml), 0o644))
|
|
|
|
_, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("both_keys_conflict", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
yaml := baseConfig(`
|
|
apple_business:
|
|
- organization_name: A
|
|
apple_business_manager:
|
|
- organization_name: B`)
|
|
yamlPath := filepath.Join(dir, "gitops.yml")
|
|
require.NoError(t, os.WriteFile(yamlPath, []byte(yaml), 0o644))
|
|
|
|
_, err := GitOpsFromFile(yamlPath, dir, nil, nopLogf)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "cannot specify both")
|
|
require.Contains(t, err.Error(), "org_settings.mdm.apple_business")
|
|
})
|
|
}
|
|
|
|
// TestSetupExperienceSoftwareDeprecation verifies that supplying a list of
|
|
// software under `controls.setup_experience.software` emits a deprecation
|
|
// warning steering users toward the per-item `setup_experience: true` form.
|
|
func TestSetupExperienceSoftwareDeprecation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("warning_emitted_when_setup_experience_software_set", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "installer.pkg"), []byte("test"), 0o644))
|
|
|
|
config := `
|
|
reports:
|
|
policies:
|
|
agent_options:
|
|
software:
|
|
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:
|
|
controls:
|
|
setup_experience:
|
|
software:
|
|
- package_path: ./installer.pkg
|
|
- app_store_id: "1091189122"
|
|
`
|
|
yamlPath := filepath.Join(dir, "gitops.yml")
|
|
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
|
|
|
|
var logs strings.Builder
|
|
logFn := func(format string, a ...any) {
|
|
logs.WriteString(fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
_, err := GitOpsFromFile(yamlPath, dir, nil, logFn)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, logs.String(), "'controls.setup_experience.software / controls.macos_setup.software' is deprecated")
|
|
assert.Contains(t, logs.String(), "setup_experience: true")
|
|
})
|
|
|
|
t.Run("no_warning_when_setup_experience_software_unset", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
config := `
|
|
reports:
|
|
policies:
|
|
agent_options:
|
|
software:
|
|
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:
|
|
controls:
|
|
setup_experience:
|
|
enable_end_user_authentication: false
|
|
`
|
|
yamlPath := filepath.Join(dir, "gitops.yml")
|
|
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
|
|
|
|
var logs strings.Builder
|
|
logFn := func(format string, a ...any) {
|
|
logs.WriteString(fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
_, err := GitOpsFromFile(yamlPath, dir, nil, logFn)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, logs.String(), "'controls.setup_experience.software / controls.macos_setup.software' is deprecated")
|
|
})
|
|
|
|
t.Run("no_warning_when_setup_experience_software_empty", func(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
|
|
config := `
|
|
reports:
|
|
policies:
|
|
agent_options:
|
|
software:
|
|
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:
|
|
controls:
|
|
setup_experience:
|
|
software: []
|
|
`
|
|
yamlPath := filepath.Join(dir, "gitops.yml")
|
|
require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644))
|
|
|
|
var logs strings.Builder
|
|
logFn := func(format string, a ...any) {
|
|
logs.WriteString(fmt.Sprintf(format, a...))
|
|
}
|
|
|
|
_, err := GitOpsFromFile(yamlPath, dir, nil, logFn)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, logs.String(), "'controls.setup_experience.software / controls.macos_setup.software' is 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.Value)
|
|
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("sh_script_with_shell_variables", func(t *testing.T) {
|
|
config := getTeamConfig([]string{"software"})
|
|
config += `
|
|
software:
|
|
packages:
|
|
- path: software/shell-vars.sh
|
|
self_service: true
|
|
`
|
|
path, basePath := createTempFile(t, "", config)
|
|
|
|
// Create a script that uses standard shell variables (not set in CI env).
|
|
_, expectedVarUnset := os.LookupEnv("SOMETHING_UNSET") // Make sure at least one is not set in the CI environment
|
|
require.False(t, expectedVarUnset, "SOMETHING_UNSET should not be set in the test environment")
|
|
scriptContent := []byte("#!/bin/bash\necho \"EUID=$EUID\"\necho \"USER=$USER\"\necho \"HOME=$HOME\"\necho \"SOMETHING_UNSET=$SOMETHING_UNSET\"\n")
|
|
require.NoError(t, os.MkdirAll(filepath.Join(basePath, "software"), 0o755)) // nolint:gosec
|
|
require.NoError(t, os.WriteFile(filepath.Join(basePath, "software", "shell-vars.sh"), scriptContent, 0o755)) // nolint:gosec
|
|
|
|
// Shell variables must not be expanded by the gitops parser.
|
|
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, "shell-vars.sh"))
|
|
})
|
|
|
|
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.Value)
|
|
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()
|
|
|
|
var installSoftware optjson.BoolOr[*PolicyInstallSoftware]
|
|
installSoftware.Other = &PolicyInstallSoftware{}
|
|
|
|
policy := &Policy{
|
|
GitOpsPolicySpec: GitOpsPolicySpec{
|
|
PolicySpec: fleet.PolicySpec{Name: "my policy"},
|
|
InstallSoftware: installSoftware, // no package_path, app_store_id, or hash_sha256
|
|
},
|
|
}
|
|
errs := parsePolicyInstallSoftware(".", &teamName, policy, nil, 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, a hash_sha256 or a fleet_maintained_app_slug`)
|
|
})
|
|
|
|
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))
|
|
|
|
var installSoftware optjson.BoolOr[*PolicyInstallSoftware]
|
|
installSoftware.Other = &PolicyInstallSoftware{PackagePath: path}
|
|
|
|
policy := &Policy{
|
|
GitOpsPolicySpec: GitOpsPolicySpec{
|
|
PolicySpec: fleet.PolicySpec{Name: "typo policy"},
|
|
InstallSoftware: installSoftware,
|
|
},
|
|
}
|
|
packages := []*fleet.SoftwarePackageSpec{{SHA256: sha}}
|
|
errs := parsePolicyInstallSoftware(".", &teamName, policy, packages, nil, nil)
|
|
require.Len(t, errs, 1)
|
|
var unknownErr *ParseUnknownKeyError
|
|
require.ErrorAs(t, errs[0], &unknownErr)
|
|
assert.Equal(t, "bad_field", unknownErr.Field)
|
|
})
|
|
t.Run("fleet_maintained_app_slug valid", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var installSoftware optjson.BoolOr[*PolicyInstallSoftware]
|
|
installSoftware.Other = &PolicyInstallSoftware{FleetMaintainedAppSlug: "zoom/darwin"}
|
|
|
|
policy := &Policy{
|
|
GitOpsPolicySpec: GitOpsPolicySpec{
|
|
PolicySpec: fleet.PolicySpec{Name: "fma policy"},
|
|
InstallSoftware: installSoftware,
|
|
},
|
|
}
|
|
fmasBySlug := map[string]struct{}{"zoom/darwin": {}}
|
|
errs := parsePolicyInstallSoftware(".", &teamName, policy, nil, nil, fmasBySlug)
|
|
require.Nil(t, errs)
|
|
assert.Equal(t, "zoom/darwin", policy.InstallSoftware.Other.FleetMaintainedAppSlug)
|
|
})
|
|
|
|
t.Run("fleet_maintained_app_slug not in FMAs", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var installSoftware optjson.BoolOr[*PolicyInstallSoftware]
|
|
installSoftware.Other = &PolicyInstallSoftware{FleetMaintainedAppSlug: "notreal/darwin"}
|
|
|
|
policy := &Policy{
|
|
GitOpsPolicySpec: GitOpsPolicySpec{
|
|
PolicySpec: fleet.PolicySpec{Name: "bad fma policy"},
|
|
InstallSoftware: installSoftware,
|
|
},
|
|
}
|
|
fmasBySlug := map[string]struct{}{"zoom/darwin": {}}
|
|
errs := parsePolicyInstallSoftware(".", &teamName, policy, nil, nil, fmasBySlug)
|
|
require.Len(t, errs, 1)
|
|
assert.Contains(t, errs[0].Error(), `fleet_maintained_app_slug "notreal/darwin" not found`)
|
|
})
|
|
|
|
t.Run("fleet_maintained_app_slug with other fields errors", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var installSoftware optjson.BoolOr[*PolicyInstallSoftware]
|
|
installSoftware.Other = &PolicyInstallSoftware{
|
|
FleetMaintainedAppSlug: "zoom/darwin",
|
|
HashSHA256: "abc123",
|
|
}
|
|
|
|
policy := &Policy{
|
|
GitOpsPolicySpec: GitOpsPolicySpec{
|
|
PolicySpec: fleet.PolicySpec{Name: "conflicting policy"},
|
|
InstallSoftware: installSoftware,
|
|
},
|
|
}
|
|
errs := parsePolicyInstallSoftware(".", &teamName, policy, nil, nil, nil)
|
|
require.Len(t, errs, 1)
|
|
assert.Contains(t, errs[0].Error(), "install_software must have only one of")
|
|
})
|
|
|
|
t.Run("fleet_maintained_app_slug on global policy errors", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var installSoftware optjson.BoolOr[*PolicyInstallSoftware]
|
|
installSoftware.Other = &PolicyInstallSoftware{FleetMaintainedAppSlug: "zoom/darwin"}
|
|
|
|
policy := &Policy{
|
|
GitOpsPolicySpec: GitOpsPolicySpec{
|
|
PolicySpec: fleet.PolicySpec{Name: "global fma policy"},
|
|
InstallSoftware: installSoftware,
|
|
},
|
|
}
|
|
errs := parsePolicyInstallSoftware(".", nil, policy, nil, nil, nil)
|
|
require.Len(t, errs, 1)
|
|
assert.Contains(t, errs[0].Error(), "install_software can only be set on team policies")
|
|
})
|
|
|
|
t.Run("patch policy with the same fleet_maintained_app_slug", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var installSoftware optjson.BoolOr[*PolicyInstallSoftware]
|
|
installSoftware.Other = &PolicyInstallSoftware{FleetMaintainedAppSlug: "zoom/darwin"}
|
|
|
|
policy := &Policy{
|
|
GitOpsPolicySpec: GitOpsPolicySpec{
|
|
PolicySpec: fleet.PolicySpec{
|
|
Name: "fma policy",
|
|
Type: fleet.PolicyTypePatch,
|
|
FleetMaintainedAppSlug: "zoom/darwin",
|
|
},
|
|
InstallSoftware: installSoftware,
|
|
},
|
|
}
|
|
fmasBySlug := map[string]struct{}{"zoom/darwin": {}}
|
|
errs := parsePolicyInstallSoftware(".", &teamName, policy, nil, nil, fmasBySlug)
|
|
require.Nil(t, errs)
|
|
assert.Equal(t, "zoom/darwin", policy.FleetMaintainedAppSlug)
|
|
assert.Equal(t, "zoom/darwin", policy.InstallSoftware.Other.FleetMaintainedAppSlug)
|
|
})
|
|
|
|
t.Run("patch policy with different fleet_maintained_app_slug", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var installSoftware optjson.BoolOr[*PolicyInstallSoftware]
|
|
installSoftware.Other = &PolicyInstallSoftware{FleetMaintainedAppSlug: "zoom/darwin"}
|
|
|
|
policy := &Policy{
|
|
GitOpsPolicySpec: GitOpsPolicySpec{
|
|
PolicySpec: fleet.PolicySpec{
|
|
Name: "fma policy",
|
|
Type: fleet.PolicyTypePatch,
|
|
FleetMaintainedAppSlug: "1password/darwin",
|
|
},
|
|
InstallSoftware: installSoftware,
|
|
},
|
|
}
|
|
fmasBySlug := map[string]struct{}{"zoom/darwin": {}, "1password/darwin": {}}
|
|
errs := parsePolicyInstallSoftware(".", &teamName, policy, nil, nil, fmasBySlug)
|
|
require.Nil(t, errs)
|
|
assert.Equal(t, "1password/darwin", policy.FleetMaintainedAppSlug)
|
|
assert.Equal(t, "zoom/darwin", policy.InstallSoftware.Other.FleetMaintainedAppSlug)
|
|
})
|
|
}
|
|
|
|
func TestGitOpsPresenceTracking(t *testing.T) {
|
|
t.Run("labels present", func(t *testing.T) {
|
|
gitops, err := gitOpsFromString(t, `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://example.com
|
|
org_info:
|
|
org_name: Test
|
|
labels:
|
|
- name: test-label
|
|
query: SELECT 1
|
|
`)
|
|
require.NoError(t, err)
|
|
assert.True(t, gitops.LabelsPresent)
|
|
})
|
|
|
|
t.Run("labels absent", func(t *testing.T) {
|
|
gitops, err := gitOpsFromString(t, `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://example.com
|
|
org_info:
|
|
org_name: Test
|
|
`)
|
|
require.NoError(t, err)
|
|
assert.False(t, gitops.LabelsPresent)
|
|
assert.Nil(t, gitops.Labels, "absent labels should be nil")
|
|
})
|
|
|
|
t.Run("labels present but empty", func(t *testing.T) {
|
|
gitops, err := gitOpsFromString(t, `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://example.com
|
|
org_info:
|
|
org_name: Test
|
|
labels:
|
|
`)
|
|
require.NoError(t, err)
|
|
assert.True(t, gitops.LabelsPresent)
|
|
assert.Nil(t, gitops.Labels, "empty labels section should result in nil")
|
|
})
|
|
|
|
t.Run("secrets present", func(t *testing.T) {
|
|
gitops, err := gitOpsFromString(t, `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://example.com
|
|
org_info:
|
|
org_name: Test
|
|
secrets:
|
|
- secret: mysecret
|
|
`)
|
|
require.NoError(t, err)
|
|
assert.True(t, gitops.SecretsPresent)
|
|
})
|
|
|
|
t.Run("secrets absent", func(t *testing.T) {
|
|
gitops, err := gitOpsFromString(t, `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://example.com
|
|
org_info:
|
|
org_name: Test
|
|
`)
|
|
require.NoError(t, err)
|
|
assert.False(t, gitops.SecretsPresent)
|
|
})
|
|
|
|
t.Run("software present on team", func(t *testing.T) {
|
|
premiumConfig := &fleet.EnrichedAppConfig{}
|
|
premiumConfig.License = &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
|
|
path, basePath := createTempFile(t, "", `
|
|
name: TestTeam
|
|
software:
|
|
packages:
|
|
- url: https://example.com/pkg.deb
|
|
`)
|
|
gitops, err := GitOpsFromFile(path, basePath, premiumConfig, nopLogf)
|
|
require.NoError(t, err)
|
|
assert.True(t, gitops.SoftwarePresent)
|
|
})
|
|
|
|
t.Run("software absent on team", func(t *testing.T) {
|
|
premiumConfig := &fleet.EnrichedAppConfig{}
|
|
premiumConfig.License = &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
|
|
|
path, basePath := createTempFile(t, "", `
|
|
name: TestTeam
|
|
`)
|
|
gitops, err := GitOpsFromFile(path, basePath, premiumConfig, nopLogf)
|
|
require.NoError(t, err)
|
|
assert.False(t, gitops.SoftwarePresent)
|
|
})
|
|
|
|
t.Run("custom host vitals present", func(t *testing.T) {
|
|
gitops, err := gitOpsFromString(t, `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://example.com
|
|
org_info:
|
|
org_name: Test
|
|
custom_host_vitals:
|
|
- name: Asset tag
|
|
- name: Department
|
|
`)
|
|
require.NoError(t, err)
|
|
assert.True(t, gitops.CustomHostVitalsPresent)
|
|
assert.ElementsMatch(t, []fleet.CustomHostVital{{Name: "Asset tag"}, {Name: "Department"}}, gitops.CustomHostVitals)
|
|
})
|
|
|
|
t.Run("custom host vitals absent", func(t *testing.T) {
|
|
gitops, err := gitOpsFromString(t, `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://example.com
|
|
org_info:
|
|
org_name: Test
|
|
`)
|
|
require.NoError(t, err)
|
|
assert.False(t, gitops.CustomHostVitalsPresent)
|
|
assert.Nil(t, gitops.CustomHostVitals, "absent custom_host_vitals should be nil")
|
|
})
|
|
|
|
t.Run("custom host vitals present but empty", func(t *testing.T) {
|
|
gitops, err := gitOpsFromString(t, `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://example.com
|
|
org_info:
|
|
org_name: Test
|
|
custom_host_vitals:
|
|
`)
|
|
require.NoError(t, err)
|
|
assert.True(t, gitops.CustomHostVitalsPresent)
|
|
assert.Empty(t, gitops.CustomHostVitals)
|
|
})
|
|
}
|
|
|
|
func TestGitOpsCustomHostVitals(t *testing.T) {
|
|
t.Run("rejected on a team file", func(t *testing.T) {
|
|
path, basePath := createTempFile(t, "", `
|
|
name: TestTeam
|
|
custom_host_vitals:
|
|
- name: Asset tag
|
|
`)
|
|
_, err := GitOpsFromFile(path, basePath, nil, nopLogf)
|
|
require.ErrorContains(t, err, "'custom_host_vitals' cannot be set on a team file")
|
|
})
|
|
|
|
t.Run("rejects an invalid name", func(t *testing.T) {
|
|
_, err := gitOpsFromString(t, `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://example.com
|
|
org_info:
|
|
org_name: Test
|
|
custom_host_vitals:
|
|
- name: " Asset tag"
|
|
`)
|
|
require.ErrorContains(t, err, "custom host vital name cannot have leading or trailing whitespace")
|
|
})
|
|
|
|
t.Run("rejects an unknown key", func(t *testing.T) {
|
|
_, err := gitOpsFromString(t, `
|
|
org_settings:
|
|
server_settings:
|
|
server_url: https://example.com
|
|
org_info:
|
|
org_name: Test
|
|
custom_host_vitals:
|
|
- name: Asset tag
|
|
id: 1
|
|
`)
|
|
require.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestGitOpsFMACategoriesPresence(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
parse := func(t *testing.T, categoriesYAML string) optjson.Slice[string] {
|
|
config := getTeamConfig(nil)
|
|
config += "software:\n fleet_maintained_apps:\n - slug: 1password/darwin\n" + categoriesYAML
|
|
path, basePath := createTempFile(t, "", config)
|
|
gitops, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf)
|
|
require.NoError(t, err)
|
|
require.Len(t, gitops.Software.FleetMaintainedApps, 1)
|
|
return gitops.Software.FleetMaintainedApps[0].Categories
|
|
}
|
|
|
|
t.Run("omitted key is unset", func(t *testing.T) {
|
|
t.Parallel()
|
|
cats := parse(t, "")
|
|
assert.False(t, cats.Set)
|
|
})
|
|
|
|
t.Run("categories: (null) is set but not valid", func(t *testing.T) {
|
|
t.Parallel()
|
|
cats := parse(t, " categories:\n")
|
|
assert.True(t, cats.Set)
|
|
assert.False(t, cats.Valid)
|
|
assert.Empty(t, cats.Value)
|
|
})
|
|
|
|
t.Run("categories: [] is set and valid", func(t *testing.T) {
|
|
t.Parallel()
|
|
cats := parse(t, " categories: []\n")
|
|
assert.True(t, cats.Set)
|
|
assert.True(t, cats.Valid)
|
|
assert.Empty(t, cats.Value)
|
|
})
|
|
|
|
t.Run("categories with a value is set with the value", func(t *testing.T) {
|
|
t.Parallel()
|
|
cats := parse(t, " categories:\n - somevalue\n")
|
|
assert.True(t, cats.Set)
|
|
assert.True(t, cats.Valid)
|
|
assert.Equal(t, []string{"somevalue"}, cats.Value)
|
|
})
|
|
}
|
|
|
|
func TestDuplicatePatchPolicySlug(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Every slug referenced by a patch policy must be declared under software.fleet_maintained_apps.
|
|
fmaSoftware := `
|
|
software:
|
|
fleet_maintained_apps:
|
|
- slug: google-chrome/darwin
|
|
- slug: 1password/darwin
|
|
- slug: firefox/darwin
|
|
`
|
|
|
|
tests := []struct {
|
|
name string
|
|
policies string
|
|
// wantErrs empty means the config must apply cleanly.
|
|
wantErrs []string
|
|
}{
|
|
{
|
|
// Before this check the second patch policy silently overwrote the first.
|
|
name: "two patch policies with the same slug",
|
|
policies: `
|
|
policies:
|
|
- name: Chrome up to date
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: google-chrome/darwin
|
|
- name: Chrome up to date again
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: google-chrome/darwin
|
|
`,
|
|
wantErrs: []string{`Couldn't add multiple policies with type "patch" for "fleet_maintained_app_slug": "google-chrome/darwin".`},
|
|
},
|
|
{
|
|
// Each duplicated slug gets its own error, driven by the slug in the config.
|
|
name: "two slugs each duplicated report one error per slug",
|
|
policies: `
|
|
policies:
|
|
- name: Chrome up to date
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: google-chrome/darwin
|
|
- name: Chrome up to date again
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: google-chrome/darwin
|
|
- name: 1Password up to date
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: 1password/darwin
|
|
- name: 1Password up to date again
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: 1password/darwin
|
|
`,
|
|
wantErrs: []string{
|
|
`Couldn't add multiple policies with type "patch" for "fleet_maintained_app_slug": "google-chrome/darwin".`,
|
|
`Couldn't add multiple policies with type "patch" for "fleet_maintained_app_slug": "1password/darwin".`,
|
|
},
|
|
},
|
|
{
|
|
// A slug used by three patch policies is still reported a single time.
|
|
name: "slug used three times is reported once",
|
|
policies: `
|
|
policies:
|
|
- name: Chrome A
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: google-chrome/darwin
|
|
- name: Chrome B
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: google-chrome/darwin
|
|
- name: Chrome C
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: google-chrome/darwin
|
|
`,
|
|
wantErrs: []string{`Couldn't add multiple policies with type "patch" for "fleet_maintained_app_slug": "google-chrome/darwin".`},
|
|
},
|
|
{
|
|
// Duplicate names and duplicate patch slug surface together.
|
|
name: "duplicate names and duplicate patch slug both reported",
|
|
policies: `
|
|
policies:
|
|
- name: Same name
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: google-chrome/darwin
|
|
- name: Same name
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: google-chrome/darwin
|
|
`,
|
|
wantErrs: []string{
|
|
"duplicate policy names",
|
|
`Couldn't add multiple policies with type "patch" for "fleet_maintained_app_slug": "google-chrome/darwin".`,
|
|
},
|
|
},
|
|
{
|
|
// A dynamic install_software policy and a patch policy may share a slug.
|
|
name: "dynamic install_software and patch with the same slug is allowed",
|
|
policies: `
|
|
policies:
|
|
- name: Chrome installed
|
|
platform: darwin
|
|
query: SELECT 1 FROM apps WHERE bundle_identifier = 'com.google.Chrome';
|
|
install_software:
|
|
fleet_maintained_app_slug: google-chrome/darwin
|
|
- name: Chrome up to date
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: google-chrome/darwin
|
|
`,
|
|
},
|
|
{
|
|
name: "two patch policies with different slugs is allowed",
|
|
policies: `
|
|
policies:
|
|
- name: Chrome up to date
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: google-chrome/darwin
|
|
- name: Firefox up to date
|
|
type: patch
|
|
platform: darwin
|
|
fleet_maintained_app_slug: firefox/darwin
|
|
`,
|
|
},
|
|
{
|
|
name: "dynamic policy with base fleet_maintained_app_slug is rejected",
|
|
policies: `
|
|
policies:
|
|
- name: Install Google Chrome
|
|
type: dynamic
|
|
platform: darwin
|
|
query: "SELECT 1;"
|
|
fleet_maintained_app_slug: google-chrome/darwin
|
|
install_software: true
|
|
`,
|
|
wantErrs: []string{"fleet_maintained_app_slug is only supported for patch policies"},
|
|
},
|
|
{
|
|
name: "dynamic policy with install_software true and no slug is allowed (does nothing)",
|
|
policies: `
|
|
policies:
|
|
- name: Some dynamic policy
|
|
type: dynamic
|
|
platform: darwin
|
|
query: "SELECT 1;"
|
|
install_software: true
|
|
`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
config := getTeamConfig([]string{"policies"}) + fmaSoftware + tc.policies
|
|
path, basePath := createTempFile(t, "", config)
|
|
_, err := GitOpsFromFile(path, basePath, premiumAppConfig(), nopLogf)
|
|
if len(tc.wantErrs) == 0 {
|
|
require.NoError(t, err)
|
|
return
|
|
}
|
|
require.Error(t, err)
|
|
for _, want := range tc.wantErrs {
|
|
assert.ErrorContains(t, err, want)
|
|
}
|
|
})
|
|
}
|
|
}
|