package main import ( "context" "crypto/rand" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "slices" "strings" "testing" "time" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki" "github.com/fleetdm/fleet/v4/server/mock" mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( teamName = "Team Test" fleetServerURL = "https://fleet.example.com" orgName = "GitOps Test" ) func TestFilenameGitOpsValidation(t *testing.T) { filename := strings.Repeat("a", filenameMaxLength+1) _, err := runAppNoChecks([]string{"gitops", "-f", filename}) assert.ErrorContains(t, err, "file name must be less than") } func TestBasicGlobalFreeGitOps(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables _, ds := runServerWithMockedDS(t) ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, ) error { return nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, ) error { return nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { return nil } ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil } ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } // Mock appConfig savedAppConfig := &fleet.AppConfig{} ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil } ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { savedAppConfig = config return nil } var enrolledSecrets []*fleet.EnrollSecret ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { enrolledSecrets = secrets return nil } tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) const ( fleetServerURL = "https://fleet.example.com" orgName = "GitOps Test" ) t.Setenv("FLEET_SERVER_URL", fleetServerURL) _, err = tmpFile.WriteString( ` controls: queries: policies: agent_options: org_settings: server_settings: server_url: $FLEET_SERVER_URL org_info: contact_url: https://example.com/contact org_logo_url: "" org_logo_url_light_background: "" org_name: ${ORG_NAME} secrets: `, ) require.NoError(t, err) // No file var errWriter strings.Builder _, err = runAppNoChecks([]string{"gitops", tmpFile.Name()}) require.Error(t, err) assert.Equal(t, `Required flag "f" not set`, err.Error()) // Blank file errWriter.Reset() _, err = runAppNoChecks([]string{"gitops", "-f", ""}) require.Error(t, err) assert.Contains(t, err.Error(), "file name cannot be empty") // Bad file errWriter.Reset() _, err = runAppNoChecks([]string{"gitops", "-f", "fileDoesNotExist.yml"}) require.Error(t, err) assert.Contains(t, err.Error(), "no such file or directory") // Empty file errWriter.Reset() badFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) _, err = runAppNoChecks([]string{"gitops", "-f", badFile.Name()}) require.Error(t, err) assert.Contains(t, err.Error(), "errors occurred") // DoGitOps error t.Setenv("ORG_NAME", "") _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()}) require.Error(t, err) assert.Contains(t, err.Error(), "organization name must be present") // Dry run t.Setenv("ORG_NAME", orgName) _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") // Real run _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()}) assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) assert.Empty(t, enrolledSecrets) } func TestBasicGlobalPremiumGitOps(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} _, ds := runServerWithMockedDS( t, &service.TestServerOpts{ License: license, }, ) ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, ) error { return nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, ) error { return nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { return nil } ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil } ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } // Mock appConfig savedAppConfig := &fleet.AppConfig{} ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil } ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { savedAppConfig = config return nil } var enrolledSecrets []*fleet.EnrollSecret ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { enrolledSecrets = secrets return nil } ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { return map[string]uint{labels[0]: 1}, nil } ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { return &fleet.MDMAppleDeclaration{}, nil } ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return &fleet.Job{}, nil } ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) const ( fleetServerURL = "https://fleet.example.com" orgName = "GitOps Premium Test" ) t.Setenv("FLEET_SERVER_URL", fleetServerURL) _, err = tmpFile.WriteString( ` controls: ios_updates: deadline: "2022-02-02" minimum_version: "17.6" ipados_updates: deadline: "2023-03-03" minimum_version: "18.0" queries: policies: agent_options: org_settings: server_settings: server_url: $FLEET_SERVER_URL org_info: contact_url: https://example.com/contact org_logo_url: "" org_logo_url_light_background: "" org_name: ${ORG_NAME} secrets: software: `, ) require.NoError(t, err) // Dry run t.Setenv("ORG_NAME", orgName) _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") // Real run _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()}) assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) assert.Empty(t, enrolledSecrets) } func TestBasicTeamGitOps(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} _, ds := runServerWithMockedDS( t, &service.TestServerOpts{ License: license, }, ) const secret = "TestSecret" ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, ) error { return nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, ) error { return nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { return nil } ds.ListTeamPoliciesFunc = func( ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { return nil, nil, nil } ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } team := &fleet.Team{ ID: 1, CreatedAt: time.Now(), Name: teamName, } var savedTeam *fleet.Team ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { if name == teamName && savedTeam != nil { return savedTeam, nil } return nil, ¬FoundError{} } ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) { if savedTeam != nil && *savedTeam.Filename == filename { return savedTeam, nil } return nil, ¬FoundError{} } ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { if tid == team.ID { return savedTeam, nil } return nil, nil } var enrolledTeamSecrets []*fleet.EnrollSecret ds.NewTeamFunc = func(ctx context.Context, newTeam *fleet.Team) (*fleet.Team, error) { newTeam.ID = team.ID savedTeam = newTeam enrolledTeamSecrets = newTeam.Secrets return newTeam, nil } ds.IsEnrollSecretAvailableFunc = func(ctx context.Context, secret string, new bool, teamID *uint) (bool, error) { return true, nil } ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { savedTeam = team return team, nil } ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { require.Len(t, labels, 1) switch labels[0] { case fleet.BuiltinLabelMacOS14Plus: return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil case fleet.BuiltinLabelIOS: return map[string]uint{fleet.BuiltinLabelIOS: 2}, nil case fleet.BuiltinLabelIPadOS: return map[string]uint{fleet.BuiltinLabelIPadOS: 3}, nil default: return nil, ¬FoundError{} } } ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil } ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { enrolledTeamSecrets = secrets return nil } ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { return &fleet.MDMAppleDeclaration{}, nil } ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return &fleet.Job{}, nil } tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) t.Setenv("TEST_SECRET", "") _, err = tmpFile.WriteString( ` controls: ios_updates: deadline: "2024-10-10" minimum_version: "18.0" ipados_updates: deadline: "2025-11-11" minimum_version: "17.6" queries: policies: agent_options: name: ${TEST_TEAM_NAME} team_settings: secrets: ${TEST_SECRET} software: `, ) require.NoError(t, err) // DoGitOps error t.Setenv("TEST_TEAM_NAME", "") _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()}) require.Error(t, err) assert.Contains(t, err.Error(), "'name' is required") // Dry run t.Setenv("TEST_TEAM_NAME", teamName) _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) assert.Nil(t, savedTeam) // Real run _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()}) require.NotNil(t, savedTeam) assert.Equal(t, teamName, savedTeam.Name) assert.Empty(t, enrolledTeamSecrets) // The previous run created the team, so let's rerun with an existing team _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()}) assert.Empty(t, enrolledTeamSecrets) // Add a secret t.Setenv("TEST_SECRET", fmt.Sprintf("[{\"secret\":\"%s\"}]", secret)) _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()}) require.Len(t, enrolledTeamSecrets, 1) assert.Equal(t, secret, enrolledTeamSecrets[0].Secret) } func TestFullGlobalGitOps(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables // mdm test configuration must be set so that activating windows MDM works. testCert, testKey, err := apple_mdm.NewSCEPCACertKey() require.NoError(t, err) testCertPEM := tokenpki.PEMCertificate(testCert.Raw) testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey) fleetCfg := config.TestConfig() config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata") // License is not needed because we are not using any premium features in our config. _, ds := runServerWithMockedDS( t, &service.TestServerOpts{ MDMStorage: new(mdmmock.MDMAppleStore), MDMPusher: mockPusher{}, FleetConfig: &fleetCfg, }, ) var appliedScripts []*fleet.Script ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { appliedScripts = scripts return nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { return nil } var appliedMacProfiles []*fleet.MDMAppleConfigProfile var appliedWinProfiles []*fleet.MDMWindowsConfigProfile ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, ) error { appliedMacProfiles = macProfiles appliedWinProfiles = winProfiles return nil } ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { return nil } ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil } // Policies policy := fleet.Policy{} policy.ID = 1 policy.Name = "Policy to delete" policyDeleted := false ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return []*fleet.Policy{&policy}, nil } ds.PoliciesByIDFunc = func(ctx context.Context, ids []uint) (map[uint]*fleet.Policy, error) { if slices.Contains(ids, 1) { return map[uint]*fleet.Policy{1: &policy}, nil } return nil, nil } ds.DeleteGlobalPoliciesFunc = func(ctx context.Context, ids []uint) ([]uint, error) { policyDeleted = true assert.Equal(t, []uint{policy.ID}, ids) return ids, nil } var appliedPolicySpecs []*fleet.PolicySpec ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error { appliedPolicySpecs = specs return nil } // Queries query := fleet.Query{} query.ID = 1 query.Name = "Query to delete" queryDeleted := false ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return []*fleet.Query{&query}, nil } ds.DeleteQueriesFunc = func(ctx context.Context, ids []uint) (uint, error) { queryDeleted = true assert.Equal(t, []uint{query.ID}, ids) return 1, nil } ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) { if id == query.ID { return &query, nil } return nil, nil } var appliedQueries []*fleet.Query ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) { return nil, ¬FoundError{} } ds.ApplyQueriesFunc = func( ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{}, ) error { appliedQueries = queries return nil } // Mock appConfig savedAppConfig := &fleet.AppConfig{} ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil } ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { savedAppConfig = config return nil } ds.IsEnrollSecretAvailableFunc = func(ctx context.Context, secret string, new bool, teamID *uint) (bool, error) { return true, nil } var enrolledSecrets []*fleet.EnrollSecret ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { enrolledSecrets = secrets return nil } const ( fleetServerURL = "https://fleet.example.com" orgName = "GitOps Test" ) t.Setenv("FLEET_SERVER_URL", fleetServerURL) t.Setenv("ORG_NAME", orgName) t.Setenv("APPLE_BM_DEFAULT_TEAM", teamName) t.Setenv("SOFTWARE_INSTALLER_URL", fleetServerURL) file := "./testdata/gitops/global_config_no_paths.yml" // Dry run should fail because Apple BM Default Team does not exist and premium license is not set _, err = runAppNoChecks([]string{"gitops", "-f", file, "--dry-run"}) require.Error(t, err) assert.True(t, strings.Contains(err.Error(), "missing or invalid license")) // Dry run t.Setenv("APPLE_BM_DEFAULT_TEAM", "") _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"}) assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") assert.Len(t, enrolledSecrets, 0) assert.Len(t, appliedPolicySpecs, 0) assert.Len(t, appliedQueries, 0) assert.Len(t, appliedScripts, 0) assert.Len(t, appliedMacProfiles, 0) assert.Len(t, appliedWinProfiles, 0) // Real run _ = runAppForTest(t, []string{"gitops", "-f", file}) assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) assert.Contains(t, string(*savedAppConfig.AgentOptions), "distributed_denylist_duration") assert.Equal(t, 2000, savedAppConfig.ServerSettings.QueryReportCap) assert.Len(t, enrolledSecrets, 2) assert.True(t, policyDeleted) assert.Len(t, appliedPolicySpecs, 5) assert.True(t, queryDeleted) assert.Len(t, appliedQueries, 3) assert.Len(t, appliedScripts, 1) assert.Len(t, appliedMacProfiles, 1) assert.Len(t, appliedWinProfiles, 1) require.Len(t, savedAppConfig.Integrations.GoogleCalendar, 1) assert.Equal(t, "service@example.com", savedAppConfig.Integrations.GoogleCalendar[0].ApiKey["client_email"]) assert.True(t, savedAppConfig.ActivityExpirySettings.ActivityExpiryEnabled) assert.Equal(t, 60, savedAppConfig.ActivityExpirySettings.ActivityExpiryWindow) assert.True(t, savedAppConfig.ServerSettings.AIFeaturesDisabled) assert.True(t, savedAppConfig.WebhookSettings.ActivitiesWebhook.Enable) assert.Equal(t, "https://activities_webhook_url", savedAppConfig.WebhookSettings.ActivitiesWebhook.DestinationURL) } func TestFullTeamGitOps(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} // mdm test configuration must be set so that activating windows MDM works. testCert, testKey, err := apple_mdm.NewSCEPCACertKey() require.NoError(t, err) testCertPEM := tokenpki.PEMCertificate(testCert.Raw) testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey) fleetCfg := config.TestConfig() config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata") // License is not needed because we are not using any premium features in our config. _, ds := runServerWithMockedDS( t, &service.TestServerOpts{ License: license, MDMStorage: new(mdmmock.MDMAppleStore), MDMPusher: mockPusher{}, FleetConfig: &fleetCfg, NoCacheDatastore: true, }, ) appConfig := fleet.AppConfig{ // During dry run, the global calendar integration setting may not be set MDM: fleet.MDM{ EnabledAndConfigured: true, WindowsEnabledAndConfigured: true, }, } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &appConfig, nil } var appliedScripts []*fleet.Script ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { appliedScripts = scripts return nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { return nil } var appliedMacProfiles []*fleet.MDMAppleConfigProfile var appliedWinProfiles []*fleet.MDMWindowsConfigProfile ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, ) error { appliedMacProfiles = macProfiles appliedWinProfiles = winProfiles return nil } ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { return nil } ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil } ds.NewMDMAppleConfigProfileFunc = func(ctx context.Context, profile fleet.MDMAppleConfigProfile) (*fleet.MDMAppleConfigProfile, error) { return &profile, nil } ds.NewMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { return declaration, nil } ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { require.ElementsMatch(t, labels, []string{fleet.BuiltinLabelMacOS14Plus}) return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil } ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { declaration.DeclarationUUID = uuid.NewString() return declaration, nil } ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil } // Team var savedTeam *fleet.Team ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { if savedTeam != nil && savedTeam.Name == name { return savedTeam, nil } return nil, ¬FoundError{} } ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) { if savedTeam != nil && *savedTeam.Filename == filename { return savedTeam, nil } return nil, ¬FoundError{} } ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { if tid == savedTeam.ID { return savedTeam, nil } return nil, nil } ds.IsEnrollSecretAvailableFunc = func(ctx context.Context, secret string, new bool, teamID *uint) (bool, error) { return true, nil } const teamID = uint(123) var enrolledSecrets []*fleet.EnrollSecret ds.NewTeamFunc = func(ctx context.Context, newTeam *fleet.Team) (*fleet.Team, error) { newTeam.ID = teamID savedTeam = newTeam enrolledSecrets = newTeam.Secrets return newTeam, nil } ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { if team.ID == teamID { savedTeam = team } else { assert.Fail(t, "unexpected team ID when saving team") } return team, nil } // Policies policy := fleet.Policy{} policy.ID = 1 policy.Name = "Policy to delete" policy.TeamID = ptr.Uint(teamID) policyDeleted := false ds.ListTeamPoliciesFunc = func( ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { return []*fleet.Policy{&policy}, nil, nil } ds.PoliciesByIDFunc = func(ctx context.Context, ids []uint) (map[uint]*fleet.Policy, error) { if slices.Contains(ids, 1) { return map[uint]*fleet.Policy{1: &policy}, nil } return nil, nil } ds.DeleteTeamPoliciesFunc = func(ctx context.Context, teamID uint, IDs []uint) ([]uint, error) { policyDeleted = true assert.Equal(t, []uint{policy.ID}, IDs) return []uint{policy.ID}, nil } var appliedPolicySpecs []*fleet.PolicySpec ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error { appliedPolicySpecs = specs return nil } // Queries query := fleet.Query{} query.ID = 1 query.TeamID = ptr.Uint(teamID) query.Name = "Query to delete" queryDeleted := false ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return []*fleet.Query{&query}, nil } ds.DeleteQueriesFunc = func(ctx context.Context, ids []uint) (uint, error) { queryDeleted = true assert.Equal(t, []uint{query.ID}, ids) return 1, nil } ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) { if id == query.ID { return &query, nil } return nil, nil } var appliedQueries []*fleet.Query ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) { return nil, ¬FoundError{} } ds.ApplyQueriesFunc = func( ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{}, ) error { appliedQueries = queries return nil } ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { enrolledSecrets = secrets return nil } startSoftwareInstallerServer(t) t.Setenv("TEST_TEAM_NAME", teamName) // Dry run const baseFilename = "team_config_no_paths.yml" file := "./testdata/gitops/" + baseFilename _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"}) assert.Nil(t, savedTeam) assert.Len(t, enrolledSecrets, 0) assert.Len(t, appliedPolicySpecs, 0) assert.Len(t, appliedQueries, 0) assert.Len(t, appliedScripts, 0) assert.Len(t, appliedMacProfiles, 0) assert.Len(t, appliedWinProfiles, 0) // Real run // Setting global calendar config appConfig.Integrations = fleet.Integrations{ GoogleCalendar: []*fleet.GoogleCalendarIntegration{{}}, } _ = runAppForTest(t, []string{"gitops", "-f", file}) require.NotNil(t, savedTeam) assert.Equal(t, teamName, savedTeam.Name) assert.Contains(t, string(*savedTeam.Config.AgentOptions), "distributed_denylist_duration") assert.True(t, savedTeam.Config.Features.EnableHostUsers) assert.Equal(t, 30, savedTeam.Config.HostExpirySettings.HostExpiryWindow) assert.True(t, savedTeam.Config.MDM.EnableDiskEncryption) assert.Len(t, enrolledSecrets, 2) assert.True(t, policyDeleted) assert.Len(t, appliedPolicySpecs, 5) assert.True(t, queryDeleted) assert.Len(t, appliedQueries, 3) assert.Len(t, appliedScripts, 1) assert.Len(t, appliedMacProfiles, 1) assert.Len(t, appliedWinProfiles, 1) assert.True(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable) assert.Equal(t, "https://example.com/host_status_webhook", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL) require.NotNil(t, savedTeam.Config.Integrations.GoogleCalendar) assert.True(t, savedTeam.Config.Integrations.GoogleCalendar.Enable) assert.Equal(t, baseFilename, *savedTeam.Filename) // Change team name newTeamName := "New Team Name" t.Setenv("TEST_TEAM_NAME", newTeamName) _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"}) _ = runAppForTest(t, []string{"gitops", "-f", file}) require.NotNil(t, savedTeam) assert.Equal(t, newTeamName, savedTeam.Name) assert.Equal(t, baseFilename, *savedTeam.Filename) // Now clear the settings tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) secret := "TestSecret" t.Setenv("TEST_SECRET", secret) _, err = tmpFile.WriteString( ` controls: queries: policies: agent_options: name: ${TEST_TEAM_NAME} team_settings: secrets: [{"secret":"${TEST_SECRET}"}] software: `, ) require.NoError(t, err) // Dry run savedTeam = nil _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) assert.Nil(t, savedTeam) // Real run _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()}) require.NotNil(t, savedTeam) assert.Equal(t, newTeamName, savedTeam.Name) require.Len(t, enrolledSecrets, 1) assert.Equal(t, secret, enrolledSecrets[0].Secret) assert.False(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable) assert.Equal(t, "", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL) assert.NotNil(t, savedTeam.Config.Integrations.GoogleCalendar) assert.False(t, savedTeam.Config.Integrations.GoogleCalendar.Enable) assert.Empty(t, savedTeam.Config.Integrations.GoogleCalendar) assert.Empty(t, savedTeam.Config.MDM.MacOSSettings.CustomSettings) assert.Empty(t, savedTeam.Config.MDM.WindowsSettings.CustomSettings.Value) assert.Empty(t, savedTeam.Config.MDM.MacOSUpdates.Deadline.Value) assert.Empty(t, savedTeam.Config.MDM.MacOSUpdates.MinimumVersion.Value) assert.Empty(t, savedTeam.Config.MDM.MacOSSetup.BootstrapPackage.Value) assert.False(t, savedTeam.Config.MDM.EnableDiskEncryption) assert.Equal(t, filepath.Base(tmpFile.Name()), *savedTeam.Filename) } func TestBasicGlobalAndTeamGitOps(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} _, ds := runServerWithMockedDS( t, &service.TestServerOpts{ License: license, }, ) // Mock appConfig savedAppConfig := &fleet.AppConfig{} ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil } ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { savedAppConfig = config return nil } ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } const ( fleetServerURL = "https://fleet.example.com" orgName = "GitOps Test" secret = "TestSecret" ) var enrolledSecrets []*fleet.EnrollSecret var enrolledTeamSecrets []*fleet.EnrollSecret var savedTeam *fleet.Team team := &fleet.Team{ ID: 1, CreatedAt: time.Now(), Name: teamName, } ds.IsEnrollSecretAvailableFunc = func(ctx context.Context, secret string, new bool, teamID *uint) (bool, error) { return true, nil } ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { if teamID == nil { enrolledSecrets = secrets } else { enrolledTeamSecrets = secrets } return nil } ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, ) error { assert.Empty(t, macProfiles) assert.Empty(t, winProfiles) return nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { assert.Empty(t, scripts) return nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, ) error { assert.Empty(t, profileUUIDs) return nil } ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil } ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { require.ElementsMatch(t, labels, []string{fleet.BuiltinLabelMacOS14Plus}) return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil } ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil } ds.ListTeamPoliciesFunc = func( ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { return nil, nil, nil } ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { return nil, nil } ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { return nil } ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { job.ID = 1 return job, nil } ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { if tid == team.ID { return savedTeam, nil } return nil, nil } ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { if name == teamName && savedTeam != nil { return savedTeam, nil } return nil, ¬FoundError{} } ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) { if savedTeam != nil && *savedTeam.Filename == filename { return savedTeam, nil } return nil, ¬FoundError{} } ds.NewTeamFunc = func(ctx context.Context, newTeam *fleet.Team) (*fleet.Team, error) { newTeam.ID = team.ID savedTeam = newTeam enrolledTeamSecrets = newTeam.Secrets return newTeam, nil } ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { savedTeam = team return team, nil } ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } globalFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) t.Setenv("FLEET_SERVER_URL", fleetServerURL) t.Setenv("ORG_NAME", orgName) _, err = globalFile.WriteString( ` controls: queries: policies: agent_options: org_settings: server_settings: server_url: $FLEET_SERVER_URL org_info: contact_url: https://example.com/contact org_logo_url: "" org_logo_url_light_background: "" org_name: ${ORG_NAME} secrets: [{"secret":"globalSecret"}] software: `, ) require.NoError(t, err) teamFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) t.Setenv("TEST_TEAM_NAME", teamName) t.Setenv("TEST_SECRET", secret) _, err = teamFile.WriteString( ` controls: queries: policies: agent_options: name: ${TEST_TEAM_NAME} team_settings: secrets: [{"secret":"${TEST_SECRET}"}] software: `, ) require.NoError(t, err) teamFileDupSecret, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) _, err = teamFileDupSecret.WriteString( ` controls: queries: policies: agent_options: name: ${TEST_TEAM_NAME} team_settings: secrets: [{"secret":"${TEST_SECRET}"},{"secret":"globalSecret"}] software: `, ) require.NoError(t, err) // Files out of order _, err = runAppNoChecks([]string{"gitops", "-f", teamFile.Name(), "-f", globalFile.Name(), "--dry-run"}) require.Error(t, err) assert.True(t, strings.Contains(err.Error(), "must be the global config")) // Global file specified multiple times _, err = runAppNoChecks([]string{"gitops", "-f", globalFile.Name(), "-f", teamFile.Name(), "-f", globalFile.Name(), "--dry-run"}) require.Error(t, err) assert.True(t, strings.Contains(err.Error(), "only the first file can be the global config")) // Duplicate secret _, err = runAppNoChecks([]string{"gitops", "-f", globalFile.Name(), "-f", teamFileDupSecret.Name(), "--dry-run"}) require.Error(t, err) assert.ErrorContains(t, err, "duplicate enroll secret found") // Dry run _ = runAppForTest(t, []string{"gitops", "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}) assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") // Dry run, deleting other teams assert.False(t, ds.ListTeamsFuncInvoked) _ = runAppForTest(t, []string{"gitops", "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run", "--delete-other-teams"}) assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") assert.True(t, ds.ListTeamsFuncInvoked) // Real run _ = runAppForTest(t, []string{"gitops", "-f", globalFile.Name(), "-f", teamFile.Name()}) assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) assert.Len(t, enrolledSecrets, 1) require.NotNil(t, savedTeam) assert.Equal(t, teamName, savedTeam.Name) require.Len(t, enrolledTeamSecrets, 1) assert.Equal(t, secret, enrolledTeamSecrets[0].Secret) // Now, set up a team to delete teamToDeleteID := uint(999) teamToDelete := &fleet.Team{ ID: teamToDeleteID, CreatedAt: time.Now(), Name: "Team to delete", } ds.ListTeamsFuncInvoked = false ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { return []*fleet.Team{teamToDelete, team}, nil } ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { switch tid { case team.ID: return team, nil case teamToDeleteID: return teamToDelete, nil } assert.Fail(t, fmt.Sprintf("unexpected team ID %d", tid)) return teamToDelete, nil } ds.DeleteTeamFunc = func(ctx context.Context, tid uint) error { assert.Equal(t, teamToDeleteID, tid) return nil } ds.ListHostsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) { return nil, nil } // Real run, deleting other teams _ = runAppForTest(t, []string{"gitops", "-f", globalFile.Name(), "-f", teamFile.Name(), "--delete-other-teams"}) assert.True(t, ds.ListTeamsFuncInvoked) assert.True(t, ds.DeleteTeamFuncInvoked) } func TestFullGlobalAndTeamGitOps(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables // mdm test configuration must be set so that activating windows MDM works. ds, savedAppConfigPtr, savedTeamPtr := setupFullGitOpsPremiumServer(t) startSoftwareInstallerServer(t) var enrolledSecrets []*fleet.EnrollSecret var enrolledTeamSecrets []*fleet.EnrollSecret var appliedPolicySpecs []*fleet.PolicySpec var appliedQueries []*fleet.Query ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { if teamID == nil { enrolledSecrets = secrets } else { enrolledTeamSecrets = secrets } return nil } ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error { appliedPolicySpecs = specs return nil } ds.ApplyQueriesFunc = func( ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{}, ) error { appliedQueries = queries return nil } ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { team.ID = 1 *savedTeamPtr = team enrolledTeamSecrets = team.Secrets return *savedTeamPtr, nil } apnsCert, apnsKey, err := mysql.GenerateTestCertBytes() require.NoError(t, err) crt, key, err := apple_mdm.NewSCEPCACertKey() require.NoError(t, err) scepCert := tokenpki.PEMCertificate(crt.Raw) scepKey := tokenpki.PEMRSAPrivateKey(key) ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetCACert: {Value: scepCert}, fleet.MDMAssetCAKey: {Value: scepKey}, fleet.MDMAssetAPNSKey: {Value: apnsKey}, fleet.MDMAssetAPNSCert: {Value: apnsCert}, }, nil } ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } globalFile := "./testdata/gitops/global_config_no_paths.yml" teamFile := "./testdata/gitops/team_config_no_paths.yml" // Dry run on global file should fail because Apple BM Default Team does not exist (and has not been provided) _, err = runAppNoChecks([]string{"gitops", "-f", globalFile, "--dry-run"}) require.Error(t, err) assert.True(t, strings.Contains(err.Error(), "team name not found")) // Dry run _ = runAppForTest(t, []string{"gitops", "-f", globalFile, "-f", teamFile, "--dry-run", "--delete-other-teams"}) assert.False(t, ds.SaveAppConfigFuncInvoked) assert.Len(t, enrolledSecrets, 0) assert.Len(t, enrolledTeamSecrets, 0) assert.Len(t, appliedPolicySpecs, 0) assert.Len(t, appliedQueries, 0) // Real run _ = runAppForTest(t, []string{"gitops", "-f", globalFile, "-f", teamFile, "--delete-other-teams"}) assert.Equal(t, orgName, (*savedAppConfigPtr).OrgInfo.OrgName) assert.Equal(t, fleetServerURL, (*savedAppConfigPtr).ServerSettings.ServerURL) assert.Len(t, enrolledSecrets, 2) require.NotNil(t, *savedTeamPtr) assert.Equal(t, teamName, (*savedTeamPtr).Name) require.Len(t, enrolledTeamSecrets, 2) } func TestTeamSofwareInstallersGitOps(t *testing.T) { startSoftwareInstallerServer(t) cases := []struct { file string wantErr string }{ {"testdata/gitops/team_software_installer_not_found.yml", "Please make sure that URLs are publicy accessible to the internet."}, {"testdata/gitops/team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe or .deb."}, {"testdata/gitops/team_software_installer_too_large.yml", "The maximum file size is 500 MB"}, {"testdata/gitops/team_software_installer_valid.yml", ""}, {"testdata/gitops/team_software_installer_valid_apply.yml", ""}, {"testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."}, {"testdata/gitops/team_software_installer_pre_condition_multiple_queries_apply.yml", "should have only one query."}, {"testdata/gitops/team_software_installer_pre_condition_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"}, {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field SoftwareSpec.packages of type bool"}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { setupFullGitOpsPremiumServer(t) _, err := runAppNoChecks([]string{"gitops", "-f", c.file}) if c.wantErr == "" { require.NoError(t, err) } else { require.ErrorContains(t, err, c.wantErr) } }) } } func TestTeamSoftwareInstallersGitopsQueryEnv(t *testing.T) { startSoftwareInstallerServer(t) ds, _, _ := setupFullGitOpsPremiumServer(t) t.Setenv("QUERY_VAR", "IT_WORKS") ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { if installers[0].PreInstallQuery != "select IT_WORKS" { return fmt.Errorf("Missing env var, got %s", installers[0].PreInstallQuery) } return nil } _, err := runAppNoChecks([]string{"gitops", "-f", "testdata/gitops/team_software_installer_valid_env_query.yml"}) require.NoError(t, err) } func TestNoTeamSoftwareInstallersGitOps(t *testing.T) { startSoftwareInstallerServer(t) cases := []struct { file string wantErr string }{ {"testdata/gitops/no_team_software_installer_not_found.yml", "Please make sure that URLs are publicy accessible to the internet."}, {"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe or .deb."}, {"testdata/gitops/no_team_software_installer_too_large.yml", "The maximum file size is 500 MB"}, {"testdata/gitops/no_team_software_installer_valid.yml", ""}, {"testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."}, {"testdata/gitops/no_team_software_installer_pre_condition_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_no_url.yml", "software URL is required"}, {"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field SoftwareSpec.packages of type bool"}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { setupFullGitOpsPremiumServer(t) t.Setenv("APPLE_BM_DEFAULT_TEAM", "") _, err := runAppNoChecks([]string{"gitops", "-f", c.file}) if c.wantErr == "" { require.NoError(t, err) } else { require.ErrorContains(t, err, c.wantErr) } }) } } func TestTeamVPPAppsGitOps(t *testing.T) { config := &appleVPPConfigSrvConf{ Assets: []vpp.Asset{ { AdamID: "1", PricingParam: "STDQ", AvailableCount: 12, }, { AdamID: "2", PricingParam: "STDQ", AvailableCount: 3, }, }, SerialNumbers: []string{"123", "456"}, } startVPPApplyServer(t, config) appleITunesSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // a map of apps we can respond with db := map[string]string{ // macos app "1": `{"bundleId": "a-1", "artworkUrl512": "https://example.com/images/1", "version": "1.0.0", "trackName": "App 1", "TrackID": 1}`, // macos, ios, ipados app "2": `{"bundleId": "b-2", "artworkUrl512": "https://example.com/images/2", "version": "2.0.0", "trackName": "App 2", "TrackID": 2, "supportedDevices": ["MacDesktop-MacDesktop", "iPhone5s-iPhone5s", "iPadAir-iPadAir"] }`, // ipados app "3": `{"bundleId": "c-3", "artworkUrl512": "https://example.com/images/3", "version": "3.0.0", "trackName": "App 3", "TrackID": 3, "supportedDevices": ["iPadAir-iPadAir"] }`, } adamIDString := r.URL.Query().Get("id") adamIDs := strings.Split(adamIDString, ",") var objs []string for _, a := range adamIDs { objs = append(objs, db[a]) } _, _ = w.Write([]byte(fmt.Sprintf(`{"results": [%s]}`, strings.Join(objs, ",")))) })) t.Setenv("FLEET_DEV_ITUNES_URL", appleITunesSrv.URL) cases := []struct { file string wantErr string tokenExpiration time.Time }{ {"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour)}, {"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour)}, {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(24 * time.Hour)}, {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(-24 * time.Hour)}, {"testdata/gitops/team_vpp_valid_app.yml", "VPP token expired", time.Now().Add(-24 * time.Hour)}, {"testdata/gitops/team_vpp_invalid_app.yml", "app not available on vpp account", time.Now().Add(24 * time.Hour)}, {"testdata/gitops/team_vpp_empty_adamid.yml", "software app store id required", time.Now().Add(24 * time.Hour)}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { ds, _, _ := setupFullGitOpsPremiumServer(t) token, err := createVPPDataToken(c.tokenExpiration, "fleet", "ca") require.NoError(t, err) ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { asset := map[fleet.MDMAssetName]fleet.MDMConfigAsset{ fleet.MDMAssetVPPToken: { Name: fleet.MDMAssetVPPToken, Value: token, }, } return asset, nil } _, err = runAppNoChecks([]string{"gitops", "-f", c.file}) if c.wantErr == "" { require.NoError(t, err) } else { require.ErrorContains(t, err, c.wantErr) } }) } } func createVPPDataToken(expiration time.Time, orgName, location string) ([]byte, error) { var randBytes [32]byte _, err := rand.Read(randBytes[:]) if err != nil { return nil, fmt.Errorf("generating random bytes: %w", err) } token := base64.StdEncoding.EncodeToString(randBytes[:]) raw := fleet.VPPTokenRaw{ OrgName: orgName, Token: token, ExpDate: expiration.Format("2006-01-02T15:04:05Z0700"), } rawJson, err := json.Marshal(raw) if err != nil { return nil, fmt.Errorf("marshalling vpp raw token: %w", err) } base64Token := base64.StdEncoding.EncodeToString(rawJson) dataToken := fleet.VPPTokenData{Token: base64Token, Location: location} dataTokenJson, err := json.Marshal(dataToken) if err != nil { return nil, fmt.Errorf("marshalling vpp data token: %w", err) } return dataTokenJson, nil } func TestCustomSettingsGitOps(t *testing.T) { cases := []struct { file string wantErr string }{ {"testdata/gitops/global_macos_windows_custom_settings_valid.yml", ""}, {"testdata/gitops/global_macos_custom_settings_valid_deprecated.yml", ""}, {"testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml", `For each profile, only one of "labels_exclude_any", "labels_include_all" or "labels" can be included`}, {"testdata/gitops/global_windows_custom_settings_unknown_label.yml", `some or all the labels provided don't exist`}, {"testdata/gitops/team_macos_windows_custom_settings_valid.yml", ""}, {"testdata/gitops/team_macos_custom_settings_valid_deprecated.yml", ""}, {"testdata/gitops/team_macos_windows_custom_settings_invalid_labels_mix.yml", `For each profile, only one of "labels_exclude_any", "labels_include_all" or "labels" can be included.`}, {"testdata/gitops/team_macos_windows_custom_settings_unknown_label.yml", `some or all the labels provided don't exist`}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { ds, appCfgPtr, _ := setupFullGitOpsPremiumServer(t) (*appCfgPtr).MDM.EnabledAndConfigured = true (*appCfgPtr).MDM.WindowsEnabledAndConfigured = true labelToIDs := map[string]uint{ fleet.BuiltinLabelMacOS14Plus: 1, "A": 2, "B": 3, "C": 4, } ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { // for this test, recognize labels A, B and C (as well as the built-in macos 14+ one) ret := make(map[string]uint) for _, lbl := range labels { id, ok := labelToIDs[lbl] if ok { ret[lbl] = id } } return ret, nil } ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } _, err := runAppNoChecks([]string{"gitops", "-f", c.file}) if c.wantErr == "" { require.NoError(t, err) } else { require.ErrorContains(t, err, c.wantErr) } }) } } func startSoftwareInstallerServer(t *testing.T) { // start the web server that will serve the installer b, err := os.ReadFile(filepath.Join("..", "..", "server", "service", "testdata", "software-installers", "ruby.deb")) require.NoError(t, err) srv := httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { switch { case strings.Contains(r.URL.Path, "notfound"): w.WriteHeader(http.StatusNotFound) return case strings.HasSuffix(r.URL.Path, ".txt"): w.Header().Set("Content-Type", "text/plain") _, _ = w.Write([]byte(`a simple text file`)) return case strings.Contains(r.URL.Path, "toolarge"): w.Header().Set("Content-Type", "application/vnd.debian.binary-package") var sz int for sz < 500*1024*1024 { n, _ := w.Write(b) sz += n } default: w.Header().Set("Content-Type", "application/vnd.debian.binary-package") _, _ = w.Write(b) } }, ), ) t.Cleanup(srv.Close) t.Setenv("SOFTWARE_INSTALLER_URL", srv.URL) } type appleVPPConfigSrvConf struct { Assets []vpp.Asset SerialNumbers []string } func startVPPApplyServer(t *testing.T, config *appleVPPConfigSrvConf) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "associate") { var associations vpp.AssociateAssetsRequest decoder := json.NewDecoder(r.Body) if err := decoder.Decode(&associations); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if len(associations.Assets) == 0 { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) res := vpp.ErrorResponse{ ErrorNumber: 9718, ErrorMessage: "This request doesn't contain an asset, which is a required argument. Change the request to provide an asset.", } if err := json.NewEncoder(w).Encode(res); err != nil { panic(err) } return } if len(associations.SerialNumbers) == 0 { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) res := vpp.ErrorResponse{ ErrorNumber: 9719, ErrorMessage: "Either clientUserIds or serialNumbers are required arguments. Change the request to provide assignable users and devices.", } if err := json.NewEncoder(w).Encode(res); err != nil { panic(err) } return } var badAssets []vpp.Asset for _, reqAsset := range associations.Assets { var found bool for _, goodAsset := range config.Assets { if reqAsset == goodAsset { found = true } } if !found { badAssets = append(badAssets, reqAsset) } } var badSerials []string for _, reqSerial := range associations.SerialNumbers { var found bool for _, goodSerial := range config.SerialNumbers { if reqSerial == goodSerial { found = true } } if !found { badSerials = append(badSerials, reqSerial) } } if len(badAssets) != 0 || len(badSerials) != 0 { errMsg := "error associating assets." if len(badAssets) > 0 { var badAdamIds []string for _, asset := range badAssets { badAdamIds = append(badAdamIds, asset.AdamID) } errMsg += fmt.Sprintf(" assets don't exist on account: %s.", strings.Join(badAdamIds, ", ")) } if len(badSerials) > 0 { errMsg += fmt.Sprintf(" bad serials: %s.", strings.Join(badSerials, ", ")) } res := vpp.ErrorResponse{ ErrorInfo: vpp.ResponseErrorInfo{ Assets: badAssets, ClientUserIds: []string{"something"}, SerialNumbers: badSerials, }, // Not sure what error should be returned on each // error type ErrorNumber: 1, ErrorMessage: errMsg, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) if err := json.NewEncoder(w).Encode(res); err != nil { panic(err) } } return } if strings.Contains(r.URL.Path, "assets") { // Then we're responding to GetAssets w.Header().Set("Content-Type", "application/json") encoder := json.NewEncoder(w) err := encoder.Encode(map[string][]vpp.Asset{"assets": config.Assets}) if err != nil { panic(err) } return } resp := []byte(`{"locationName": "Fleet Location One"}`) if strings.Contains(r.URL.RawQuery, "invalidToken") { // This replicates the response sent back from Apple's VPP endpoints when an invalid // token is passed. For more details see: // https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes // https://developer.apple.com/documentation/devicemanagement/client_config // https://developer.apple.com/documentation/devicemanagement/errorresponse // Note that the Apple server returns 200 in this case. resp = []byte(`{"errorNumber": 9622,"errorMessage": "Invalid authentication token"}`) } if strings.Contains(r.URL.RawQuery, "serverError") { resp = []byte(`{"errorNumber": 9603,"errorMessage": "Internal server error"}`) w.WriteHeader(http.StatusInternalServerError) } _, _ = w.Write(resp) })) t.Setenv("FLEET_DEV_VPP_URL", srv.URL) t.Cleanup(srv.Close) } func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, **fleet.Team) { testCert, testKey, err := apple_mdm.NewSCEPCACertKey() require.NoError(t, err) testCertPEM := tokenpki.PEMCertificate(testCert.Raw) testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey) fleetCfg := config.TestConfig() config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata") license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} _, ds := runServerWithMockedDS( t, &service.TestServerOpts{ MDMStorage: new(mdmmock.MDMAppleStore), MDMPusher: mockPusher{}, FleetConfig: &fleetCfg, License: license, NoCacheDatastore: true, }, ) // Mock appConfig savedAppConfig := &fleet.AppConfig{ MDM: fleet.MDM{ EnabledAndConfigured: true, }, } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { appConfigCopy := *savedAppConfig return &appConfigCopy, nil } ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { appConfigCopy := *config savedAppConfig = &appConfigCopy return nil } ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } var savedTeam *fleet.Team ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { return nil } ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error { return nil } ds.ApplyQueriesFunc = func( ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{}, ) error { return nil } ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, ) error { return nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, ) error { return nil } ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil } ds.IsEnrollSecretAvailableFunc = func(ctx context.Context, secret string, new bool, teamID *uint) (bool, error) { return true, nil } ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { require.ElementsMatch(t, labels, []string{fleet.BuiltinLabelMacOS14Plus}) return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil } ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil } ds.ListTeamPoliciesFunc = func( ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { return nil, nil, nil } ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { if savedTeam != nil { return []*fleet.Team{savedTeam}, nil } return nil, nil } ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { return nil } ds.NewMDMAppleConfigProfileFunc = func(ctx context.Context, p fleet.MDMAppleConfigProfile) (*fleet.MDMAppleConfigProfile, error) { return nil, nil } ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { job.ID = 1 return job, nil } ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { team.ID = 1 savedTeam = team return savedTeam, nil } ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) { return nil, ¬FoundError{} } ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { if savedTeam != nil && tid == savedTeam.ID { return savedTeam, nil } return nil, ¬FoundError{} } ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { if savedTeam != nil && name == teamName { return savedTeam, nil } return nil, ¬FoundError{} } ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) { if savedTeam != nil && *savedTeam.Filename == filename { return savedTeam, nil } return nil, ¬FoundError{} } ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { savedTeam = team return team, nil } ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) ( *fleet.MDMAppleDeclaration, error, ) { declaration.DeclarationUUID = uuid.NewString() return declaration, nil } ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } t.Setenv("FLEET_SERVER_URL", fleetServerURL) t.Setenv("ORG_NAME", orgName) t.Setenv("TEST_TEAM_NAME", teamName) t.Setenv("APPLE_BM_DEFAULT_TEAM", teamName) return ds, &savedAppConfig, &savedTeam }