77 "github.com/fleetdm/fleet/v4/server/fleet"
88 apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
99 "github.com/fleetdm/fleet/v4/server/test"
10+ "github.com/jmoiron/sqlx"
1011 "github.com/stretchr/testify/require"
1112)
1213
@@ -22,6 +23,11 @@ func TestHostDeviceNames(t *testing.T) {
2223 {"Verify" , testHostDeviceNamesVerify },
2324 {"Resend" , testHostDeviceNamesResend },
2425 {"Reconcile" , testHostDeviceNamesReconcile },
26+ {"TransferViaAddHostsToTeam" , testHostDeviceNamesTransferViaAddHostsToTeam },
27+ {"TransferBatched" , testHostDeviceNamesTransferBatched },
28+ {"SummaryAndFilter" , testHostDeviceNamesSummaryAndFilter },
29+ {"SummaryFilterLabel" , testHostDeviceNamesSummaryFilterLabel },
30+ {"TeamDeletionCleanup" , testHostDeviceNamesTeamDeletionCleanup },
2531 {"HostDeletionCleanup" , testHostDeviceNamesHostDeletionCleanup },
2632 {"FullLifecycle" , testHostDeviceNamesFullLifecycle },
2733 }
@@ -302,6 +308,249 @@ func testHostDeviceNamesReconcile(t *testing.T, ds *Datastore) {
302308 require .NoError (t , ds .ReconcileHostDeviceNamesForHosts (ctx , nil ))
303309}
304310
311+ // setDeviceNameTemplate writes name_template into a team's config JSON directly,
312+ // so the eligibility/reconcile SQL sees a non-empty template without depending on
313+ // the TeamMDM struct field.
314+ func setDeviceNameTemplate (t * testing.T , ds * Datastore , teamID uint , tmpl string ) {
315+ _ , err := ds .writer (t .Context ()).ExecContext (t .Context (),
316+ `UPDATE teams SET config = JSON_SET(config, '$.mdm.name_template', ?) WHERE id = ?` , tmpl , teamID )
317+ require .NoError (t , err )
318+ }
319+
320+ // testHostDeviceNamesTransferViaAddHostsToTeam exercises the transfer
321+ // reconciliation wired into the AddHostsToTeam datastore transaction, which
322+ // covers every service entry point that moves hosts between teams.
323+ func testHostDeviceNamesTransferViaAddHostsToTeam (t * testing.T , ds * Datastore ) {
324+ ctx := t .Context ()
325+
326+ withTemplate , err := ds .NewTeam (ctx , & fleet.Team {Name : "transfer-with-template" })
327+ require .NoError (t , err )
328+ setDeviceNameTemplate (t , ds , withTemplate .ID , "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL" )
329+
330+ noTemplate , err := ds .NewTeam (ctx , & fleet.Team {Name : "transfer-no-template" })
331+ require .NoError (t , err )
332+
333+ // A host starts in the template team with a queued row.
334+ host := enrollAppleHostForDeviceName (t , ds , "mac" , "darwin" , withTemplate .ID , false )
335+ require .NoError (t , ds .BulkUpsertHostDeviceNameEnforcement (ctx , withTemplate .ID ))
336+ require .Nil (t , getDeviceNameRow (t , ds , host .UUID ).Status )
337+
338+ // Give the host a name so we can assert the transfer never renames it.
339+ _ , err = ds .writer (ctx ).ExecContext (ctx , `UPDATE hosts SET computer_name = ? WHERE id = ?` , "keep-this-name" , host .ID )
340+ require .NoError (t , err )
341+
342+ // template -> template-less: the row is deleted (enforcement stops) and the
343+ // host's name is left untouched.
344+ require .NoError (t , ds .AddHostsToTeam (ctx , fleet .NewAddHostsToTeamParams (& noTemplate .ID , []uint {host .ID })))
345+ _ , err = ds .GetHostDeviceNameEnforcement (ctx , host .UUID )
346+ require .True (t , fleet .IsNotFound (err ), "transfer to a template-less team should delete the enforcement row" )
347+ var name string
348+ require .NoError (t , sqlx .GetContext (ctx , ds .reader (ctx ), & name , `SELECT computer_name FROM hosts WHERE id = ?` , host .ID ))
349+ require .Equal (t , "keep-this-name" , name , "transfer must not rename the host" )
350+
351+ // template-less -> template: reconcile re-creates the queued row (UI reverts
352+ // to Enforcing/Pending).
353+ require .NoError (t , ds .AddHostsToTeam (ctx , fleet .NewAddHostsToTeamParams (& withTemplate .ID , []uint {host .ID })))
354+ require .Nil (t , getDeviceNameRow (t , ds , host .UUID ).Status , "transfer into a template team should create a queued row" )
355+
356+ // template -> template (a different team that also has a template): the row is
357+ // reset to NULL so the destination team's template is enforced afresh. Mark it
358+ // verified first to prove the transfer resets an already-settled row.
359+ _ , err = ds .writer (ctx ).ExecContext (ctx , `UPDATE host_mdm_apple_device_names SET status = ? WHERE host_uuid = ?` , fleet .MDMDeliveryVerified , host .UUID )
360+ require .NoError (t , err )
361+ otherTemplate , err := ds .NewTeam (ctx , & fleet.Team {Name : "transfer-other-template" })
362+ require .NoError (t , err )
363+ setDeviceNameTemplate (t , ds , otherTemplate .ID , "Lab-$FLEET_VAR_HOST_HARDWARE_SERIAL" )
364+ require .NoError (t , ds .AddHostsToTeam (ctx , fleet .NewAddHostsToTeamParams (& otherTemplate .ID , []uint {host .ID })))
365+ require .Nil (t , getDeviceNameRow (t , ds , host .UUID ).Status , "transfer between template teams should reset the row to queued" )
366+
367+ // template -> No team: the row is deleted (No team never enforces).
368+ require .NoError (t , ds .AddHostsToTeam (ctx , fleet .NewAddHostsToTeamParams (nil , []uint {host .ID })))
369+ _ , err = ds .GetHostDeviceNameEnforcement (ctx , host .UUID )
370+ require .True (t , fleet .IsNotFound (err ), "transfer to No team should delete the enforcement row" )
371+ }
372+
373+ // testHostDeviceNamesTransferBatched moves several hosts at once with a batch
374+ // size smaller than the host count, so the reconcile runs across multiple batches
375+ // within AddHostsToTeam.
376+ func testHostDeviceNamesTransferBatched (t * testing.T , ds * Datastore ) {
377+ ctx := t .Context ()
378+
379+ noTemplate , err := ds .NewTeam (ctx , & fleet.Team {Name : "batch-no-template" })
380+ require .NoError (t , err )
381+ withTemplate , err := ds .NewTeam (ctx , & fleet.Team {Name : "batch-with-template" })
382+ require .NoError (t , err )
383+ setDeviceNameTemplate (t , ds , withTemplate .ID , "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL" )
384+
385+ // Three eligible hosts start in the template-less team (no rows) plus one BYOD
386+ // host that must never get a row.
387+ var hostIDs []uint
388+ eligible := make ([]* fleet.Host , 0 , 3 )
389+ for i := range 3 {
390+ h := enrollAppleHostForDeviceName (t , ds , "batch" + string (rune ('a' + i )), "darwin" , noTemplate .ID , false )
391+ eligible = append (eligible , h )
392+ hostIDs = append (hostIDs , h .ID )
393+ }
394+ byod := enrollAppleHostForDeviceName (t , ds , "batch-byod" , "ios" , noTemplate .ID , true )
395+ hostIDs = append (hostIDs , byod .ID )
396+
397+ // Move all four into the template team with a batch size of 1 so the reconcile
398+ // runs once per batch.
399+ require .NoError (t , ds .AddHostsToTeam (ctx , fleet .NewAddHostsToTeamParams (& withTemplate .ID , hostIDs ).WithBatchSize (1 )))
400+ for _ , h := range eligible {
401+ require .Nil (t , getDeviceNameRow (t , ds , h .UUID ).Status , "eligible host %s should be queued after batched transfer" , h .Hostname )
402+ }
403+ _ , err = ds .GetHostDeviceNameEnforcement (ctx , byod .UUID )
404+ require .True (t , fleet .IsNotFound (err ), "BYOD host must not get a row" )
405+
406+ // Move them all back to the template-less team, again batched: all rows deleted.
407+ require .NoError (t , ds .AddHostsToTeam (ctx , fleet .NewAddHostsToTeamParams (& noTemplate .ID , hostIDs ).WithBatchSize (2 )))
408+ for _ , h := range eligible {
409+ _ , err = ds .GetHostDeviceNameEnforcement (ctx , h .UUID )
410+ require .True (t , fleet .IsNotFound (err ), "row for %s should be deleted after batched transfer out" , h .Hostname )
411+ }
412+ }
413+
414+ // testHostDeviceNamesSummaryAndFilter asserts that host-name enforcement rows are
415+ // folded into the OS-settings aggregate counts and the os_settings host-list
416+ // filter, and that ineligible hosts (no row) count in no bucket.
417+ func testHostDeviceNamesSummaryAndFilter (t * testing.T , ds * Datastore ) {
418+ ctx := t .Context ()
419+
420+ team , err := ds .NewTeam (ctx , & fleet.Team {Name : "summary-team" })
421+ require .NoError (t , err )
422+ setDeviceNameTemplate (t , ds , team .ID , "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL" )
423+
424+ failedHost := enrollAppleHostForDeviceName (t , ds , "failed" , "darwin" , team .ID , false )
425+ verifiedHost := enrollAppleHostForDeviceName (t , ds , "verified" , "ios" , team .ID , false )
426+ verifyingHost := enrollAppleHostForDeviceName (t , ds , "verifying" , "ios" , team .ID , false )
427+ queuedHost := enrollAppleHostForDeviceName (t , ds , "queued" , "ipados" , team .ID , false )
428+ comboHost := enrollAppleHostForDeviceName (t , ds , "combo" , "darwin" , team .ID , false )
429+ byodHost := enrollAppleHostForDeviceName (t , ds , "byod" , "ios" , team .ID , true )
430+
431+ require .NoError (t , ds .BulkUpsertHostDeviceNameEnforcement (ctx , team .ID ))
432+ // The BYOD host is ineligible, so it never got a row.
433+ _ , err = ds .GetHostDeviceNameEnforcement (ctx , byodHost .UUID )
434+ require .True (t , fleet .IsNotFound (err ))
435+
436+ setStatus := func (hostUUID string , status fleet.MDMDeliveryStatus ) {
437+ _ , err := ds .writer (ctx ).ExecContext (ctx , `UPDATE host_mdm_apple_device_names SET status = ? WHERE host_uuid = ?` , status , hostUUID )
438+ require .NoError (t , err )
439+ }
440+ setStatus (failedHost .UUID , fleet .MDMDeliveryFailed )
441+ setStatus (verifiedHost .UUID , fleet .MDMDeliveryVerified )
442+ setStatus (verifyingHost .UUID , fleet .MDMDeliveryVerifying )
443+ setStatus (comboHost .UUID , fleet .MDMDeliveryFailed )
444+ // queuedHost keeps its NULL status from the bulk upsert, which renders as pending.
445+
446+ // comboHost has a failed rename (set above) AND a verified (install) config
447+ // profile: the shared status CASE must combine the profile and device-name
448+ // buckets and let the failed rename win (failed > verified precedence).
449+ _ , err = ds .writer (ctx ).ExecContext (ctx , `
450+ INSERT INTO host_mdm_apple_profiles
451+ (host_uuid, profile_uuid, command_uuid, status, operation_type, detail, profile_name, profile_identifier, checksum)
452+ VALUES (?, ?, '', ?, ?, '', 'p1', 'com.example.p1', ?)` ,
453+ comboHost .UUID , "a" + comboHost .UUID , fleet .MDMDeliveryVerified , fleet .MDMOperationTypeInstall , []byte ("csum" ))
454+ require .NoError (t , err )
455+
456+ // Aggregate summary folds the rename statuses in (a NULL/queued row counts as
457+ // pending); the BYOD host is in no bucket, and comboHost lands in failed.
458+ summary , err := ds .GetMDMAppleProfilesSummary (ctx , & team .ID )
459+ require .NoError (t , err )
460+ require .Equal (t , uint (2 ), summary .Failed , "failedHost + comboHost (failed rename wins over its verified profile)" )
461+ require .Equal (t , uint (1 ), summary .Verified )
462+ require .Equal (t , uint (1 ), summary .Verifying )
463+ require .Equal (t , uint (1 ), summary .Pending )
464+
465+ // Each aggregate card's host-list filter returns the matching hosts, including
466+ // the queued (NULL-status) host under pending and comboHost under failed.
467+ userFilter := fleet.TeamFilter {User : test .UserAdmin }
468+ assertFilter := func (status fleet.OSSettingsStatus , want ... * fleet.Host ) {
469+ hosts , err := ds .ListHosts (ctx , userFilter , fleet.HostListOptions {TeamFilter : & team .ID , OSSettingsFilter : status })
470+ require .NoError (t , err )
471+ gotIDs := make ([]uint , 0 , len (hosts ))
472+ for _ , h := range hosts {
473+ gotIDs = append (gotIDs , h .ID )
474+ }
475+ wantIDs := make ([]uint , 0 , len (want ))
476+ for _ , h := range want {
477+ wantIDs = append (wantIDs , h .ID )
478+ }
479+ require .ElementsMatch (t , wantIDs , gotIDs )
480+ }
481+ assertFilter (fleet .OSSettingsFailed , failedHost , comboHost )
482+ assertFilter (fleet .OSSettingsVerified , verifiedHost )
483+ assertFilter (fleet .OSSettingsVerifying , verifyingHost )
484+ assertFilter (fleet .OSSettingsPending , queuedHost )
485+ }
486+
487+ // testHostDeviceNamesSummaryFilterLabel covers the os_settings host-list filter
488+ // on the label-hosts path (ListHostsInLabel), which folds in the same
489+ // device-name status join as the main list path.
490+ func testHostDeviceNamesSummaryFilterLabel (t * testing.T , ds * Datastore ) {
491+ ctx := t .Context ()
492+
493+ team , err := ds .NewTeam (ctx , & fleet.Team {Name : "label-team" })
494+ require .NoError (t , err )
495+ setDeviceNameTemplate (t , ds , team .ID , "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL" )
496+
497+ failedHost := enrollAppleHostForDeviceName (t , ds , "label-failed" , "darwin" , team .ID , false )
498+ verifiedHost := enrollAppleHostForDeviceName (t , ds , "label-verified" , "ios" , team .ID , false )
499+ require .NoError (t , ds .BulkUpsertHostDeviceNameEnforcement (ctx , team .ID ))
500+ _ , err = ds .writer (ctx ).ExecContext (ctx , `UPDATE host_mdm_apple_device_names SET status = ? WHERE host_uuid = ?` , fleet .MDMDeliveryFailed , failedHost .UUID )
501+ require .NoError (t , err )
502+ _ , err = ds .writer (ctx ).ExecContext (ctx , `UPDATE host_mdm_apple_device_names SET status = ? WHERE host_uuid = ?` , fleet .MDMDeliveryVerified , verifiedHost .UUID )
503+ require .NoError (t , err )
504+
505+ label , err := ds .NewLabel (ctx , & fleet.Label {Name : "label-dn" , Query : "select 1" })
506+ require .NoError (t , err )
507+ for _ , h := range []* fleet.Host {failedHost , verifiedHost } {
508+ require .NoError (t , ds .RecordLabelQueryExecutions (ctx , h , map [uint ]* bool {label .ID : new (true )}, time .Now (), false ))
509+ }
510+
511+ userFilter := fleet.TeamFilter {User : test .UserAdmin }
512+ hosts , err := ds .ListHostsInLabel (ctx , userFilter , label .ID , fleet.HostListOptions {TeamFilter : & team .ID , OSSettingsFilter : fleet .OSSettingsFailed })
513+ require .NoError (t , err )
514+ require .Len (t , hosts , 1 )
515+ require .Equal (t , failedHost .ID , hosts [0 ].ID )
516+ }
517+
518+ // testHostDeviceNamesTeamDeletionCleanup asserts that deleting a team removes the
519+ // host-name enforcement rows for its hosts. Team deletion moves the hosts to "No
520+ // team" via ON DELETE SET NULL (not AddHostsToTeam), so the enforcement rows must
521+ // be cleaned up explicitly in DeleteTeam.
522+ func testHostDeviceNamesTeamDeletionCleanup (t * testing.T , ds * Datastore ) {
523+ ctx := t .Context ()
524+
525+ team , err := ds .NewTeam (ctx , & fleet.Team {Name : "delete-me" })
526+ require .NoError (t , err )
527+ setDeviceNameTemplate (t , ds , team .ID , "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL" )
528+ host := enrollAppleHostForDeviceName (t , ds , "del" , "darwin" , team .ID , false )
529+
530+ otherTeam , err := ds .NewTeam (ctx , & fleet.Team {Name : "keep-me" })
531+ require .NoError (t , err )
532+ setDeviceNameTemplate (t , ds , otherTeam .ID , "WS-$FLEET_VAR_HOST_HARDWARE_SERIAL" )
533+ otherHost := enrollAppleHostForDeviceName (t , ds , "keep" , "darwin" , otherTeam .ID , false )
534+
535+ require .NoError (t , ds .BulkUpsertHostDeviceNameEnforcement (ctx , team .ID ))
536+ require .NoError (t , ds .BulkUpsertHostDeviceNameEnforcement (ctx , otherTeam .ID ))
537+ require .Nil (t , getDeviceNameRow (t , ds , host .UUID ).Status )
538+ require .Nil (t , getDeviceNameRow (t , ds , otherHost .UUID ).Status )
539+
540+ require .NoError (t , ds .DeleteTeam (ctx , team .ID ))
541+
542+ // The deleted team's host keeps its record but moves to No team; its
543+ // enforcement row is gone.
544+ _ , err = ds .GetHostDeviceNameEnforcement (ctx , host .UUID )
545+ require .True (t , fleet .IsNotFound (err ), "enforcement row must be deleted with the team" )
546+ movedHost , err := ds .Host (ctx , host .ID )
547+ require .NoError (t , err )
548+ require .Nil (t , movedHost .TeamID , "host should have moved to No team, not been deleted" )
549+
550+ // The other team's row is untouched.
551+ require .Nil (t , getDeviceNameRow (t , ds , otherHost .UUID ).Status , "other team's row must survive" )
552+ }
553+
305554func testHostDeviceNamesHostDeletionCleanup (t * testing.T , ds * Datastore ) {
306555 ctx := t .Context ()
307556
0 commit comments