@@ -7218,6 +7218,259 @@ software:
72187218 })
72197219}
72207220
7221+ func TestGitOpsHostNameTemplate (t * testing.T ) {
7222+ // Cannot t.Parallel() — RunServerWithMockedDS sets environment variables.
7223+ license := & fleet.LicenseInfo {Tier : fleet .TierPremium , Expiration : time .Now ().Add (24 * time .Hour )}
7224+ opts := & service.TestServerOpts {
7225+ License : license ,
7226+ KeyValueStore : testing_utils .NewMemKeyValueStore (),
7227+ }
7228+ _ , ds := testing_utils .RunServerWithMockedDS (t , opts )
7229+ setupEmptyGitOpsMocks (ds )
7230+
7231+ // Mock Apple GDMF API (required for validating OS update minimum version settings)
7232+ mdmtest .StartNewAppleGDMFTestServer (t )
7233+
7234+ const (
7235+ localTeamName = "Team1"
7236+ fleetServerURL = "https://fleet.example.com"
7237+ orgName = "GitOps Test"
7238+ template = "iPad $FLEET_VAR_HOST_HARDWARE_SERIAL"
7239+ )
7240+
7241+ var savedTeam * fleet.Team
7242+ ds .TeamByNameFunc = func (ctx context.Context , name string ) (* fleet.Team , error ) {
7243+ if name == localTeamName && savedTeam != nil {
7244+ return savedTeam , nil
7245+ }
7246+ return nil , & notFoundError {}
7247+ }
7248+ ds .SaveTeamFunc = func (ctx context.Context , tm * fleet.Team ) (* fleet.Team , error ) {
7249+ savedTeam = tm
7250+ return tm , nil
7251+ }
7252+ ds .NewTeamFunc = func (ctx context.Context , newTeam * fleet.Team ) (* fleet.Team , error ) {
7253+ newTeam .ID = 1
7254+ savedTeam = newTeam
7255+ return newTeam , nil
7256+ }
7257+ ds .TeamByFilenameFunc = func (ctx context.Context , filename string ) (* fleet.Team , error ) {
7258+ if savedTeam != nil && savedTeam .Filename != nil && * savedTeam .Filename == filename {
7259+ return savedTeam , nil
7260+ }
7261+ return nil , & notFoundError {}
7262+ }
7263+ defaultTeamConfig := & fleet.TeamConfig {}
7264+ ds .TeamLiteFunc = func (ctx context.Context , tid uint ) (* fleet.TeamLite , error ) {
7265+ if tid == 0 {
7266+ return & fleet.TeamLite {ID : 0 , Name : fleet .ReservedNameNoTeam , Config : defaultTeamConfig .ToLite ()}, nil
7267+ }
7268+ if tid == 1 && savedTeam != nil {
7269+ return savedTeam .ToTeamLite (), nil
7270+ }
7271+ return nil , nil
7272+ }
7273+ ds .DefaultTeamConfigFunc = func (ctx context.Context ) (* fleet.TeamConfig , error ) { return defaultTeamConfig , nil }
7274+ ds .SaveDefaultTeamConfigFunc = func (ctx context.Context , config * fleet.TeamConfig ) error {
7275+ defaultTeamConfig = config
7276+ return nil
7277+ }
7278+ ds .LabelIDsByNameFunc = func (ctx context.Context , names []string , filter fleet.TeamFilter ) (map [string ]uint , error ) {
7279+ return map [string ]uint {
7280+ fleet .BuiltinLabelMacOS14Plus : 1 ,
7281+ fleet .BuiltinLabelIOS : 2 ,
7282+ fleet .BuiltinLabelIPadOS : 3 ,
7283+ }, nil
7284+ }
7285+ ds .HasAppleUpdateConfigProfileConfiguredFunc = func (ctx context.Context , teamID uint ) (bool , error ) { return false , nil }
7286+ ds .BulkUpsertHostDeviceNameEnforcementFunc = func (ctx context.Context , teamID uint ) error { return nil }
7287+ ds .DeleteHostDeviceNameEnforcementForTeamFunc = func (ctx context.Context , teamID uint ) error { return nil }
7288+
7289+ var emitted []activity_api.ActivityDetails
7290+ var emittedMu sync.Mutex
7291+ opts .ActivityMock .NewActivityFunc = func (_ context.Context , _ * activity_api.User , a activity_api.ActivityDetails ) error {
7292+ emittedMu .Lock ()
7293+ defer emittedMu .Unlock ()
7294+ emitted = append (emitted , a )
7295+ return nil
7296+ }
7297+ nameTemplateActivities := func () []fleet.ActivityTypeEditedHostNameTemplate {
7298+ emittedMu .Lock ()
7299+ defer emittedMu .Unlock ()
7300+ var out []fleet.ActivityTypeEditedHostNameTemplate
7301+ for _ , a := range emitted {
7302+ if act , ok := a .(fleet.ActivityTypeEditedHostNameTemplate ); ok {
7303+ out = append (out , act )
7304+ }
7305+ }
7306+ return out
7307+ }
7308+
7309+ writeYAML := func (t * testing.T , body string ) string {
7310+ f , err := os .CreateTemp (t .TempDir (), "*.yml" )
7311+ require .NoError (t , err )
7312+ _ , err = f .WriteString (body )
7313+ require .NoError (t , err )
7314+ require .NoError (t , f .Close ())
7315+ return f .Name ()
7316+ }
7317+ teamYAML := func (controlsSection string ) string {
7318+ return fmt .Sprintf (`
7319+ controls:
7320+ %s
7321+ queries:
7322+ policies:
7323+ agent_options:
7324+ name: %s
7325+ team_settings:
7326+ secrets:
7327+ - secret: test
7328+ software:
7329+ ` , controlsSection , localTeamName )
7330+ }
7331+
7332+ // 0. Dry-run validates but does not persist, and still enforces the
7333+ // fleets-only rule (GitOps CI runs --dry-run on every PR).
7334+ t .Run ("dry run does not persist a valid template" , func (t * testing.T ) {
7335+ ds .BulkUpsertHostDeviceNameEnforcementFuncInvoked = false
7336+ yml := writeYAML (t , teamYAML (fmt .Sprintf (" name_template: %q" , template )))
7337+ _ = runAppForTest (t , []string {"gitops" , "-f" , yml , "--dry-run" })
7338+
7339+ require .Nil (t , savedTeam )
7340+ require .False (t , ds .BulkUpsertHostDeviceNameEnforcementFuncInvoked )
7341+ require .Empty (t , nameTemplateActivities ())
7342+ })
7343+
7344+ t .Run ("dry run rejects org-level template" , func (t * testing.T ) {
7345+ yml := writeYAML (t , fmt .Sprintf (`
7346+ controls:
7347+ name_template: %q
7348+ queries:
7349+ policies:
7350+ agent_options:
7351+ org_settings:
7352+ server_settings:
7353+ server_url: %s
7354+ org_info:
7355+ contact_url: https://example.com/contact
7356+ org_logo_url: ""
7357+ org_logo_url_light_background: ""
7358+ org_name: %s
7359+ secrets:
7360+ software:
7361+ ` , template , fleetServerURL , orgName ))
7362+ _ , err := runAppNoChecks ([]string {"gitops" , "-f" , yml , "--dry-run" })
7363+ require .Error (t , err )
7364+ require .Contains (t , err .Error (), "only supported for fleets" )
7365+ })
7366+
7367+ // 1. Setting the template on a fleet stores it and emits the activity.
7368+ t .Run ("set template on fleet" , func (t * testing.T ) {
7369+ ds .BulkUpsertHostDeviceNameEnforcementFuncInvoked = false
7370+ yml := writeYAML (t , teamYAML (fmt .Sprintf (" name_template: %q" , template )))
7371+ _ = runAppForTest (t , []string {"gitops" , "-f" , yml })
7372+
7373+ require .NotNil (t , savedTeam )
7374+ require .Equal (t , template , savedTeam .Config .MDM .HostNameTemplate )
7375+ require .True (t , ds .BulkUpsertHostDeviceNameEnforcementFuncInvoked )
7376+
7377+ acts := nameTemplateActivities ()
7378+ require .Len (t , acts , 1 )
7379+ require .NotNil (t , acts [0 ].HostNameTemplate )
7380+ require .Equal (t , template , * acts [0 ].HostNameTemplate )
7381+ })
7382+
7383+ // 2. Re-applying the same YAML is a no-op: no re-enqueue, no duplicate activity.
7384+ t .Run ("idempotent re-apply" , func (t * testing.T ) {
7385+ ds .BulkUpsertHostDeviceNameEnforcementFuncInvoked = false
7386+ yml := writeYAML (t , teamYAML (fmt .Sprintf (" name_template: %q" , template )))
7387+ _ = runAppForTest (t , []string {"gitops" , "-f" , yml })
7388+
7389+ require .Equal (t , template , savedTeam .Config .MDM .HostNameTemplate )
7390+ require .False (t , ds .BulkUpsertHostDeviceNameEnforcementFuncInvoked )
7391+ require .Len (t , nameTemplateActivities (), 1 ) // still just the one from step 1
7392+ })
7393+
7394+ // 3. Removing the key clears the template (declarative) and emits a null activity.
7395+ t .Run ("clear template" , func (t * testing.T ) {
7396+ ds .DeleteHostDeviceNameEnforcementForTeamFuncInvoked = false
7397+ yml := writeYAML (t , teamYAML ("" ))
7398+ _ = runAppForTest (t , []string {"gitops" , "-f" , yml })
7399+
7400+ require .Empty (t , savedTeam .Config .MDM .HostNameTemplate )
7401+ require .True (t , ds .DeleteHostDeviceNameEnforcementForTeamFuncInvoked )
7402+
7403+ acts := nameTemplateActivities ()
7404+ require .Len (t , acts , 2 )
7405+ require .Nil (t , acts [1 ].HostNameTemplate ) // cleared → null
7406+ })
7407+
7408+ // 4. An invalid variable surfaces the server's 422 validation message through fleetctl.
7409+ t .Run ("invalid variable rejected" , func (t * testing.T ) {
7410+ yml := writeYAML (t , teamYAML (` name_template: "$FLEET_VAR_HOST_END_USER_IDP_GROUPS"` ))
7411+ _ , err := runAppNoChecks ([]string {"gitops" , "-f" , yml })
7412+ require .Error (t , err )
7413+ require .Contains (t , err .Error (), "is not supported in host name templates" )
7414+ })
7415+
7416+ // 4b. A secret variable is rejected at the GitOps env-expansion layer (before
7417+ // the server), since $FLEET_SECRET_* is only allowed in profiles and scripts.
7418+ t .Run ("secret variable rejected at expansion" , func (t * testing.T ) {
7419+ yml := writeYAML (t , teamYAML (` name_template: "iPad $FLEET_SECRET_FOO"` ))
7420+ _ , err := runAppNoChecks ([]string {"gitops" , "-f" , yml })
7421+ require .Error (t , err )
7422+ require .Contains (t , err .Error (), "FLEET_SECRET_" )
7423+ require .Contains (t , err .Error (), "only allowed in profiles and scripts" )
7424+ })
7425+
7426+ // 5. name_template in org-level controls is rejected (fleets only).
7427+ t .Run ("rejected at org level" , func (t * testing.T ) {
7428+ yml := writeYAML (t , fmt .Sprintf (`
7429+ controls:
7430+ name_template: %q
7431+ queries:
7432+ policies:
7433+ agent_options:
7434+ org_settings:
7435+ server_settings:
7436+ server_url: %s
7437+ org_info:
7438+ contact_url: https://example.com/contact
7439+ org_logo_url: ""
7440+ org_logo_url_light_background: ""
7441+ org_name: %s
7442+ secrets:
7443+ software:
7444+ ` , template , fleetServerURL , orgName ))
7445+ _ , err := runAppNoChecks ([]string {"gitops" , "-f" , yml })
7446+ require .Error (t , err )
7447+ require .Contains (t , err .Error (), "only supported for fleets" )
7448+ })
7449+
7450+ // 6. name_template in no-team.yml controls is rejected (fleets only).
7451+ t .Run ("rejected for no team" , func (t * testing.T ) {
7452+ globalFile := createGlobalFileBasic (t , fleetServerURL , orgName )
7453+
7454+ // The file must be named exactly "no-team.yml".
7455+ noTeamPath := filepath .Join (t .TempDir (), "no-team.yml" )
7456+ noTeamFile , err := os .Create (noTeamPath )
7457+ require .NoError (t , err )
7458+ _ , err = noTeamFile .WriteString (fmt .Sprintf (`
7459+ controls:
7460+ name_template: %q
7461+ policies:
7462+ name: No team
7463+ software:
7464+ ` , template ))
7465+ require .NoError (t , err )
7466+ require .NoError (t , noTeamFile .Close ())
7467+
7468+ _ , err = runAppNoChecks ([]string {"gitops" , "-f" , globalFile .Name (), "-f" , noTeamPath })
7469+ require .Error (t , err )
7470+ require .Contains (t , err .Error (), "only supported for fleets" )
7471+ })
7472+ }
7473+
72217474func TestGitOpsWindowsOSUpdates (t * testing.T ) {
72227475 license := & fleet.LicenseInfo {Tier : fleet .TierPremium , Expiration : time .Now ().Add (24 * time .Hour )}
72237476 _ , ds := testing_utils .RunServerWithMockedDS (
0 commit comments