fleet/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go
Konstantin Sykulev 3d4a3e1b87
Added deny list for checking external user submitted urls (#39947)
This PR changes 3 things.
1. Validate `admin_url` + all URLs for HTTPS/non-private
2. Add custom `DialContext` hook in fleethttp.NewClient(), this is
needed for DNS-rebinding protection at connection time
3. Validate Smallstep SCEP challenge endpoint 

# **IMPORTANT**
There are two validations occurring.
1. `CheckURLForSSRF`
2. `SSRFDialContext`

## Why?
`CheckURLForSSRF` checks the hostname. It resolves DNS, validates the
ip, and then returns an error to the user. It protects certificate
authority create/update API endpoints. But then
`GetSmallstepSCEPChallenge` calls `http.NewRequest(http.MethodPost,
ca.ChallengeURL, ...)` with the original hostname
This is where `SSRFDialContext` comes into play. It fires when an actual
HTTP request is attempted. Meaning Fleet would first build the request,
encode the body, set up TLS, etc., before being blocked at the dial.
`CheckURLForSSRF` stops the operation before any of that work happens.
`SSRFDialContext` protects the actual challenge fetch that happens later
at enrollment time. They're not always called together. The dial-time
check is the only thing protecting the enrollment request and DNS
rebinding.

## Should we remove `CheckURLForSSRF`
This is debatable and I don't have a strong opinion. Removing
`CheckURLForSSRF` would still provide the same protection. However, it
would return a generic connection error from the HTTP client which would
make it slightly hard to diagnose why it is broken.

## What's next
I implemented this for certificate authorities. I am sure there are
other places in the code base that take user submitted urls and could
also use this check. That is outside the scope of this particular PR.
But worthy to investigate in the near future.

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.


## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Security**
* Added SSRF protections for validating external URLs and blocking
private/IP-metadata ranges; dev mode can bypass checks for local testing
* **New Features**
* Introduced an SSRF-protected HTTP transport and an option to supply a
custom transport per client
* **Tests**
* Added comprehensive tests covering SSRF validation, dialing behavior,
and resolution edge cases
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-17 17:09:52 -06:00

3486 lines
114 KiB
Go

package gitops
import (
"context"
"encoding/json"
"fmt"
"maps"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"testing"
"text/template"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/testing_utils"
"github.com/fleetdm/fleet/v4/cmd/fleetctl/integrationtest"
ma "github.com/fleetdm/fleet/v4/ee/maintained-apps"
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
"github.com/fleetdm/fleet/v4/ee/server/service/digicert"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/filesystem"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/fleet"
appleMdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/platform/logging"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/service/integrationtest/scep_server"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/go-git/go-git/v5"
kitlog "github.com/go-kit/log"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
const fleetGitopsRepo = "https://github.com/fleetdm/fleet-gitops"
func TestIntegrationsEnterpriseGitops(t *testing.T) {
testingSuite := new(enterpriseIntegrationGitopsTestSuite)
testingSuite.WithServer.Suite = &testingSuite.Suite
suite.Run(t, testingSuite)
}
type enterpriseIntegrationGitopsTestSuite struct {
suite.Suite
integrationtest.WithServer
fleetCfg config.FleetConfig
softwareTitleIconStore fleet.SoftwareTitleIconStore
}
func (s *enterpriseIntegrationGitopsTestSuite) SetupSuite() {
s.WithDS.SetupSuite("enterpriseIntegrationGitopsTestSuite")
appConf, err := s.DS.AppConfig(context.Background())
require.NoError(s.T(), err)
appConf.MDM.EnabledAndConfigured = true
appConf.MDM.AppleBMEnabledAndConfigured = true
err = s.DS.SaveAppConfig(context.Background(), appConf)
require.NoError(s.T(), err)
testCert, testKey, err := appleMdm.NewSCEPCACertKey()
require.NoError(s.T(), err)
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(s.T(), &fleetCfg, testCertPEM, testKeyPEM, "../../../../server/service/testdata")
fleetCfg.Osquery.EnrollCooldown = 0
err = s.DS.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{
{Name: fleet.MDMAssetAPNSCert, Value: testCertPEM},
{Name: fleet.MDMAssetAPNSKey, Value: testKeyPEM},
{Name: fleet.MDMAssetCACert, Value: testCertPEM},
{Name: fleet.MDMAssetCAKey, Value: testKeyPEM},
}, nil)
require.NoError(s.T(), err)
mdmStorage, err := s.DS.NewMDMAppleMDMStorage()
require.NoError(s.T(), err)
depStorage, err := s.DS.NewMDMAppleDEPStorage()
require.NoError(s.T(), err)
scepStorage, err := s.DS.NewSCEPDepot()
require.NoError(s.T(), err)
redisPool := redistest.SetupRedis(s.T(), "zz", false, false, false)
// Create a software title icon store
iconDir := s.T().TempDir()
softwareTitleIconStore, err := filesystem.NewSoftwareTitleIconStore(iconDir)
require.NoError(s.T(), err)
s.softwareTitleIconStore = softwareTitleIconStore
serverConfig := service.TestServerOpts{
License: &fleet.LicenseInfo{
Tier: fleet.TierPremium,
},
FleetConfig: &fleetCfg,
MDMStorage: mdmStorage,
DEPStorage: depStorage,
SCEPStorage: scepStorage,
Pool: redisPool,
APNSTopic: "com.apple.mgmt.External.10ac3ce5-4668-4e58-b69a-b2b5ce667589",
SCEPConfigService: eeservice.NewSCEPConfigService(kitlog.NewLogfmtLogger(os.Stdout), nil),
DigiCertService: digicert.NewService(),
SoftwareTitleIconStore: softwareTitleIconStore,
}
err = s.DS.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{
{Name: fleet.MDMAssetSCEPChallenge, Value: []byte("scepchallenge")},
}, nil)
require.NoError(s.T(), err)
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
serverConfig.Logger = logging.NewNopLogger()
}
users, server := service.RunServerForTestsWithDS(s.T(), s.DS, &serverConfig)
s.T().Setenv("FLEET_SERVER_ADDRESS", server.URL) // fleetctl always uses this env var in tests
s.Server = server
s.Users = users
s.fleetCfg = fleetCfg
appConf, err = s.DS.AppConfig(context.Background())
require.NoError(s.T(), err)
appConf.ServerSettings.ServerURL = server.URL
err = s.DS.SaveAppConfig(context.Background(), appConf)
require.NoError(s.T(), err)
}
func (s *enterpriseIntegrationGitopsTestSuite) TearDownSuite() {
appConf, err := s.DS.AppConfig(context.Background())
require.NoError(s.T(), err)
appConf.MDM.EnabledAndConfigured = false
err = s.DS.SaveAppConfig(context.Background(), appConf)
require.NoError(s.T(), err)
}
func (s *enterpriseIntegrationGitopsTestSuite) TearDownTest() {
t := s.T()
ctx := context.Background()
teams, err := s.DS.ListTeams(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.ListOptions{})
require.NoError(t, err)
for _, tm := range teams {
err := s.DS.DeleteTeam(ctx, tm.ID)
require.NoError(t, err)
}
// Clean software installers in "No team" (the others are deleted in ts.DS.DeleteTeam above).
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`)
return err
})
vppTokens, err := s.DS.ListVPPTokens(ctx)
require.NoError(t, err)
for _, tok := range vppTokens {
err := s.DS.DeleteVPPToken(ctx, tok.ID)
require.NoError(t, err)
}
mysql.ExecAdhocSQL(t, s.DS, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, "DELETE FROM vpp_apps;")
return err
})
mysql.ExecAdhocSQL(t, s.DS, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, "DELETE FROM in_house_apps;")
return err
})
lbls, err := s.DS.ListLabels(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.ListOptions{}, false)
require.NoError(t, err)
for _, lbl := range lbls {
if lbl.LabelType != fleet.LabelTypeBuiltIn {
err := s.DS.DeleteLabel(ctx, lbl.Name, fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
}
}
}
func (s *enterpriseIntegrationGitopsTestSuite) assertDryRunOutput(t *testing.T, output string) {
allowedVerbs := []string{
"moved",
"deleted",
"updated",
"applied",
"added",
"created",
"set",
}
pattern := fmt.Sprintf("\\[([+\\-!])] would've (%s)", strings.Join(allowedVerbs, "|"))
reg := regexp.MustCompile(pattern)
for line := range strings.SplitSeq(output, "\n") {
if line != "" && !strings.Contains(line, "succeeded") {
assert.Regexp(t, reg, line, "on dry run")
}
}
}
func (s *enterpriseIntegrationGitopsTestSuite) assertRealRunOutput(t *testing.T, output string) {
allowedVerbs := []string{
"moving",
"deleted",
"updated",
"applied",
"added",
"created",
"set",
"applying", // this is used when doing groups operations before the operation starts, e.g. "Applying 10 policies"
"deleting", // ditto
}
pattern := fmt.Sprintf("\\[([+\\-!])] (%s)", strings.Join(allowedVerbs, "|"))
reg := regexp.MustCompile(pattern)
for line := range strings.SplitSeq(output, "\n") {
if line != "" && !strings.Contains(line, "succeeded") {
assert.Regexp(t, reg, line, "on real run")
}
}
}
// TestFleetGitops runs `fleetctl gitops` command on configs in https://github.com/fleetdm/fleet-gitops repo.
// Changes to that repo may cause this test to fail.
func (s *enterpriseIntegrationGitopsTestSuite) TestFleetGitops() {
t := s.T()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Clone git repo
repoDir := t.TempDir()
_, err := git.PlainClone(
repoDir, false, &git.CloneOptions{
ReferenceName: "main",
SingleBranch: true,
Depth: 1,
URL: fleetGitopsRepo,
Progress: os.Stdout,
},
)
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
t.Setenv("FLEET_GLOBAL_ENROLL_SECRET", "global_enroll_secret")
t.Setenv("FLEET_WORKSTATIONS_ENROLL_SECRET", "workstations_enroll_secret")
t.Setenv("FLEET_PERSONAL_MOBILE_DEVICES_ENROLL_SECRET", "personal_mobile_devices_enroll_secret")
t.Setenv("FLEET_DEDICATED_DEVICES_ENROLL_SECRET", "dedicated_devices_enroll_secret")
t.Setenv("FLEET_EMPLOYEE_ISSUED_MOBILE_DEVICES_ENROLL_SECRET", "employee_issued_mobile_devices_enroll_secret")
t.Setenv("FLEET_IT_SERVERS_ENROLL_SECRET", "it_servers_enroll_secret")
globalFile := path.Join(repoDir, "default.yml")
teamsDir := path.Join(repoDir, "teams")
teamFiles, err := os.ReadDir(teamsDir)
require.NoError(t, err)
teamFileNames := make([]string, 0, len(teamFiles))
for _, file := range teamFiles {
if filepath.Ext(file.Name()) == ".yml" {
teamFileNames = append(teamFileNames, path.Join(teamsDir, file.Name()))
}
}
// Create a team to be deleted.
deletedTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
const deletedTeamName = "team_to_be_deleted"
_, err = deletedTeamFile.WriteString(
fmt.Sprintf(
`
controls:
software:
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"deleted_team_secret"}]
`, deletedTeamName,
),
)
require.NoError(t, err)
test.CreateInsertGlobalVPPToken(t, s.DS)
// Apply the team to be deleted
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", deletedTeamFile.Name()}))
// Dry run
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile, "--dry-run"}))
for _, fileName := range teamFileNames {
// When running no-teams, global config must also be provided ...
if strings.Contains(fileName, "no-team.yml") {
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName, "-f", globalFile, "--dry-run"}))
} else {
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName, "--dry-run"}))
}
}
// Dry run with all the files
args := []string{"gitops", "--config", fleetctlConfig.Name(), "--dry-run", "--delete-other-teams", "-f", globalFile}
for _, fileName := range teamFileNames {
args = append(args, "-f", fileName)
}
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, args))
// Real run with all the files, but don't delete other teams
args = []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile}
for _, fileName := range teamFileNames {
args = append(args, "-f", fileName)
}
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, args))
// Check that all the teams exist
teamsJSON := fleetctl.RunAppForTest(t, []string{"get", "teams", "--config", fleetctlConfig.Name(), "--json"})
assert.Equal(t, 6, strings.Count(teamsJSON, "team_id"))
// Real run with all the files, and delete other teams
args = []string{"gitops", "--config", fleetctlConfig.Name(), "--delete-other-teams", "-f", globalFile}
for _, fileName := range teamFileNames {
args = append(args, "-f", fileName)
}
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, args))
// Check that only the right teams exist
teamsJSON = fleetctl.RunAppForTest(t, []string{"get", "teams", "--config", fleetctlConfig.Name(), "--json"})
assert.Equal(t, 5, strings.Count(teamsJSON, "team_id"))
assert.NotContains(t, teamsJSON, deletedTeamName)
// Real run with one file at a time
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile}))
for _, fileName := range teamFileNames {
// When running no-teams, global config must also be provided ...
if strings.Contains(fileName, "no-team.yml") {
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName, "-f", globalFile}))
} else {
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName}))
}
}
}
func (s *enterpriseIntegrationGitopsTestSuite) createFleetctlConfig(t *testing.T, user fleet.User) *os.File {
fleetctlConfig, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
token := s.GetTestToken(user.Email, test.GoodPassword)
configStr := fmt.Sprintf(
`
contexts:
default:
address: %s
tls-skip-verify: true
token: %s
`, s.Server.URL, token,
)
_, err = fleetctlConfig.WriteString(configStr)
require.NoError(t, err)
return fleetctlConfig
}
func (s *enterpriseIntegrationGitopsTestSuite) createGitOpsUser(t *testing.T) fleet.User {
user := fleet.User{
Name: "GitOps User " + uuid.NewString(),
Email: uuid.NewString() + "@example.com",
GlobalRole: ptr.String(fleet.RoleGitOps),
}
require.NoError(t, user.SetPassword(test.GoodPassword, 10, 10))
_, err := s.DS.NewUser(context.Background(), &user)
require.NoError(t, err)
return user
}
// TestDeleteMacOSSetup tests the deletion of macOS setup assets by `fleetctl gitops` command.
func (s *enterpriseIntegrationGitopsTestSuite) TestDeleteMacOSSetup() {
t := s.T()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(`
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`)
require.NoError(t, err)
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(
fmt.Sprintf(
`
controls:
software:
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
`, teamName,
),
)
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// Apply configs
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
// Add bootstrap packages
require.NoError(t, s.DS.InsertMDMAppleBootstrapPackage(context.Background(), &fleet.MDMAppleBootstrapPackage{
Name: "bootstrap.pkg",
TeamID: 0,
Bytes: []byte("bootstrap package"),
Token: uuid.NewString(),
Sha256: []byte("sha256"),
}, nil))
team, err := s.DS.TeamByName(context.Background(), teamName)
require.NoError(t, err)
t.Cleanup(func() {
_ = s.DS.DeleteTeam(context.Background(), team.ID)
})
require.NoError(t, s.DS.InsertMDMAppleBootstrapPackage(context.Background(), &fleet.MDMAppleBootstrapPackage{
Name: "bootstrap.pkg",
TeamID: team.ID,
Bytes: []byte("bootstrap package"),
Token: uuid.NewString(),
Sha256: []byte("sha256"),
}, nil))
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
stmt := "SELECT COUNT(*) FROM mdm_apple_bootstrap_packages WHERE team_id IN (?, ?)"
var result int
require.NoError(t, sqlx.GetContext(context.Background(), q, &result, stmt, 0, team.ID))
assert.Equal(t, 2, result)
return nil
})
// Add enrollment profiles
_, err = s.DS.SetOrUpdateMDMAppleSetupAssistant(context.Background(), &fleet.MDMAppleSetupAssistant{
TeamID: nil,
Name: "enrollment_profile.json",
Profile: []byte(`{"foo":"bar"}`),
})
require.NoError(t, err)
_, err = s.DS.SetOrUpdateMDMAppleSetupAssistant(context.Background(), &fleet.MDMAppleSetupAssistant{
TeamID: &team.ID,
Name: "enrollment_profile.json",
Profile: []byte(`{"foo":"bar"}`),
})
require.NoError(t, err)
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
stmt := "SELECT COUNT(*) FROM mdm_apple_setup_assistants WHERE global_or_team_id IN (?, ?)"
var result int
require.NoError(t, sqlx.GetContext(context.Background(), q, &result, stmt, 0, team.ID))
assert.Equal(t, 2, result)
return nil
})
// Re-apply configs and expect the macOS setup assets to be cleared
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
stmt := "SELECT COUNT(*) FROM mdm_apple_bootstrap_packages WHERE team_id IN (?, ?)"
var result int
require.NoError(t, sqlx.GetContext(context.Background(), q, &result, stmt, 0, team.ID))
assert.Equal(t, 0, result)
return nil
})
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
stmt := "SELECT COUNT(*) FROM mdm_apple_setup_assistants WHERE global_or_team_id IN (?, ?)"
var result int
require.NoError(t, sqlx.GetContext(context.Background(), q, &result, stmt, 0, team.ID))
assert.Equal(t, 0, result)
return nil
})
}
// TestCAIntegrations enables DigiCert and Custom SCEP CAs via GitOps.
// At the same time, GitOps uploads Apple profiles that use the newly configured CAs.
func (s *enterpriseIntegrationGitopsTestSuite) TestCAIntegrations() {
t := s.T()
dev_mode.IsEnabled = true
t.Cleanup(func() { dev_mode.IsEnabled = false })
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
var (
gotProfileMu sync.Mutex
gotProfile bool
)
digiCertServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
matches := regexp.MustCompile(`^/mpki/api/v2/profile/([a-zA-Z0-9_-]+)$`).FindStringSubmatch(r.URL.Path)
if len(matches) != 2 {
w.WriteHeader(http.StatusBadRequest)
return
}
profileID := matches[1]
resp := map[string]string{
"id": profileID,
"name": "DigiCert",
"status": "Active",
}
err := json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
gotProfileMu.Lock()
gotProfile = profileID == "digicert_profile_id"
defer gotProfileMu.Unlock()
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}))
t.Cleanup(digiCertServer.Close)
scepServer := scep_server.StartTestSCEPServer(t)
// Get the path to the directory of this test file
_, currentFile, _, ok := runtime.Caller(0)
require.True(t, ok, "failed to get runtime caller info")
dirPath := filepath.Dir(currentFile)
// Resolve ../../fleetctl relative to the source file directory
dirPath = filepath.Join(dirPath, "../../fleetctl")
// Clean and convert to absolute path
dirPath, err := filepath.Abs(filepath.Clean(dirPath))
require.NoError(t, err)
apiToken := "digicert_api_token" // nolint:gosec // G101: Potential hardcoded credentials
profileID := "digicert_profile_id"
certCN := "digicert_cn"
certSeatID := "digicert_seat_id"
_, err = s.DS.NewCertificateAuthority(t.Context(), &fleet.CertificateAuthority{
Type: string(fleet.CATypeDigiCert),
Name: ptr.String("DigiCert"),
URL: &digiCertServer.URL,
APIToken: &apiToken,
ProfileID: &profileID,
CertificateCommonName: &certCN,
CertificateUserPrincipalNames: &[]string{"digicert_upn"},
CertificateSeatID: &certSeatID,
})
require.NoError(t, err)
challenge := "challenge"
_, err = s.DS.NewCertificateAuthority(t.Context(), &fleet.CertificateAuthority{
Type: string(fleet.CATypeCustomSCEPProxy),
Name: ptr.String("CustomScepProxy"),
URL: &scepServer.URL,
Challenge: &challenge,
})
require.NoError(t, err)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(fmt.Sprintf(`
agent_options:
controls:
macos_settings:
custom_settings:
- path: %s/testdata/gitops/lib/scep-and-digicert.mobileconfig
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
certificate_authorities:
digicert:
- name: DigiCert
url: %s
api_token: digicert_api_token
profile_id: digicert_profile_id
certificate_common_name: digicert_cn
certificate_user_principal_names: ["digicert_upn"]
certificate_seat_id: digicert_seat_id
custom_scep_proxy:
- name: CustomScepProxy
url: %s
challenge: challenge
policies:
queries:
`,
dirPath,
digiCertServer.URL,
scepServer.URL+"/scep",
))
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// Apply configs
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
groupedCAs, err := s.DS.GetGroupedCertificateAuthorities(t.Context(), false)
require.NoError(t, err)
// check digicert
require.Len(t, groupedCAs.DigiCert, 1)
digicertCA := groupedCAs.DigiCert[0]
require.Equal(t, "DigiCert", digicertCA.Name)
require.Equal(t, digiCertServer.URL, digicertCA.URL)
require.Equal(t, fleet.MaskedPassword, digicertCA.APIToken)
require.Equal(t, "digicert_profile_id", digicertCA.ProfileID)
require.Equal(t, "digicert_cn", digicertCA.CertificateCommonName)
require.Equal(t, []string{"digicert_upn"}, digicertCA.CertificateUserPrincipalNames)
require.Equal(t, "digicert_seat_id", digicertCA.CertificateSeatID)
gotProfileMu.Lock()
require.False(t, gotProfile) // external digicert service was NOT called because stored config was not modified
gotProfileMu.Unlock()
// check custom SCEP proxy
require.Len(t, groupedCAs.CustomScepProxy, 1)
customSCEPProxyCA := groupedCAs.CustomScepProxy[0]
require.Equal(t, "CustomScepProxy", customSCEPProxyCA.Name)
require.Equal(t, scepServer.URL+"/scep", customSCEPProxyCA.URL)
require.Equal(t, fleet.MaskedPassword, customSCEPProxyCA.Challenge)
profiles, _, err := s.DS.ListMDMConfigProfiles(context.Background(), nil, fleet.ListOptions{})
require.NoError(t, err)
assert.Len(t, profiles, 1)
// now modify the stored config and confirm that external digicert service is called
_, err = globalFile.WriteString(fmt.Sprintf(`
agent_options:
controls:
macos_settings:
custom_settings:
- path: %s/testdata/gitops/lib/scep-and-digicert.mobileconfig
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
certificate_authorities:
digicert:
- name: DigiCert
url: %s
api_token: digicert_api_token
profile_id: digicert_profile_id
certificate_common_name: digicert_cn
certificate_user_principal_names: [%q]
certificate_seat_id: digicert_seat_id
custom_scep_proxy:
- name: CustomScepProxy
url: %s
challenge: challenge
policies:
queries:
`,
dirPath,
digiCertServer.URL,
"digicert_upn_2", // minor modification to stored config so gitops run is not a no-op and triggers call to external digicert service
scepServer.URL+"/scep",
))
require.NoError(t, err)
// Apply configs
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
groupedCAs, err = s.DS.GetGroupedCertificateAuthorities(t.Context(), false)
require.NoError(t, err)
// check digicert
require.Len(t, groupedCAs.DigiCert, 1)
digicertCA = groupedCAs.DigiCert[0]
require.Equal(t, "DigiCert", digicertCA.Name)
require.Equal(t, digiCertServer.URL, digicertCA.URL)
require.Equal(t, fleet.MaskedPassword, digicertCA.APIToken)
require.Equal(t, "digicert_profile_id", digicertCA.ProfileID)
require.Equal(t, "digicert_cn", digicertCA.CertificateCommonName)
require.Equal(t, []string{"digicert_upn_2"}, digicertCA.CertificateUserPrincipalNames)
require.Equal(t, "digicert_seat_id", digicertCA.CertificateSeatID)
gotProfileMu.Lock()
require.True(t, gotProfile) // external digicert service was called because stored config was modified
gotProfileMu.Unlock()
// Now test that we can clear the configs
_, err = globalFile.WriteString(`
agent_options:
controls:
macos_settings:
custom_settings:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`)
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
groupedCAs, err = s.DS.GetGroupedCertificateAuthorities(t.Context(), true)
require.NoError(t, err)
assert.Empty(t, groupedCAs.DigiCert)
assert.Empty(t, groupedCAs.CustomScepProxy)
}
// TestUnsetConfigurationProfileLabels tests the removal of labels associated with a
// configuration profile via gitops.
func (s *enterpriseIntegrationGitopsTestSuite) TestUnsetConfigurationProfileLabels() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
lbl, err := s.DS.NewLabel(ctx, &fleet.Label{Name: "Label1", Query: "SELECT 1"})
require.NoError(t, err)
require.NotZero(t, lbl.ID)
profileFile, err := os.CreateTemp(t.TempDir(), "*.mobileconfig")
require.NoError(t, err)
_, err = profileFile.WriteString(test.GenerateMDMAppleProfile("test", "test", uuid.NewString()))
require.NoError(t, err)
err = profileFile.Close()
require.NoError(t, err)
const (
globalTemplate = `
agent_options:
controls:
macos_settings:
custom_settings:
- path: %s
%s
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`
withLabelsIncludeAny = `
labels_include_any:
- Label1
`
emptyLabelsIncludeAny = `
labels_include_any:
`
teamTemplate = `
controls:
macos_settings:
custom_settings:
- path: %s
%s
software:
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
`
withLabelsIncludeAll = `
labels_include_all:
- Label1
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(fmt.Sprintf(globalTemplate, profileFile.Name(), withLabelsIncludeAny))
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, profileFile.Name(), withLabelsIncludeAll, teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// Apply configs
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
// get the team ID
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// the custom setting is scoped by the label for no team
profs, _, err := s.DS.ListMDMConfigProfiles(ctx, nil, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, profs, 1)
require.Len(t, profs[0].LabelsIncludeAny, 1)
require.Equal(t, "Label1", profs[0].LabelsIncludeAny[0].LabelName)
// the custom setting is scoped by the label for team
profs, _, err = s.DS.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, profs, 1)
require.Len(t, profs[0].LabelsIncludeAll, 1)
require.Equal(t, "Label1", profs[0].LabelsIncludeAll[0].LabelName)
// remove the label conditions
err = os.WriteFile(globalFile.Name(), []byte(fmt.Sprintf(globalTemplate, profileFile.Name(), emptyLabelsIncludeAny)), 0o644)
require.NoError(t, err)
err = os.WriteFile(teamFile.Name(), []byte(fmt.Sprintf(teamTemplate, profileFile.Name(), "", teamName)), 0o644)
require.NoError(t, err)
// Apply configs
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
// the custom setting is not scoped by label anymore
profs, _, err = s.DS.ListMDMConfigProfiles(ctx, nil, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, profs, 1)
require.Len(t, profs[0].LabelsIncludeAny, 0)
profs, _, err = s.DS.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, profs, 1)
require.Len(t, profs[0].LabelsIncludeAll, 0)
}
// TestUnsetSoftwareInstallerLabels tests the removal of labels associated with a
// software installer via gitops.
func (s *enterpriseIntegrationGitopsTestSuite) TestUnsetSoftwareInstallerLabels() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
lbl, err := s.DS.NewLabel(ctx, &fleet.Label{Name: "Label1", Query: "SELECT 1"})
require.NoError(t, err)
require.NotZero(t, lbl.ID)
const (
globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`
noTeamTemplate = `name: No team
controls:
policies:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
%s
`
withLabelsIncludeAny = `
labels_include_any:
- Label1
`
emptyLabelsIncludeAny = `
labels_include_any:
`
teamTemplate = `
controls:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
%s
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
`
withLabelsExcludeAny = `
labels_exclude_any:
- Label1
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(fmt.Sprintf(noTeamTemplate, withLabelsIncludeAny))
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "no-team.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, withLabelsExcludeAny, teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
// Apply configs
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()}))
// get the team ID
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// the installer is scoped by the label for no team
titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 1)
require.NotNil(t, titles[0].SoftwarePackage)
noTeamTitleID := titles[0].ID
meta, err := s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, noTeamTitleID, false)
require.NoError(t, err)
require.Len(t, meta.LabelsIncludeAny, 1)
require.Equal(t, "Label1", meta.LabelsIncludeAny[0].LabelName)
// the installer is scoped by the label for team
titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID}, fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 1)
require.NotNil(t, titles[0].SoftwarePackage)
teamTitleID := titles[0].ID
meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, teamTitleID, false)
require.NoError(t, err)
require.Len(t, meta.LabelsExcludeAny, 1)
require.Equal(t, "Label1", meta.LabelsExcludeAny[0].LabelName)
// remove the label conditions
err = os.WriteFile(noTeamFilePath, []byte(fmt.Sprintf(noTeamTemplate, emptyLabelsIncludeAny)), 0o644)
require.NoError(t, err)
err = os.WriteFile(teamFile.Name(), []byte(fmt.Sprintf(teamTemplate, "", teamName)), 0o644)
require.NoError(t, err)
// Apply configs
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()}))
// the installer is not scoped by label anymore
meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, noTeamTitleID, false)
require.NoError(t, err)
require.NotNil(t, meta.TitleID)
require.Equal(t, noTeamTitleID, *meta.TitleID)
require.Len(t, meta.LabelsExcludeAny, 0)
require.Len(t, meta.LabelsIncludeAny, 0)
meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, teamTitleID, false)
require.NoError(t, err)
require.NotNil(t, meta.TitleID)
require.Equal(t, teamTitleID, *meta.TitleID)
require.Len(t, meta.LabelsExcludeAny, 0)
require.Len(t, meta.LabelsIncludeAny, 0)
}
func (s *enterpriseIntegrationGitopsTestSuite) TestDeletingNoTeamYAML() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// global file setup
const (
globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
// setup script
const testScriptTemplate = `echo "Hello, world!"`
scriptFile, err := os.CreateTemp(t.TempDir(), "*.sh")
require.NoError(t, err)
_, err = scriptFile.WriteString(testScriptTemplate)
require.NoError(t, err)
err = scriptFile.Close()
require.NoError(t, err)
// no team file setup
const (
noTeamTemplate = `name: No team
policies:
controls:
macos_setup:
script: %s
software:
`
)
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(fmt.Sprintf(noTeamTemplate, scriptFile.Name()))
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "no-team.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath}))
// Check script existance
_, err = s.DS.GetSetupExperienceScript(ctx, nil)
require.NoError(t, err)
_ = fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"})
_ = fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()})
// Check script does not exist
_, err = s.DS.GetSetupExperienceScript(ctx, nil)
var nfe fleet.NotFoundError
require.ErrorAs(t, err, &nfe)
}
// Helper function to get No Team webhook settings from the database
func getNoTeamWebhookSettings(ctx context.Context, t *testing.T, ds *mysql.Datastore) fleet.FailingPoliciesWebhookSettings {
cfg, err := ds.DefaultTeamConfig(ctx)
require.NoError(t, err)
require.NotNil(t, cfg)
return cfg.WebhookSettings.FailingPoliciesWebhook
}
// Helper function to verify No Team webhook settings match expected values
func verifyNoTeamWebhookSettings(ctx context.Context, t *testing.T, ds *mysql.Datastore, expected fleet.FailingPoliciesWebhookSettings) {
actual := getNoTeamWebhookSettings(ctx, t, ds)
require.Equal(t, expected.Enable, actual.Enable)
if expected.Enable {
require.Equal(t, expected.DestinationURL, actual.DestinationURL)
require.Equal(t, expected.HostBatchSize, actual.HostBatchSize)
require.ElementsMatch(t, expected.PolicyIDs, actual.PolicyIDs)
}
}
func (s *enterpriseIntegrationGitopsTestSuite) TestNoTeamWebhookSettings() {
t := s.T()
ctx := t.Context()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
var webhookSettings fleet.FailingPoliciesWebhookSettings
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// Create a global config file
const globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
- secret: global_secret
policies:
queries:
`
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
// Create a no-team.yml file with webhook settings
const noTeamTemplateWithWebhook = `
name: No team
policies:
- name: No Team Test Policy
query: SELECT 1 FROM osquery_info WHERE version = '0.0.0';
description: Test policy for no team
resolution: This is a test
controls:
software:
team_settings:
webhook_settings:
failing_policies_webhook:
enable_failing_policies_webhook: true
destination_url: https://example.com/no-team-webhook
host_batch_size: 50
policy_ids:
- 1
- 2
- 3
`
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(noTeamTemplateWithWebhook)
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "no-team.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
// Test dry-run first
output := fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "--dry-run"})
s.assertDryRunOutput(t, output)
// Check that webhook settings are mentioned in the output
require.Contains(t, output, "would've applied webhook settings for 'No team'")
// Apply the configuration (non-dry-run)
output = fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath})
s.assertRealRunOutput(t, output)
// Verify the output mentions webhook settings were applied
require.Contains(t, output, "applying webhook settings for 'No team'")
require.Contains(t, output, "applied webhook settings for 'No team'")
// Verify webhook settings were actually applied by checking the database
verifyNoTeamWebhookSettings(ctx, t, s.DS, fleet.FailingPoliciesWebhookSettings{
Enable: true,
DestinationURL: "https://example.com/no-team-webhook",
HostBatchSize: 50,
PolicyIDs: []uint{1, 2, 3},
})
// Test updating webhook settings
const noTeamTemplateUpdatedWebhook = `
name: No team
policies:
- name: No Team Test Policy
query: SELECT 1 FROM osquery_info WHERE version = '0.0.0';
description: Test policy for no team
resolution: This is a test
controls:
software:
team_settings:
webhook_settings:
failing_policies_webhook:
enable_failing_policies_webhook: false
destination_url: https://updated.example.com/webhook
host_batch_size: 100
policy_ids:
- 4
- 5
`
noTeamFileUpdated, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFileUpdated.WriteString(noTeamTemplateUpdatedWebhook)
require.NoError(t, err)
err = noTeamFileUpdated.Close()
require.NoError(t, err)
noTeamFilePathUpdated := filepath.Join(filepath.Dir(noTeamFileUpdated.Name()), "no-team.yml")
err = os.Rename(noTeamFileUpdated.Name(), noTeamFilePathUpdated)
require.NoError(t, err)
// Apply the updated configuration
output = fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePathUpdated})
// Verify the output still mentions webhook settings were applied
require.Contains(t, output, "applying webhook settings for 'No team'")
require.Contains(t, output, "applied webhook settings for 'No team'")
// Verify webhook settings were updated
verifyNoTeamWebhookSettings(ctx, t, s.DS, fleet.FailingPoliciesWebhookSettings{
Enable: false,
DestinationURL: "https://updated.example.com/webhook",
HostBatchSize: 100,
PolicyIDs: []uint{4, 5},
})
// Test removing webhook settings entirely
const noTeamTemplateNoWebhook = `
name: No team
policies:
- name: No Team Test Policy
query: SELECT 1 FROM osquery_info WHERE version = '0.0.0';
description: Test policy for no team
resolution: This is a test
controls:
software:
`
noTeamFileNoWebhook, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFileNoWebhook.WriteString(noTeamTemplateNoWebhook)
require.NoError(t, err)
err = noTeamFileNoWebhook.Close()
require.NoError(t, err)
noTeamFilePathNoWebhook := filepath.Join(filepath.Dir(noTeamFileNoWebhook.Name()), "no-team.yml")
err = os.Rename(noTeamFileNoWebhook.Name(), noTeamFilePathNoWebhook)
require.NoError(t, err)
// Apply configuration without webhook settings
output = fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePathNoWebhook})
// Verify webhook settings are mentioned as being applied (they're applied as nil to clear)
require.Contains(t, output, "applying webhook settings for 'No team'")
require.Contains(t, output, "applied webhook settings for 'No team'")
// Verify webhook settings were cleared
verifyNoTeamWebhookSettings(ctx, t, s.DS, fleet.FailingPoliciesWebhookSettings{
Enable: false,
})
// Test case: team_settings exists but webhook_settings is nil
// First, set webhook settings again
output = fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath})
require.Contains(t, output, "applied webhook settings for 'No team'")
// Verify webhook was set
webhookSettings = getNoTeamWebhookSettings(ctx, t, s.DS)
require.True(t, webhookSettings.Enable)
// Now apply config with team_settings but no webhook_settings
const noTeamTemplateTeamSettingsNoWebhook = `
name: No team
policies:
- name: No Team Test Policy
query: SELECT 1 FROM osquery_info WHERE version = '0.0.0';
description: Test policy for no team
resolution: This is a test
controls:
software:
team_settings:
`
noTeamFileTeamNoWebhook, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFileTeamNoWebhook.WriteString(noTeamTemplateTeamSettingsNoWebhook)
require.NoError(t, err)
err = noTeamFileTeamNoWebhook.Close()
require.NoError(t, err)
noTeamFilePathTeamNoWebhook := filepath.Join(filepath.Dir(noTeamFileTeamNoWebhook.Name()), "no-team.yml")
err = os.Rename(noTeamFileTeamNoWebhook.Name(), noTeamFilePathTeamNoWebhook)
require.NoError(t, err)
// Apply configuration with team_settings but no webhook_settings
output = fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePathTeamNoWebhook})
// Verify webhook settings are cleared
require.Contains(t, output, "applying webhook settings for 'No team'")
require.Contains(t, output, "applied webhook settings for 'No team'")
// Verify webhook settings are disabled
verifyNoTeamWebhookSettings(ctx, t, s.DS, fleet.FailingPoliciesWebhookSettings{
Enable: false,
})
// Test case: webhook_settings exists but failing_policies_webhook is nil
// First, set webhook settings again
output = fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath})
require.Contains(t, output, "applied webhook settings for 'No team'")
// Verify webhook was set
webhookSettings = getNoTeamWebhookSettings(ctx, t, s.DS)
require.True(t, webhookSettings.Enable)
// Now apply config with webhook_settings but no failing_policies_webhook
const noTeamTemplateWebhookNoFailing = `
name: No team
policies:
- name: No Team Test Policy
query: SELECT 1 FROM osquery_info WHERE version = '0.0.0';
description: Test policy for no team
resolution: This is a test
controls:
software:
team_settings:
webhook_settings:
`
noTeamFileWebhookNoFailing, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFileWebhookNoFailing.WriteString(noTeamTemplateWebhookNoFailing)
require.NoError(t, err)
err = noTeamFileWebhookNoFailing.Close()
require.NoError(t, err)
noTeamFilePathWebhookNoFailing := filepath.Join(filepath.Dir(noTeamFileWebhookNoFailing.Name()), "no-team.yml")
err = os.Rename(noTeamFileWebhookNoFailing.Name(), noTeamFilePathWebhookNoFailing)
require.NoError(t, err)
// Apply configuration with webhook_settings but no failing_policies_webhook
output = fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePathWebhookNoFailing})
// Verify webhook settings are cleared
require.Contains(t, output, "applying webhook settings for 'No team'")
require.Contains(t, output, "applied webhook settings for 'No team'")
// Verify webhook settings are disabled
verifyNoTeamWebhookSettings(ctx, t, s.DS, fleet.FailingPoliciesWebhookSettings{
Enable: false,
})
}
func (s *enterpriseIntegrationGitopsTestSuite) TestRemoveCustomSettingsFromDefaultYAML() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// setup custom settings profile
profileFile, err := os.CreateTemp(t.TempDir(), "*.mobileconfig")
require.NoError(t, err)
_, err = profileFile.WriteString(test.GenerateMDMAppleProfile("test", "test", uuid.NewString()))
require.NoError(t, err)
err = profileFile.Close()
require.NoError(t, err)
// global file setup with custom settings
const (
globalTemplateWithCustomSettings = `
agent_options:
controls:
macos_settings:
custom_settings:
- path: %s
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(fmt.Sprintf(globalTemplateWithCustomSettings, profileFile.Name()))
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
profiles, err := s.DS.ListMDMAppleConfigProfiles(ctx, nil)
require.NoError(t, err)
require.Equal(t, 1, len(profiles))
// global file setup without custom settings
const (
globalTemplateWithoutCustomSettings = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`
)
globalFile, err = os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplateWithoutCustomSettings)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
// Check profile does not exist
profiles, err = s.DS.ListMDMAppleConfigProfiles(ctx, nil)
require.NoError(t, err)
require.Equal(t, 0, len(profiles))
}
func (s *enterpriseIntegrationGitopsTestSuite) TestMacOSSetup() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
const (
globalConfig = `
agent_options:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`
globalConfigOnly = `
agent_options:
controls:
macos_setup:
manual_agent_install: %t
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`
noTeamConfig = `name: No team
controls:
macos_setup:
manual_agent_install: true
policies:
software:
`
teamConfig = `
controls:
macos_setup:
manual_agent_install: %t
software:
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalConfig)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(noTeamConfig)
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "no-team.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamConfig, true, teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
teamFileClear, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFileClear.WriteString(fmt.Sprintf(teamConfig, false, teamName))
require.NoError(t, err)
err = teamFileClear.Close()
require.NoError(t, err)
globalFileOnlySet, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFileOnlySet.WriteString(fmt.Sprintf(globalConfigOnly, true))
require.NoError(t, err)
err = globalFileOnlySet.Close()
require.NoError(t, err)
globalFileOnlyClear, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFileOnlyClear.WriteString(fmt.Sprintf(globalConfigOnly, false))
require.NoError(t, err)
err = globalFileOnlyClear.Close()
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
// Apply configs
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()}))
appConfig, err := s.DS.AppConfig(ctx)
require.NoError(t, err)
assert.True(t, appConfig.MDM.MacOSSetup.ManualAgentInstall.Value)
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
assert.True(t, team.Config.MDM.MacOSSetup.ManualAgentInstall.Value)
// Apply global configs without no-team
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileOnlyClear.Name(), "-f", teamFileClear.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileOnlyClear.Name(), "-f", teamFileClear.Name()}))
appConfig, err = s.DS.AppConfig(ctx)
require.NoError(t, err)
assert.False(t, appConfig.MDM.MacOSSetup.ManualAgentInstall.Value)
team, err = s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
assert.False(t, team.Config.MDM.MacOSSetup.ManualAgentInstall.Value)
// Apply global configs only
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t,
[]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileOnlySet.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFileOnlySet.Name()}))
appConfig, err = s.DS.AppConfig(ctx)
require.NoError(t, err)
assert.True(t, appConfig.MDM.MacOSSetup.ManualAgentInstall.Value)
}
func (s *enterpriseIntegrationGitopsTestSuite) TestFleetGitOpsDeletesNonManagedLabels() {
t := s.T()
ctx := context.Background()
// Set the required environment variables
t.Setenv("FLEET_SERVER_URL", s.Server.URL)
t.Setenv("ORG_NAME", "Around the block")
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
var someUser fleet.User
for _, u := range s.Users {
someUser = u
break
}
// 'nonManagedLabel' is associated with a software installer and is
// not managed in the ops file so it should be deleted.
nonManagedLabel, err := s.DS.NewLabel(ctx, &fleet.Label{
Name: t.Name(),
Query: "bye bye label",
})
require.NoError(t, err)
installer, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
require.NoError(t, err)
_, _, err = s.DS.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install zoo",
InstallerFile: installer,
StorageID: uuid.NewString(),
Filename: "zoo.pkg",
Title: "zoo",
Source: "apps",
Version: "0.0.1",
UserID: someUser.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{nonManagedLabel.Name: {
LabelID: nonManagedLabel.ID,
LabelName: nonManagedLabel.Name,
}},
},
})
require.NoError(t, err)
opsFile := path.Join("..", "..", "fleetctl", "testdata", "gitops", "global_config_no_paths.yml")
_ = fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", opsFile})
// Check label was removed successfully
result, err := s.DS.LabelIDsByName(ctx, []string{nonManagedLabel.Name}, fleet.TeamFilter{})
require.NoError(t, err)
require.Empty(t, result)
}
func (s *enterpriseIntegrationGitopsTestSuite) TestMacOSSetupScriptWithFleetSecret() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
const secretName = "MY_SECRET"
const secretValue = "my-secret-value"
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
t.Setenv("FLEET_SECRET_"+secretName, secretValue)
// Create a script file that uses the fleet secret
scriptFile, err := os.CreateTemp(t.TempDir(), "*.sh")
require.NoError(t, err)
_, err = scriptFile.WriteString(`echo "Using secret: $FLEET_SECRET_` + secretName)
require.NoError(t, err)
err = scriptFile.Close()
require.NoError(t, err)
// Create a no-team file with the script
const noTeamTemplate = `name: No team
policies:
controls:
macos_setup:
script: %s
software:
`
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(fmt.Sprintf(noTeamTemplate, scriptFile.Name()))
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "no-team.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
// Create a global file
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(`
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`)
require.NoError(t, err)
// Apply the configs
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath}))
// Verify the script was saved
_, err = s.DS.GetSetupExperienceScript(ctx, nil)
require.NoError(t, err)
// Verify the secret was saved
secretVariables, err := s.DS.GetSecretVariables(ctx, []string{secretName})
require.NoError(t, err)
require.Equal(t, secretVariables[0].Name, secretName)
require.Equal(t, secretVariables[0].Value, secretValue)
}
// TestEnvSubstitutionInProfiles tests that only FLEET_SECRET_ prefixed env vars are saved as secrets
func (s *enterpriseIntegrationGitopsTestSuite) TestEnvSubstitutionInProfiles() {
t := s.T()
ctx := t.Context()
tempDir := t.TempDir()
// Create a test configuration profile with both valid and invalid secret references
profileContent := `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>Test Profile</string>
<key>PayloadIdentifier</key>
<string>com.fleet.test.env</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>12345678-1234-1234-1234-123456789012</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>TestSecretValue</key>
<string>$FLEET_SECRET_TEST_SECRET</string>
<key>TestInvalidSecret</key>
<string>$FLEET_DUO_CERTIFICATE_SECRET</string>
<key>TestPlainValue</key>
<string>$HOME</string>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>Test Profile</string>
<key>PayloadIdentifier</key>
<string>com.fleet.test.env</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>12345678-1234-1234-1234-123456789012</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`
// Write the profile to a file
profilePath := filepath.Join(tempDir, "test-profile.mobileconfig")
err := os.WriteFile(profilePath, []byte(profileContent), 0o644) //nolint:gosec // test code
require.NoError(t, err)
// Create a GitOps config file that references the profile
// Note: Environment variables in the YAML config itself get expanded,
// but not in the referenced profile files
gitopsConfig := fmt.Sprintf(`
org_settings:
server_settings:
server_url: %s
secrets:
- secret: test_secret
agent_options:
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
controls:
macos_settings:
custom_settings:
- path: %s
queries: []
policies: []
`, s.Server.URL, profilePath)
configPath := filepath.Join(tempDir, "gitops.yml")
err = os.WriteFile(configPath, []byte(gitopsConfig), 0o644) //nolint:gosec // test code
require.NoError(t, err)
// Create a GitOps user
gitOpsUser := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, gitOpsUser)
// Set the environment variable for the valid secret
t.Setenv("FLEET_SECRET_TEST_SECRET", "super_secret_value_123")
t.Setenv("FLEET_DUO_CERTIFICATE_SECRET", "should_not_be_saved")
t.Setenv("HOME", "also_not_saved")
// Run GitOps dry-run - should fail without the required secret
// First, unset the environment variable to trigger the error
_ = os.Unsetenv("FLEET_SECRET_TEST_SECRET")
_, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath, "--dry-run"})
require.ErrorContains(t, err, "FLEET_SECRET_TEST_SECRET")
// Set the env var again and run for real
t.Setenv("FLEET_SECRET_TEST_SECRET", "super_secret_value_123")
_ = fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath})
// Verify that the secret was saved to the server
secrets, err := s.DS.GetSecretVariables(ctx, []string{"TEST_SECRET"})
require.NoError(t, err)
require.Len(t, secrets, 1)
assert.Equal(t, "TEST_SECRET", secrets[0].Name)
assert.Equal(t, "super_secret_value_123", secrets[0].Value)
// Verify that non-FLEET_SECRET_ variables were NOT saved
notSaved, err := s.DS.GetSecretVariables(ctx, []string{"DUO_CERTIFICATE_SECRET", "HOME"})
require.NoError(t, err)
assert.Empty(t, notSaved, "Non-FLEET_SECRET_ variables should not be saved")
// Verify that the profile content has the expected substitutions:
// - $FLEET_SECRET_* variables should remain as-is (substituted at delivery time)
// - Other env vars should be expanded during GitOps
profiles, err := s.DS.ListMDMAppleConfigProfiles(ctx, nil)
require.NoError(t, err)
foundProfile := false
for _, profile := range profiles {
t.Logf("Found profile: %s", profile.Name)
if strings.Contains(profile.Name, "test-profile") || strings.Contains(profile.Identifier, "com.fleet.test.env") {
foundProfile = true
// $FLEET_SECRET_* variables should NOT be expanded (they're expanded at delivery time)
assert.Contains(t, string(profile.Mobileconfig), "$FLEET_SECRET_TEST_SECRET")
// Non-FLEET_SECRET_/FLEET_VAR_ variables SHOULD be expanded during GitOps
assert.Contains(t, string(profile.Mobileconfig), "should_not_be_saved") // Value of $FLEET_DUO_CERTIFICATE_SECRET
assert.Contains(t, string(profile.Mobileconfig), "also_not_saved") // Value of $HOME
// The original variable names should NOT be present
assert.NotContains(t, string(profile.Mobileconfig), "$FLEET_DUO_CERTIFICATE_SECRET")
assert.NotContains(t, string(profile.Mobileconfig), "$HOME")
break
}
}
assert.True(t, foundProfile, "Profile should be uploaded to the server")
}
// TestFleetSecretInDataTag tests that FLEET_SECRET_ variables in <data> tags of Apple profiles
// are handled properly.
func (s *enterpriseIntegrationGitopsTestSuite) TestFleetSecretInDataTag() {
t := s.T()
tempDir := t.TempDir()
ctx := context.Background()
// Sample certificate in base64 format (this is a dummy test certificate)
testCertBase64 := `MIIDaTCCAlGgAwIBAgIUNQLezMUpmZK18DcLKt/XTRcLlK8wDQYJKoZIhvcNAQELBQAwRDEfMB0GA1UEAwwWRHVtbXkgVGVzdCBDZXJ0aWZpY2F0ZTEUMBIGA1UECgwLRXhhbXBsZSBPcmcxCzAJBgNVBAYTAlVTMB4XDTI1MDgxOTE3NDMwN1oXDTI2MDgxOTE3NDMwN1owRDEfMB0GA1UEAwwWRHVtbXkgVGVzdCBDZXJ0aWZpY2F0ZTEUMBIGA1UECgwLRXhhbXBsZSBPcmcxCzAJBgNVBAYTAlVTMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA07Np/w5WpFVLlMKX3dZSxwo+c2uwP2glTN0HA5c/6UOQRR9c91yoGGJsD4pfqhtIMSTFw7po3n/PjhGDe/WH+utK+ZIcD0nGD6SvmOggyoohHs81eIOjJAEJjxzhk7eLTVpUI2EnPe/24ei/dgkK59As9qQyH/y+CoR8JIYbNCJH5YLC2Pa44V84QWa2I5DHKUKrUXo9WsrRp1N1JjyaG/6hxLBJZ69e0QTrxxScboreRqVUR6oIEJRTchB+rDG5dxXzCQE6/F8N3qR76t23wd3CLmrcXoEc1P2P331Qzi0KXNXjdJFf0plmfRkT/IWgfM81Vfon1QwENwRSBNmPfQIDAQABo1MwUTAdBgNVHQ4EFgQU9q7SDfQRbJ31snRt2sZzx5sdEpYwHwYDVR0jBBgwFoAU9q7SDfQRbJ31snRt2sZzx5sdEpYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYwH42JP45SnZejSF74OcYt8fp08jCWHOiFC3QEo3cROXVn6AWjEbzuQpOxRWF9EizNA83c4E6I+kQVztiuv9bUKGuLFeYb9lUZe8HOvH+j22MtGvZrDPygsJc8TavdkxAsu6OiNQZrYiCFzixkKS9b5p/1B93GBh62OFnV1nUBS8PzAZhOAyJ8UcEhr+GNzZG99/wOkcB0uwxmIb8x8sB3KnQ0qef/qnmgeWxlJlDc/SZ2/4PgtaluZ+noDfNPzaQn4eJNnBz0OTqZ9yuKALeE1WHk8U13zSdc1GNVLhXOrEHegPK5bBmA/lpIQ6HrkwUX7MJ3vK0AD3LjaTzXltDQ==`
// Create a test team first
team, err := s.DS.NewTeam(ctx, &fleet.Team{
Name: "Test Team for Secret in Data Tag",
})
require.NoError(t, err)
// Create a profile with $FLEET_SECRET_DUO_CERTIFICATE in a <data> tag
// This mimics the real-world scenario where the certificate should be base64 encoded
profileContent := `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.test.cert</string>
<key>PayloadUUID</key>
<string>11111111-2222-3333-4444-555555555555</string>
<key>PayloadDisplayName</key>
<string>Test Root Certificate</string>
<key>PayloadContent</key>
<data>$FLEET_SECRET_DUO_CERTIFICATE</data>
</dict>
</array>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.test.profile</string>
<key>PayloadUUID</key>
<string>aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</string>
<key>PayloadDisplayName</key>
<string>Test MDM Profile with Base64</string>
</dict>
</plist>`
// Write the profile to a file
profilePath := filepath.Join(tempDir, "rootcert-secret.mobileconfig")
err = os.WriteFile(profilePath, []byte(profileContent), 0o644) //nolint:gosec
require.NoError(t, err)
// Create a team GitOps config file that references the profile
teamConfig := fmt.Sprintf(`
name: %s
team_settings:
secrets:
- secret: test_secret
agent_options:
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
controls:
macos_settings:
custom_settings:
- path: %s
queries:
policies:
software:
`, team.Name, profilePath)
configPath := filepath.Join(tempDir, "team-gitops.yml")
err = os.WriteFile(configPath, []byte(teamConfig), 0o644) //nolint:gosec
require.NoError(t, err)
// Create a GitOps user
gitOpsUser := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, gitOpsUser)
// Set the environment variable with the base64-encoded certificate
t.Setenv("FLEET_SECRET_DUO_CERTIFICATE", testCertBase64)
// The fix expands FLEET_SECRET_ variables for validation only, allowing the profile to be parsed
_, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath, "--dry-run"})
require.NoError(t, err, "GitOps dry-run should succeed with the fix")
// Also test without dry-run to confirm it works
_, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath})
require.NoError(t, err, "GitOps should succeed with the fix")
// Verify that the profile stored on the server still has the unexpanded variable
profiles, err := s.DS.ListMDMAppleConfigProfiles(ctx, &team.ID)
require.NoError(t, err)
require.Len(t, profiles, 1)
// The stored profile should still contain the unexpanded variable, not the actual secret
require.Contains(t, string(profiles[0].Mobileconfig), "$FLEET_SECRET_DUO_CERTIFICATE",
"Profile should still contain unexpanded FLEET_SECRET variable")
}
func (s *enterpriseIntegrationGitopsTestSuite) TestAddManualLabels() {
t := s.T()
ctx := context.Background()
user := fleet.User{
Name: "Admin User",
Email: uuid.NewString() + "@example.com",
GlobalRole: ptr.String(fleet.RoleAdmin),
}
require.NoError(t, user.SetPassword(test.GoodPassword, 10, 10))
_, err := s.DS.NewUser(context.Background(), &user)
require.NoError(t, err)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Add some hosts
host1, err := s.DS.NewHost(context.Background(), &fleet.Host{
UUID: "uuid-1",
Hostname: "host1",
Platform: "linux",
HardwareSerial: "serial1",
})
require.NoError(t, err)
host2, err := s.DS.NewHost(context.Background(), &fleet.Host{
UUID: "uuid-2",
Hostname: "host2",
Platform: "linux",
HardwareSerial: "serial2",
})
require.NoError(t, err)
host3, err := s.DS.NewHost(context.Background(), &fleet.Host{
UUID: "uuid-3",
Hostname: "host3",
Platform: "linux",
HardwareSerial: "serial3",
})
require.NoError(t, err)
host4, err := s.DS.NewHost(context.Background(), &fleet.Host{
UUID: "uuid-4",
Hostname: "host4",
Platform: "linux",
HardwareSerial: "serial4",
})
require.NoError(t, err)
// Add a host whose UUID starts with the ID of host4 (probably ID 4,
// but get it from the record just in case.)
// host4 should _not_ be added to the label (see issue #34236).
host5, err := s.DS.NewHost(context.Background(), &fleet.Host{
UUID: fmt.Sprintf("%duuid-5", host4.ID),
Hostname: "dummy",
Platform: "linux",
HardwareSerial: "dummy",
})
require.NoError(t, err)
// Create a global file
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(fmt.Sprintf(`
agent_options:
controls:
org_settings:
secrets:
- secret: test_secret
policies:
queries:
labels:
- name: my-label
label_membership_type: manual
hosts:
- %s
- %s
- %d
- %s
- dummy
`, host1.Hostname, host2.HardwareSerial, host3.ID, host5.UUID))
require.NoError(t, err)
// Apply the configs
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name()}))
// Verify the label was created and has the correct hosts
labels, err := s.DS.LabelsByName(ctx, []string{"my-label"}, fleet.TeamFilter{})
require.NoError(t, err)
require.Len(t, labels, 1)
label := labels["my-label"]
// Get the hosts for the label
labelHosts, err := s.DS.ListHostsInLabel(ctx, fleet.TeamFilter{User: &user}, label.ID, fleet.HostListOptions{})
require.NoError(t, err)
require.Len(t, labelHosts, 4)
// Get the IDs of the hosts
var labelHostIDs []uint
for _, h := range labelHosts {
labelHostIDs = append(labelHostIDs, h.ID)
}
// Verify the correct hosts were added to the label
require.ElementsMatch(t, labelHostIDs, []uint{host1.ID, host2.ID, host3.ID, host5.ID})
}
func (s *enterpriseIntegrationGitopsTestSuite) TestIPASoftwareInstallers() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
lbl, err := s.DS.NewLabel(ctx, &fleet.Label{Name: "Label1", Query: "SELECT 1"})
require.NoError(t, err)
require.NotZero(t, lbl.ID)
const (
globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
labels:
- name: Label1
label_membership_type: dynamic
query: SELECT 1
`
noTeamTemplate = `name: No team
controls:
policies:
software:
packages:
%s
`
teamTemplate = `
controls:
software:
packages:
%s
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
// create an .ipa software for the no-team config
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(fmt.Sprintf(noTeamTemplate, `
- url: ${SOFTWARE_INSTALLER_URL}/ipa_test.ipa
self_service: true
`))
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "no-team.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath}))
// the ipa installer was created for no team
titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 2)
var sources, platforms []string
for _, title := range titles {
require.Equal(t, "ipa_test", title.Name)
require.NotNil(t, title.BundleIdentifier)
require.Equal(t, "com.ipa-test.ipa-test", *title.BundleIdentifier)
sources = append(sources, title.Source)
require.NotNil(t, title.SoftwarePackage)
platforms = append(platforms, title.SoftwarePackage.Platform)
require.Equal(t, "ipa_test.ipa", title.SoftwarePackage.Name)
meta, err := s.DS.GetInHouseAppMetadataByTeamAndTitleID(ctx, nil, title.ID)
require.NoError(t, err)
require.True(t, meta.SelfService)
require.Empty(t, meta.LabelsExcludeAny)
require.Empty(t, meta.LabelsIncludeAny)
}
require.ElementsMatch(t, []string{"ios_apps", "ipados_apps"}, sources)
require.ElementsMatch(t, []string{"ios", "ipados"}, platforms)
// create a dummy install script, should be ignored for ipa apps
scriptFile, err := os.CreateTemp(t.TempDir(), "*.sh")
require.NoError(t, err)
_, err = scriptFile.WriteString(`echo "dummy install script"`)
require.NoError(t, err)
err = scriptFile.Close()
require.NoError(t, err)
// create an .ipa software for the team config
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, `
- url: ${SOFTWARE_INSTALLER_URL}/ipa_test.ipa
self_service: false
install_script:
path: `+scriptFile.Name()+`
labels_include_any:
- Label1
`, teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
// get the team ID
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// the ipa installer was created for the team
titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &team.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 2)
sources, platforms = []string{}, []string{}
for _, title := range titles {
require.Equal(t, "ipa_test", title.Name)
require.NotNil(t, title.BundleIdentifier)
require.Equal(t, "com.ipa-test.ipa-test", *title.BundleIdentifier)
sources = append(sources, title.Source)
require.NotNil(t, title.SoftwarePackage)
platforms = append(platforms, title.SoftwarePackage.Platform)
require.Equal(t, "ipa_test.ipa", title.SoftwarePackage.Name)
meta, err := s.DS.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, title.ID)
require.NoError(t, err)
require.False(t, meta.SelfService)
require.Empty(t, meta.LabelsExcludeAny)
require.Len(t, meta.LabelsIncludeAny, 1)
require.Equal(t, lbl.ID, meta.LabelsIncludeAny[0].LabelID)
require.Empty(t, meta.InstallScript) // install script should be ignored for ipa apps
}
require.ElementsMatch(t, []string{"ios_apps", "ipados_apps"}, sources)
require.ElementsMatch(t, []string{"ios", "ipados"}, platforms)
// update the team config to clear the label condition
err = os.WriteFile(teamFile.Name(), []byte(fmt.Sprintf(teamTemplate, `
- url: ${SOFTWARE_INSTALLER_URL}/ipa_test.ipa
labels_include_any:
`, teamName)), 0o644)
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
// the ipa installer was created for the team
titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &team.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 2)
sources, platforms = []string{}, []string{}
for _, title := range titles {
require.Equal(t, "ipa_test", title.Name)
require.NotNil(t, title.BundleIdentifier)
require.Equal(t, "com.ipa-test.ipa-test", *title.BundleIdentifier)
sources = append(sources, title.Source)
require.NotNil(t, title.SoftwarePackage)
platforms = append(platforms, title.SoftwarePackage.Platform)
require.Equal(t, "ipa_test.ipa", title.SoftwarePackage.Name)
meta, err := s.DS.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, title.ID)
require.NoError(t, err)
require.False(t, meta.SelfService)
require.Empty(t, meta.LabelsExcludeAny)
require.Empty(t, meta.LabelsIncludeAny)
}
require.ElementsMatch(t, []string{"ios_apps", "ipados_apps"}, sources)
require.ElementsMatch(t, []string{"ios", "ipados"}, platforms)
// update the team config to clear all installers
err = os.WriteFile(teamFile.Name(), []byte(fmt.Sprintf(teamTemplate, "", teamName)), 0o644)
require.NoError(t, err)
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}))
titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &team.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 0)
}
// TestGitOpsSoftwareDisplayName tests that display names for software packages and VPP apps
// are properly applied via GitOps.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsSoftwareDisplayName() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
const (
globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`
noTeamTemplate = `name: No team
controls:
policies:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
display_name: Custom Ruby Name
`
teamTemplate = `
controls:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
display_name: Team Custom Ruby
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
`
)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = noTeamFile.WriteString(noTeamTemplate)
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "no-team.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
// Apply configs
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()}))
// get the team ID
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// Verify display name for no team
noTeamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, noTeamTitles, 1)
require.NotNil(t, noTeamTitles[0].SoftwarePackage)
noTeamTitleID := noTeamTitles[0].ID
// Verify the display name is stored in the database for no team
var noTeamDisplayName string
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &noTeamDisplayName,
"SELECT display_name FROM software_title_display_names WHERE team_id = ? AND software_title_id = ?",
0, noTeamTitleID)
})
require.Equal(t, "Custom Ruby Name", noTeamDisplayName)
// Verify display name for team
teamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID}, fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, teamTitles, 1)
require.NotNil(t, teamTitles[0].SoftwarePackage)
teamTitleID := teamTitles[0].ID
// Verify the display name is stored in the database for team
var teamDisplayName string
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &teamDisplayName,
"SELECT display_name FROM software_title_display_names WHERE team_id = ? AND software_title_id = ?",
team.ID, teamTitleID)
})
require.Equal(t, "Team Custom Ruby", teamDisplayName)
}
// TestGitOpsSoftwareIcons tests that custom icons for software packages
// and fleet maintained apps are properly applied via GitOps.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsSoftwareIcons() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
const (
globalTemplate = `
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
policies:
queries:
`
noTeamTemplate = `name: No team
controls:
policies:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
icon:
path: %s/testdata/gitops/lib/icon.png
fleet_maintained_apps:
- slug: foo/darwin
icon:
path: %s/testdata/gitops/lib/icon.png
`
teamTemplate = `
controls:
software:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/ruby.deb
icon:
path: %s/testdata/gitops/lib/icon.png
fleet_maintained_apps:
- slug: foo/darwin
icon:
path: %s/testdata/gitops/lib/icon.png
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
`
)
// Get the absolute path to the directory of this test file
_, currentFile, _, ok := runtime.Caller(0)
require.True(t, ok, "failed to get runtime caller info")
dirPath := filepath.Dir(currentFile)
dirPath = filepath.Join(dirPath, "../../fleetctl")
dirPath, err := filepath.Abs(filepath.Clean(dirPath))
require.NoError(t, err)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fmt.Fprintf(noTeamFile, noTeamTemplate, dirPath, dirPath)
require.NoError(t, err)
err = noTeamFile.Close()
require.NoError(t, err)
noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "no-team.yml")
err = os.Rename(noTeamFile.Name(), noTeamFilePath)
require.NoError(t, err)
teamName := uuid.NewString()
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fmt.Fprintf(teamFile, teamTemplate, dirPath, dirPath, teamName)
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
// Mock server to serve fleet maintained app installer
installerBytes := []byte("foo")
installerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(installerBytes)
}))
defer installerServer.Close()
// Mock server to serve fleet maintained app manifest
manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var versions []*ma.FMAManifestApp
versions = append(versions, &ma.FMAManifestApp{
Version: "6.0",
Queries: ma.FMAQueries{
Exists: "SELECT 1 FROM osquery_info;",
},
InstallerURL: installerServer.URL + "/foo.pkg",
InstallScriptRef: "foobaz",
UninstallScriptRef: "foobaz",
SHA256: "no_check", // See ma.noCheckHash
})
manifest := ma.FMAManifestFile{
Versions: versions,
Refs: map[string]string{
"foobaz": "Hello World!",
},
}
err := json.NewEncoder(w).Encode(manifest)
require.NoError(t, err)
}))
t.Cleanup(manifestServer.Close)
dev_mode.SetOverride("FLEET_DEV_MAINTAINED_APPS_BASE_URL", manifestServer.URL, t)
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO fleet_maintained_apps (name, slug, platform, unique_identifier)
VALUES ('foo', 'foo/darwin', 'darwin', 'com.example.foo')`)
return err
})
// Apply configs
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()}))
// Get the team ID
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// Verify titles were added for no team
noTeamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: ptr.Uint(0)},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, noTeamTitles, 2)
require.NotNil(t, noTeamTitles[0].SoftwarePackage)
require.NotNil(t, noTeamTitles[1].SoftwarePackage)
noTeamTitleIDs := []uint{noTeamTitles[0].ID, noTeamTitles[1].ID}
// Verify the custom icon is stored in the database for no team
var noTeamIconFilenames []string
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
stmt, args, err := sqlx.In("SELECT filename FROM software_title_icons WHERE team_id = ? AND software_title_id IN (?)", 0, noTeamTitleIDs)
if err != nil {
return err
}
return sqlx.SelectContext(ctx, q, &noTeamIconFilenames, stmt, args...)
})
require.Len(t, noTeamIconFilenames, 2)
require.Equal(t, "icon.png", noTeamIconFilenames[0])
require.Equal(t, "icon.png", noTeamIconFilenames[1])
// Verify titles were added for team
teamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID}, fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, teamTitles, 2)
require.NotNil(t, teamTitles[0].SoftwarePackage)
require.NotNil(t, teamTitles[1].SoftwarePackage)
teamTitleIDs := []uint{teamTitles[0].ID, teamTitles[1].ID}
// Verify the custom icon is stored in the database for team
var teamIconFilenames []string
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
stmt, args, err := sqlx.In("SELECT filename FROM software_title_icons WHERE team_id = ? AND software_title_id IN (?)", 0, teamTitleIDs)
if err != nil {
return err
}
return sqlx.SelectContext(ctx, q, &teamIconFilenames, stmt, args...)
})
require.Len(t, teamIconFilenames, 2)
require.Equal(t, "icon.png", teamIconFilenames[0])
require.Equal(t, "icon.png", teamIconFilenames[1])
}
// TestGitOpsTeamLabels tests operations around team labels
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsTeamLabels() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetCfg := s.createFleetctlConfig(t, user)
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
// -----------------------------------------------------------------
// First, let's validate that we can add labels to the global scope
// -----------------------------------------------------------------
require.NoError(t, os.WriteFile(globalFile.Name(), []byte(`
agent_options:
controls:
org_settings:
secrets:
- secret: test_secret
policies:
queries:
labels:
- name: global-label-one
label_membership_type: dynamic
query: SELECT 1
- name: global-label-two
label_membership_type: dynamic
query: SELECT 1
`), 0o644))
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name()}))
expected := make(map[string]uint)
expected["global-label-one"] = 0
expected["global-label-two"] = 0
got := labelTeamIDResult(t, s, ctx)
require.True(t, maps.Equal(expected, got))
// ---------------------------------------------------------------
// Now, let's validate that we can add and remove labels in a team
// ---------------------------------------------------------------
// TeamOne already exists
teamOneName := uuid.NewString()
teamOne, err := s.DS.NewTeam(context.Background(), &fleet.Team{Name: teamOneName})
require.NoError(t, err)
teamOneFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
require.NoError(t, os.WriteFile(teamOneFile.Name(), fmt.Appendf(nil,
`
controls:
software:
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
labels:
- name: team-one-label-one
label_membership_type: dynamic
query: SELECT 2
- name: team-one-label-two
label_membership_type: dynamic
query: SELECT 3
`, teamOneName), 0o644))
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", teamOneFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", teamOneFile.Name()}))
got = labelTeamIDResult(t, s, ctx)
expected = make(map[string]uint)
expected["global-label-one"] = 0
expected["global-label-two"] = 0
expected["team-one-label-one"] = teamOne.ID
expected["team-one-label-two"] = teamOne.ID
require.True(t, maps.Equal(expected, got))
// Try removing one label from teamOne
require.NoError(t, os.WriteFile(teamOneFile.Name(), fmt.Appendf(nil,
`
controls:
software:
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
labels:
- name: team-one-label-one
label_membership_type: dynamic
query: SELECT 2
`, teamOneName), 0o644))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name(), "-f", teamOneFile.Name()}))
expected = make(map[string]uint)
expected["global-label-one"] = 0
expected["global-label-two"] = 0
expected["team-one-label-one"] = teamOne.ID
got = labelTeamIDResult(t, s, ctx)
require.True(t, maps.Equal(expected, got))
// ------------------------------------------------
// Finally, let's validate that we can move labels around
// ------------------------------------------------
require.NoError(t, os.WriteFile(globalFile.Name(), []byte(`
agent_options:
controls:
org_settings:
secrets:
- secret: test_secret
policies:
queries:
labels:
- name: global-label-one
label_membership_type: dynamic
query: SELECT 1
`), 0o644))
require.NoError(t, os.WriteFile(teamOneFile.Name(), fmt.Appendf(nil,
`
controls:
software:
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
labels:
- name: team-one-label-two
label_membership_type: dynamic
query: SELECT 3
- name: global-label-two
label_membership_type: dynamic
query: SELECT 1
`, teamOneName), 0o644))
teamTwoName := uuid.NewString()
teamTwoFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
require.NoError(t, os.WriteFile(teamTwoFile.Name(), fmt.Appendf(nil, `
controls:
software:
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret2"}]
labels:
- name: team-one-label-one
label_membership_type: dynamic
query: SELECT 2
`, teamTwoName), 0o644))
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name(), "-f", teamOneFile.Name(), "-f", teamTwoFile.Name(), "--dry-run"}))
// TODO: Seems like we require two passes to achieve equilibrium?
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name(), "-f", teamOneFile.Name(), "-f", teamTwoFile.Name()}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalFile.Name(), "-f", teamOneFile.Name(), "-f", teamTwoFile.Name()}))
teamTwo, err := s.DS.TeamByName(ctx, teamTwoName)
require.NoError(t, err)
got = labelTeamIDResult(t, s, ctx)
expected = make(map[string]uint)
expected["global-label-one"] = 0
expected["team-one-label-two"] = teamOne.ID
expected["global-label-two"] = teamOne.ID
expected["team-one-label-one"] = teamTwo.ID
require.True(t, maps.Equal(expected, got))
}
// Tests a gitops setup where every team runs from an independent repo. Multiple repos are simulated by
// copying over the example repository multiple times.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsTeamLabelsMultipleRepos() {
t := s.T()
ctx := context.Background()
var users []fleet.User
var cfgPaths []*os.File
var reposDir []string
for range 2 {
user := s.createGitOpsUser(t)
users = append(users, user)
cfg := s.createFleetctlConfig(t, user)
cfgPaths = append(cfgPaths, cfg)
repoDir := t.TempDir()
_, err := git.PlainClone(
repoDir, false, &git.CloneOptions{
ReferenceName: "main",
SingleBranch: true,
Depth: 1,
URL: fleetGitopsRepo,
Progress: os.Stdout,
},
)
require.NoError(t, err)
reposDir = append(reposDir, repoDir)
}
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
t.Setenv("FLEET_GLOBAL_ENROLL_SECRET", "global_enroll_secret")
t.Setenv("FLEET_WORKSTATIONS_ENROLL_SECRET", "workstations_enroll_secret")
t.Setenv("FLEET_WORKSTATIONS_CANARY_ENROLL_SECRET", "workstations_canary_enroll_secret")
type tmplParams struct {
Name string
Queries string
Labels string
}
teamCfgTmpl, err := template.New("t1").Parse(`
controls:
software:
queries:{{ .Queries }}
policies:
labels:{{ .Labels }}
agent_options:
name:{{ .Name }}
team_settings:
secrets: [{"secret":"{{ .Name}}_secret"}]
`)
require.NoError(t, err)
// --------------------------------------------------
// First, lets simulate adding a new team per repo
// --------------------------------------------------
for i, repo := range reposDir {
globalFile := path.Join(repo, "default.yml")
newTeamCfgFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
require.NoError(t, teamCfgTmpl.Execute(newTeamCfgFile, tmplParams{
Name: fmt.Sprintf(" team-%d", i),
Queries: fmt.Sprintf("\n - name: query-%d\n query: SELECT 1", i),
Labels: fmt.Sprintf("\n - name: label-%d\n label_membership_type: dynamic\n query: SELECT 1", i),
}))
args := []string{"gitops", "--config", cfgPaths[i].Name(), "-f", globalFile, "-f", newTeamCfgFile.Name()}
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, args))
}
for i, user := range users {
team, err := s.DS.TeamByName(ctx, fmt.Sprintf("team-%d", i))
require.NoError(t, err)
require.NotNil(t, team)
queries, _, _, _, err := s.DS.ListQueries(ctx, fleet.ListQueryOptions{TeamID: &team.ID})
require.NoError(t, err)
require.Len(t, queries, 1)
require.Equal(t, fmt.Sprintf("query-%d", i), queries[0].Name)
require.Equal(t, "SELECT 1", queries[0].Query)
require.NotNil(t, queries[0].TeamID)
require.Equal(t, *queries[0].TeamID, team.ID)
require.NotNil(t, queries[0].AuthorID)
require.Equal(t, *queries[0].AuthorID, user.ID)
label, err := s.DS.LabelByName(ctx, fmt.Sprintf("label-%d", i), fleet.TeamFilter{User: &fleet.User{ID: user.ID}})
require.NoError(t, err)
require.NotNil(t, label)
require.NotNil(t, label.TeamID)
require.Equal(t, *label.TeamID, team.ID)
require.NotNil(t, label.AuthorID)
require.Equal(t, *label.AuthorID, user.ID)
}
// -----------------------------------------------------------------
// Then, lets simulate a mutation by dropping the labels on team one
// -----------------------------------------------------------------
for i, repo := range reposDir {
globalFile := path.Join(repo, "default.yml")
newTeamCfgFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
params := tmplParams{
Name: fmt.Sprintf(" team-%d", i),
Queries: fmt.Sprintf("\n - name: query-%d\n query: SELECT 1", i),
}
if i != 0 {
params.Labels = fmt.Sprintf("\n - name: label-%d\n label_membership_type: dynamic\n query: SELECT 1", i)
}
require.NoError(t, teamCfgTmpl.Execute(newTeamCfgFile, params))
args := []string{"gitops", "--config", cfgPaths[i].Name(), "-f", globalFile, "-f", newTeamCfgFile.Name()}
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, args))
}
for i, user := range users {
team, err := s.DS.TeamByName(ctx, fmt.Sprintf("team-%d", i))
require.NoError(t, err)
require.NotNil(t, team)
queries, _, _, _, err := s.DS.ListQueries(ctx, fleet.ListQueryOptions{TeamID: &team.ID})
require.NoError(t, err)
require.Len(t, queries, 1)
require.Equal(t, fmt.Sprintf("query-%d", i), queries[0].Name)
require.Equal(t, "SELECT 1", queries[0].Query)
require.NotNil(t, queries[0].TeamID)
require.Equal(t, *queries[0].TeamID, team.ID)
require.NotNil(t, queries[0].AuthorID)
require.Equal(t, *queries[0].AuthorID, user.ID)
label, err := s.DS.LabelByName(ctx, fmt.Sprintf("label-%d", i), fleet.TeamFilter{User: &fleet.User{ID: user.ID}})
if i == 0 {
require.Error(t, err)
require.Nil(t, label)
} else {
require.NoError(t, err)
require.NotNil(t, label)
require.NotNil(t, label.TeamID)
require.Equal(t, *label.TeamID, team.ID)
require.NotNil(t, label.AuthorID)
require.Equal(t, *label.AuthorID, user.ID)
}
}
}
func labelTeamIDResult(t *testing.T, s *enterpriseIntegrationGitopsTestSuite, ctx context.Context) map[string]uint {
type labelResult struct {
Name string `db:"name"`
TeamID *uint `db:"team_id"`
}
var result []labelResult
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
require.NoError(t, sqlx.SelectContext(ctx, q, &result, "SELECT name, team_id FROM labels WHERE label_type = 0"))
return nil
})
got := make(map[string]uint)
for _, r := range result {
var teamID uint
if r.TeamID != nil {
teamID = *r.TeamID
}
got[r.Name] = teamID
}
return got
}
// TestGitOpsVPPAppAutoUpdate tests that auto-update settings for VPP apps (iOS/iPadOS)
// are properly applied via GitOps.
func (s *enterpriseIntegrationGitopsTestSuite) TestGitOpsVPPAppAutoUpdate() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Create a global VPP token (location is "Jungle")
test.CreateInsertGlobalVPPToken(t, s.DS)
// Generate team name upfront since we need it in the global template
teamName := uuid.NewString()
// The global template includes VPP token assignment to the team
// The location "Jungle" comes from test.CreateInsertGlobalVPPToken
globalTemplate := fmt.Sprintf(`
agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
mdm:
volume_purchasing_program:
- location: Jungle
teams:
- %s
policies:
queries:
`, teamName)
teamTemplate := `
controls:
software:
app_store_apps:
- app_store_id: "2"
platform: ios
self_service: false
auto_update_enabled: true
auto_update_window_start: "02:00"
auto_update_window_end: "06:00"
- app_store_id: "2"
platform: ipados
self_service: false
auto_update_enabled: true
auto_update_window_start: "03:00"
auto_update_window_end: "07:00"
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
`
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = globalFile.WriteString(globalTemplate)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, teamName))
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartAndServeVPPServer(t)
dryRunOutput := fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"})
require.Contains(t, dryRunOutput, "gitops dry run succeeded")
realRunOutput := fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()})
require.Contains(t, realRunOutput, "gitops succeeded")
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
// Verify VPP apps were added
titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &team.ID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 2) // One for iOS, one for iPadOS
// Verify auto-update schedules were created in the database
type autoUpdateSchedule struct {
TitleID uint `db:"title_id"`
TeamID uint `db:"team_id"`
Enabled bool `db:"enabled"`
StartTime string `db:"start_time"`
EndTime string `db:"end_time"`
}
var schedules []autoUpdateSchedule
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &schedules,
`SELECT title_id, team_id, enabled, start_time, end_time
FROM software_update_schedules
WHERE team_id = ?
ORDER BY title_id`, team.ID)
})
require.Len(t, schedules, 2)
for _, schedule := range schedules {
require.Equal(t, team.ID, schedule.TeamID)
require.True(t, schedule.Enabled)
var foundTitle *fleet.SoftwareTitleListResult
for i := range titles {
if titles[i].ID == schedule.TitleID {
foundTitle = &titles[i]
break
}
}
require.NotNil(t, foundTitle, "should find title for schedule")
// Verify the correct start/end times based on source
switch foundTitle.Source {
case "ios_apps":
require.Equal(t, "02:00", schedule.StartTime)
require.Equal(t, "06:00", schedule.EndTime)
case "ipados_apps":
require.Equal(t, "03:00", schedule.StartTime)
require.Equal(t, "07:00", schedule.EndTime)
default:
t.Fatalf("unexpected source: %s", foundTitle.Source)
}
}
// Now apply a config without auto-update fields for the iPadOS app and verify they're cleared
teamTemplateNoAutoUpdate := `
controls:
software:
app_store_apps:
- app_store_id: "2"
platform: ios
self_service: false
auto_update_enabled: true
auto_update_window_start: "02:00"
auto_update_window_end: "06:00"
- app_store_id: "2"
platform: ipados
self_service: false
queries:
policies:
agent_options:
name: %s
team_settings:
secrets: [{"secret":"enroll_secret"}]
`
teamFileNoAutoUpdate, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = teamFileNoAutoUpdate.WriteString(fmt.Sprintf(teamTemplateNoAutoUpdate, teamName))
require.NoError(t, err)
err = teamFileNoAutoUpdate.Close()
require.NoError(t, err)
// Apply the updated config
realRunOutput = fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFileNoAutoUpdate.Name()})
require.Contains(t, realRunOutput, "gitops succeeded")
// Verify auto-update schedules: iOS should still have settings, iPadOS should be disabled
var updatedSchedules []autoUpdateSchedule
mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &updatedSchedules,
`SELECT title_id, team_id, enabled, start_time, end_time
FROM software_update_schedules
WHERE team_id = ?
ORDER BY title_id`, team.ID)
})
require.Len(t, updatedSchedules, 2)
for _, schedule := range updatedSchedules {
var foundTitle *fleet.SoftwareTitleListResult
for i := range titles {
if titles[i].ID == schedule.TitleID {
foundTitle = &titles[i]
break
}
}
require.NotNil(t, foundTitle, "should find title for schedule")
switch foundTitle.Source {
case "ios_apps":
// iOS app should still have auto-update enabled
require.True(t, schedule.Enabled)
require.Equal(t, "02:00", schedule.StartTime)
require.Equal(t, "06:00", schedule.EndTime)
case "ipados_apps":
// iPadOS app should now have auto-update disabled (fields removed from config)
// but the previous start/end times should still be preserved in the database
require.False(t, schedule.Enabled)
require.Equal(t, "03:00", schedule.StartTime)
require.Equal(t, "07:00", schedule.EndTime)
default:
t.Fatalf("unexpected source: %s", foundTitle.Source)
}
}
}
// TestFleetDesktopSettingsBrowserAlternativeHost tests that user can mutate the fleet_desktop.alternative_browser_host
// setting via GitOps.
func (s *enterpriseIntegrationGitopsTestSuite) TestFleetDesktopSettingsBrowserAlternativeHost() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetCfg := s.createFleetctlConfig(t, user)
type tmplParams struct {
AlternativeBrowserHost string
}
globalCfgTpl, err := template.New("t1").Parse(`
agent_options:
controls:
queries:
policies:
org_settings:
secrets:
- secret: test_secret
fleet_desktop:
{{ .AlternativeBrowserHost }}
`)
require.NoError(t, err)
// Set the required environment variables
t.Setenv("FLEET_URL", s.Server.URL)
t.Setenv("FLEET_GLOBAL_ENROLL_SECRET", "global_enroll_secret")
t.Setenv("FLEET_WORKSTATIONS_ENROLL_SECRET", "workstations_enroll_secret")
t.Setenv("FLEET_WORKSTATIONS_CANARY_ENROLL_SECRET", "workstations_canary_enroll_secret")
testCases := []struct {
Name string
AlternativeBrowserHost string
Expected string
ShouldError bool
}{
{
Name: "custom",
AlternativeBrowserHost: `alternative_browser_host: "example1.com"`,
Expected: "example1.com",
},
{
Name: "empty value",
AlternativeBrowserHost: `alternative_browser_host: ""`,
Expected: "",
},
{
Name: "invalid value",
AlternativeBrowserHost: `alternative_browser_host: "http://example2.com"`,
ShouldError: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.Name, func(t *testing.T) {
globalCfgFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
require.NoError(t, globalCfgTpl.Execute(globalCfgFile, tmplParams{
AlternativeBrowserHost: testCase.AlternativeBrowserHost,
}))
if testCase.ShouldError {
fleetctl.RunAppCheckErr(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalCfgFile.Name()}, "applying fleet config: PATCH /api/latest/fleet/config received status 422 Validation Failed: must be a valid hostname or IP address")
} else {
s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalCfgFile.Name(), "--dry-run"}))
s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetCfg.Name(), "-f", globalCfgFile.Name()}))
}
storedCfg, err := s.DS.AppConfig(ctx)
require.NoError(t, err)
require.NotNil(t, storedCfg)
require.Equal(t, testCase.Expected, storedCfg.FleetDesktop.AlternativeBrowserHost)
})
}
}
func (s *enterpriseIntegrationGitopsTestSuite) TestSpecialCaseTeamsVPPApps() {
t := s.T()
ctx := context.Background()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
// Create a global VPP token (location is "Jungle")
test.CreateInsertGlobalVPPToken(t, s.DS)
// Generate team name upfront since we need it in the global template
teamName := uuid.NewString()
// The global template includes VPP token assignment to the team
// The location "Jungle" comes from test.CreateInsertGlobalVPPToken
globalTemplate := `agent_options:
controls:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
- secret: foobar
mdm:
volume_purchasing_program:
- location: Jungle
teams:
- %s
policies:
queries:
`
teamTemplate := `
controls:
software:
app_store_apps:
- app_store_id: "2"
platform: ios
self_service: false
auto_update_enabled: true
auto_update_window_start: "02:00"
auto_update_window_end: "06:00"
- app_store_id: "2"
platform: ipados
self_service: false
auto_update_enabled: true
auto_update_window_start: "03:00"
auto_update_window_end: "07:00"
queries:
policies:
agent_options:
name: %s
team_settings:
%s
`
testCases := []struct {
specialCase string
teamName string
teamSettings string
}{
{
specialCase: "All teams",
teamName: teamName,
teamSettings: `secrets: [{"secret":"enroll_secret"}]`,
},
{
specialCase: "No team",
teamName: "No team",
},
}
for _, tc := range testCases {
t.Run(tc.specialCase, func(t *testing.T) {
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
globalYAML := fmt.Sprintf(globalTemplate, tc.specialCase)
_, err = globalFile.WriteString(globalYAML)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fmt.Fprintf(teamFile, teamTemplate, tc.teamName, tc.teamSettings)
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
teamFileName := teamFile.Name()
if tc.specialCase == "No team" {
noTeamFilePath := filepath.Join(filepath.Dir(teamFile.Name()), "no-team.yml")
err = os.Rename(teamFile.Name(), noTeamFilePath)
require.NoError(t, err)
teamFileName = noTeamFilePath
}
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartAndServeVPPServer(t)
dryRunOutput := fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFileName, "--dry-run"})
require.Contains(t, dryRunOutput, "gitops dry run succeeded")
realRunOutput := fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFileName})
require.Contains(t, realRunOutput, "gitops succeeded")
var teamID uint
if tc.specialCase != "No team" {
team, err := s.DS.TeamByName(ctx, teamName)
require.NoError(t, err)
teamID = team.ID
}
// Verify VPP apps were added
titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &teamID},
fleet.TeamFilter{User: test.UserAdmin})
require.NoError(t, err)
require.Len(t, titles, 2) // One for iOS, one for iPadOS
})
}
}
func (s *enterpriseIntegrationGitopsTestSuite) TestDisallowSoftwareSetupExperience() {
t := s.T()
user := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, user)
test.CreateInsertGlobalVPPToken(t, s.DS)
teamName := uuid.NewString()
// The global template includes VPP token assignment to the team
// The location "Jungle" comes from test.CreateInsertGlobalVPPToken
globalTemplate := `agent_options:
org_settings:
server_settings:
server_url: $FLEET_URL
org_info:
org_name: Fleet
secrets:
- secret: foobar
mdm:
volume_purchasing_program:
- location: Jungle
teams:
- %s
policies:
controls:
queries:
`
testAll := `
controls:
macos_setup:
manual_agent_install: true
software:
app_store_apps:
- app_store_id: "2"
platform: darwin
setup_experience: true
- app_store_id: "2"
platform: ios
setup_experience: true
- app_store_id: "2"
platform: ipados
setup_experience: true
queries:
policies:
agent_options:
name: %s
team_settings:
%s
`
//nolint:gosec // test code
testPackagesFail := `
controls:
macos_setup:
manual_agent_install: true
software:
app_store_apps:
packages:
- url: ${SOFTWARE_INSTALLER_URL}/dummy_installer.pkg
setup_experience: true
queries:
policies:
agent_options:
name: %s
team_settings:
%s
`
testCases := []struct {
VPPTeam string
testName string
teamName string
teamTemplate string
teamSettings string
errContains *string
}{
{
testName: "All VPP with setup experience",
VPPTeam: "All teams",
teamName: teamName,
teamTemplate: testAll,
teamSettings: `secrets: [{"secret":"enroll_secret"}]`,
errContains: ptr.String("Couldn't edit software."),
},
{
testName: "Packages fail",
VPPTeam: "All teams",
teamName: teamName,
teamTemplate: testPackagesFail,
teamSettings: `secrets: [{"secret":"enroll_secret"}]`,
errContains: ptr.String("Couldn't edit software."),
},
{
testName: "No team",
VPPTeam: "No team",
teamName: "No team",
teamTemplate: testAll,
errContains: ptr.String("Couldn't edit software."),
},
// left out more possible combinations of setup experience being set for different platforms
}
for _, tc := range testCases {
t.Run(tc.testName, func(t *testing.T) {
globalFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
globalYAML := fmt.Sprintf(globalTemplate, tc.VPPTeam)
_, err = globalFile.WriteString(globalYAML)
require.NoError(t, err)
err = globalFile.Close()
require.NoError(t, err)
teamFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = fmt.Fprintf(teamFile, tc.teamTemplate, tc.teamName, tc.teamSettings)
require.NoError(t, err)
err = teamFile.Close()
require.NoError(t, err)
teamFileName := teamFile.Name()
if tc.VPPTeam == "No team" {
noTeamFilePath := filepath.Join(filepath.Dir(teamFile.Name()), "no-team.yml")
err = os.Rename(teamFile.Name(), noTeamFilePath)
require.NoError(t, err)
teamFileName = noTeamFilePath
}
t.Setenv("FLEET_URL", s.Server.URL)
testing_utils.StartSoftwareInstallerServer(t)
testing_utils.StartAndServeVPPServer(t)
// Don't attempt dry runs because they would not actually create the team, so the config would not be found
_, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFileName})
if tc.errContains != nil {
require.ErrorContains(t, err, *tc.errContains)
} else {
require.NoError(t, err)
}
})
}
}