SHAA: host dep details API (#42250)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #40794

# Checklist for submitter

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

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually
This commit is contained in:
Jahziel Villasana-Espinoza
2026-03-24 09:49:26 -04:00
committed by GitHub
parent a265768d20
commit 588106aca1
8 changed files with 395 additions and 7 deletions
+3 -1
View File
@@ -2026,7 +2026,9 @@ func unionSelectDevices(devices []hostToCreateFromMDM) (stmt string, args []inte
func (ds *Datastore) GetHostDEPAssignment(ctx context.Context, hostID uint) (*fleet.HostDEPAssignment, error) {
var res fleet.HostDEPAssignment
err := sqlx.GetContext(ctx, ds.reader(ctx), &res, `
SELECT host_id, added_at, deleted_at, abm_token_id, mdm_migration_deadline, mdm_migration_completed FROM host_dep_assignments hdep WHERE hdep.host_id = ?`, hostID)
SELECT host_id, added_at, deleted_at, abm_token_id, mdm_migration_deadline, mdm_migration_completed,
profile_uuid, assign_profile_response, response_updated_at
FROM host_dep_assignments hdep WHERE hdep.host_id = ?`, hostID)
if err != nil {
if err == sql.ErrNoRows {
return nil, ctxerr.Wrap(ctx, notFound("HostDEPAssignment").WithID(hostID))
+45
View File
@@ -6753,6 +6753,46 @@ func testMDMAppleDEPAssignmentUpdates(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.Equal(t, h.ID, assignment.HostID)
require.Nil(t, assignment.DeletedAt)
// profile fields are nil before any assignment response has been recorded
require.Nil(t, assignment.ProfileUUID)
require.Nil(t, assignment.AssignProfileResponse)
require.Nil(t, assignment.ResponseUpdatedAt)
// simulate a successful profile assignment response from Apple
profileUUID := uuid.NewString()
err = ds.UpdateHostDEPAssignProfileResponses(ctx, &godep.ProfileResponse{
ProfileUUID: profileUUID,
Devices: map[string]string{h.HardwareSerial: string(fleet.DEPAssignProfileResponseSuccess)},
}, abmToken.ID)
require.NoError(t, err)
beforeDelete, err := ds.GetHostDEPAssignment(ctx, h.ID)
require.NoError(t, err)
require.Equal(t, h.ID, beforeDelete.HostID)
require.Nil(t, beforeDelete.DeletedAt)
// profile fields are now populated
require.NotNil(t, beforeDelete.ProfileUUID)
require.Equal(t, profileUUID, *beforeDelete.ProfileUUID)
require.NotNil(t, beforeDelete.AssignProfileResponse)
require.Equal(t, fleet.DEPAssignProfileResponseSuccess, *beforeDelete.AssignProfileResponse)
require.NotNil(t, beforeDelete.ResponseUpdatedAt)
require.WithinDuration(t, time.Now(), *beforeDelete.ResponseUpdatedAt, 5*time.Second)
// simulate a failed profile assignment response — fields should be updated
profileUUID2 := uuid.NewString()
err = ds.UpdateHostDEPAssignProfileResponses(ctx, &godep.ProfileResponse{
ProfileUUID: profileUUID2,
Devices: map[string]string{h.HardwareSerial: string(fleet.DEPAssignProfileResponseFailed)},
}, abmToken.ID)
require.NoError(t, err)
afterFail, err := ds.GetHostDEPAssignment(ctx, h.ID)
require.NoError(t, err)
require.NotNil(t, afterFail.ProfileUUID)
require.Equal(t, profileUUID2, *afterFail.ProfileUUID)
require.NotNil(t, afterFail.AssignProfileResponse)
require.Equal(t, fleet.DEPAssignProfileResponseFailed, *afterFail.AssignProfileResponse)
require.NotNil(t, afterFail.ResponseUpdatedAt)
err = ds.DeleteHostDEPAssignments(ctx, abmToken.ID, []string{h.HardwareSerial})
require.NoError(t, err)
@@ -6768,6 +6808,11 @@ func testMDMAppleDEPAssignmentUpdates(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.Equal(t, h.ID, assignment.HostID)
require.Nil(t, assignment.DeletedAt)
// profile fields survive an upsert (the upsert only resets added_at/deleted_at)
require.NotNil(t, assignment.ProfileUUID)
require.Equal(t, profileUUID2, *assignment.ProfileUUID)
require.NotNil(t, assignment.AssignProfileResponse)
require.Equal(t, fleet.DEPAssignProfileResponseFailed, *assignment.AssignProfileResponse)
}
func createRawAppleCmd(reqType, cmdUUID string) string {
+13 -6
View File
@@ -526,22 +526,29 @@ func (p MDMAppleSetupPayload) AuthzType() string {
// HostDEPAssignment represents a row in the host_dep_assignments table.
type HostDEPAssignment struct {
// HostID is the id of the host in Fleet.
HostID uint `db:"host_id"`
HostID uint `db:"host_id" json:"-"`
// AddedAt is the timestamp when Fleet was notified that device was added to the Fleet MDM
// server in Apple Busines Manager (ABM).
AddedAt time.Time `db:"added_at"`
AddedAt time.Time `db:"added_at" json:"added_at"`
// DeletedAt is the timestamp when Fleet was notified that device was deleted from the Fleet
// MDM server in Apple Busines Manager (ABM).
DeletedAt *time.Time `db:"deleted_at"`
DeletedAt *time.Time `db:"deleted_at" json:"deleted_at"`
// ABMTokenID is the ID of the ABM token that was used to make this DEP assignment.
ABMTokenID *uint `db:"abm_token_id"`
ABMTokenID *uint `db:"abm_token_id" json:"abm_token_id"`
// MDMMigrationDeadline is the deadline for the MDM migration received from ABM on the host's
// most recent sync.
MDMMigrationDeadline *time.Time `db:"mdm_migration_deadline"`
MDMMigrationDeadline *time.Time `db:"mdm_migration_deadline" json:"mdm_migration_deadline,omitempty"`
// MDMMigrationCompleted is the value of MDMMigrationDeadline when the host completed its last
// Migration. Not a timestamp but a marker that the host completed the Migration for a given
// date.
MDMMigrationCompleted *time.Time `db:"mdm_migration_completed"`
MDMMigrationCompleted *time.Time `db:"mdm_migration_completed" json:"mdm_migration_completed,omitempty"`
// ProfileUUID is the UUID of the enrollment profile last assigned by Fleet via ABM.
ProfileUUID *string `db:"profile_uuid" json:"profile_uuid,omitempty"`
// AssignProfileResponse is the status returned by Apple when Fleet last
// assigned the enrollment profile (SUCCESS, FAILED, NOT_ACCESSIBLE, THROTTLED).
AssignProfileResponse *DEPAssignProfileResponseStatus `db:"assign_profile_response" json:"assign_profile_response,omitempty"`
// ResponseUpdatedAt is the timestamp when AssignProfileResponse was last updated.
ResponseUpdatedAt *time.Time `db:"response_updated_at" json:"response_updated_at,omitempty"`
}
func (h *HostDEPAssignment) IsDEPAssignedToFleet() bool {
+7
View File
@@ -9,6 +9,7 @@ import (
"net/url"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/version"
"github.com/fleetdm/fleet/v4/server/websocket"
)
@@ -881,6 +882,12 @@ type Service interface {
// GetHostDEPAssignment retrieves the host DEP assignment for the specified host.
GetHostDEPAssignment(ctx context.Context, host *Host) (*HostDEPAssignment, error)
// GetHostDEPAssignmentDetails retrieves Fleet's DEP assignment record and
// Apple's live device details from ABM for the given host ID.
// Returns (nil, nil, nil) for non-DEP hosts.
// If ABM returns an error, dep_device is nil and the error is logged.
GetHostDEPAssignmentDetails(ctx context.Context, hostID uint) (*HostDEPAssignment, *godep.Device, error)
// NewMDMAppleConfigProfile creates a new configuration profile for the specified team.
NewMDMAppleConfigProfile(ctx context.Context, teamID uint, data []byte, labels []string, labelsMembershipMode MDMLabelsMode) (*MDMAppleConfigProfile, error)
// NewMDMAppleConfigProfileWithPayload creates a new declaration for the specified team.
+13
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/version"
"github.com/fleetdm/fleet/v4/server/websocket"
)
@@ -559,6 +560,8 @@ type BatchAssociateVPPAppsFunc func(ctx context.Context, teamName string, payloa
type GetHostDEPAssignmentFunc func(ctx context.Context, host *fleet.Host) (*fleet.HostDEPAssignment, error)
type GetHostDEPAssignmentDetailsFunc func(ctx context.Context, hostID uint) (*fleet.HostDEPAssignment, *godep.Device, error)
type NewMDMAppleConfigProfileFunc func(ctx context.Context, teamID uint, data []byte, labels []string, labelsMembershipMode fleet.MDMLabelsMode) (*fleet.MDMAppleConfigProfile, error)
type NewMDMAppleDeclarationFunc func(ctx context.Context, teamID uint, data []byte, labels []string, name string, labelsMembershipMode fleet.MDMLabelsMode) (*fleet.MDMAppleDeclaration, error)
@@ -1708,6 +1711,9 @@ type Service struct {
GetHostDEPAssignmentFunc GetHostDEPAssignmentFunc
GetHostDEPAssignmentFuncInvoked bool
GetHostDEPAssignmentDetailsFunc GetHostDEPAssignmentDetailsFunc
GetHostDEPAssignmentDetailsFuncInvoked bool
NewMDMAppleConfigProfileFunc NewMDMAppleConfigProfileFunc
NewMDMAppleConfigProfileFuncInvoked bool
@@ -4108,6 +4114,13 @@ func (s *Service) GetHostDEPAssignment(ctx context.Context, host *fleet.Host) (*
return s.GetHostDEPAssignmentFunc(ctx, host)
}
func (s *Service) GetHostDEPAssignmentDetails(ctx context.Context, hostID uint) (*fleet.HostDEPAssignment, *godep.Device, error) {
s.mu.Lock()
s.GetHostDEPAssignmentDetailsFuncInvoked = true
s.mu.Unlock()
return s.GetHostDEPAssignmentDetailsFunc(ctx, hostID)
}
func (s *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, data []byte, labels []string, labelsMembershipMode fleet.MDMLabelsMode) (*fleet.MDMAppleConfigProfile, error) {
s.mu.Lock()
s.NewMDMAppleConfigProfileFuncInvoked = true
+2
View File
@@ -484,6 +484,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.GET("/api/_version_/fleet/hosts/summary/mdm", getHostMDMSummary, getHostMDMSummaryRequest{})
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/mdm", getHostMDM, getHostMDMRequest{})
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/dep_assignment", getHostDEPAssignmentEndpoint, getHostDEPAssignmentRequest{})
ue.POST("/api/_version_/fleet/labels", createLabelEndpoint, createLabelRequest{})
ue.PATCH("/api/_version_/fleet/labels/{id:[0-9]+}", modifyLabelEndpoint, modifyLabelRequest{})
ue.GET("/api/_version_/fleet/labels/{id:[0-9]+}", getLabelEndpoint, getLabelRequest{})
+92
View File
@@ -29,9 +29,11 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/assets"
mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/worker"
"github.com/gocarina/gocsv"
@@ -2340,6 +2342,96 @@ func (svc *Service) DeleteHostIDP(ctx context.Context, id uint) error {
return nil
}
////////////////////////////////////////////////////////////////////////////////
// Get host DEP assignment
////////////////////////////////////////////////////////////////////////////////
type getHostDEPAssignmentRequest struct {
ID uint `url:"id"`
}
type getHostDEPAssignmentResponse struct {
ID uint `json:"id"`
HostDEPAssignment *fleet.HostDEPAssignment `json:"host_dep_assignment"`
DEPDevice *godep.Device `json:"dep_device"`
Err error `json:"error,omitempty"`
}
func (r getHostDEPAssignmentResponse) Error() error { return r.Err }
func getHostDEPAssignmentEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getHostDEPAssignmentRequest)
depAssignment, depDevice, err := svc.GetHostDEPAssignmentDetails(ctx, req.ID)
if err != nil {
return getHostDEPAssignmentResponse{Err: err}, nil
}
return getHostDEPAssignmentResponse{
ID: req.ID,
HostDEPAssignment: depAssignment,
DEPDevice: depDevice,
}, nil
}
func (svc *Service) GetHostDEPAssignmentDetails(ctx context.Context, hostID uint) (*fleet.HostDEPAssignment, *godep.Device, error) {
// Load the host first so we can do a team-aware authorization check,
// mirroring what GET /hosts/:id does.
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, nil, err
}
host, err := svc.ds.HostLite(ctx, hostID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "get host for dep assignment")
}
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return nil, nil, err
}
// Fetch Fleet's DEP assignment record. A not-found error means the host is
// not a DEP host; return all nils so the response contains JSON nulls.
depAssignment, err := svc.ds.GetHostDEPAssignment(ctx, hostID)
if err != nil {
if fleet.IsNotFound(err) {
return nil, nil, nil
}
return nil, nil, ctxerr.Wrap(ctx, err, "get host dep assignment")
}
// Without an ABM token ID we can't resolve which org name to use for the
// Apple API call, so return what we have from Fleet's DB.
if depAssignment.ABMTokenID == nil {
return depAssignment, nil, nil
}
abmToken, err := svc.ds.GetABMTokenByID(ctx, *depAssignment.ABMTokenID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "get ABM token for dep assignment")
}
// If Apple MDM is not configured (e.g. free tier), depStorage will be nil
// and NewDEPClient would panic. Return what we have from Fleet's DB.
if svc.depStorage == nil {
return depAssignment, nil, nil
}
// Call Apple's "Get Device Details" API. Per the issue spec: on error, log
// and return dep_device as nil rather than surfacing the error to the caller.
depClient := apple_mdm.NewDEPClient(svc.depStorage, svc.ds, svc.logger)
depDevice, err := depClient.GetDeviceDetails(ctx, abmToken.OrganizationName, host.HardwareSerial)
if err != nil {
svc.logger.ErrorContext(ctx, "get DEP device details from ABM",
"host_id", hostID,
"org_name", abmToken.OrganizationName,
"err", err,
)
return depAssignment, nil, nil
}
return depAssignment, depDevice, nil
}
////////////////////////////////////////////////////////////////////////////////
// MDM
////////////////////////////////////////////////////////////////////////////////
+220
View File
@@ -14,6 +14,7 @@ import (
"path/filepath"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
@@ -3236,3 +3237,222 @@ func (s *integrationMDMTestSuite) TestSoftwareInventoryForADEMacOSAfterWipeAndRe
require.Equal(t, titleID2, getHostSw.Software[1].ID)
require.Equal(t, installerPayload2.Title, getHostSw.Software[1].Name)
}
func (s *integrationMDMTestSuite) TestGetHostDEPAssignment() {
t := s.T()
// ------------------------------------------------------------------
// 1. Set up ABM / DEP mock infrastructure
// ------------------------------------------------------------------
orgName := t.Name()
abmToken := s.enableABM(orgName)
require.NotNil(t, abmToken)
// Serial number for the DEP device we will simulate
depSerial := uuid.New().String()
// Apple's "Get Device Details" response that the mock ABM server will return
// when our endpoint calls /devices.
fakeProfileUUID := uuid.New().String()
fakeProfileAssignTime := time.Now().UTC().Truncate(time.Second)
fakeProfilePushTime := fakeProfileAssignTime.Add(5 * time.Second)
// Keep track of calls to the /devices endpoint so we can assert it was hit.
// Use atomic.Int32 to avoid data races since the handler runs in a separate goroutine.
var deviceDetailsCalled atomic.Int32
s.mockDEPResponse(orgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
case "/account":
_, _ = fmt.Fprintf(w, `{"admin_id": "abc", "org_name": %q}`, orgName)
case "/profile":
require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: fakeProfileUUID}))
case "/server/devices":
require.NoError(t, encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{
{
SerialNumber: depSerial,
Model: "MacBook Pro",
OS: "osx",
OpType: "added",
ProfileStatus: "assigned",
ProfileUUID: fakeProfileUUID,
ProfileAssignTime: fakeProfileAssignTime,
ProfilePushTime: fakeProfilePushTime,
},
}}))
case "/devices/sync":
require.NoError(t, encoder.Encode(godep.DeviceResponse{Cursor: "done"}))
case "/profile/devices":
b, err := io.ReadAll(r.Body)
require.NoError(t, err)
var req profileAssignmentReq
require.NoError(t, json.Unmarshal(b, &req))
resp := godep.ProfileResponse{ProfileUUID: req.ProfileUUID, Devices: make(map[string]string)}
for _, d := range req.Devices {
resp.Devices[d] = string(fleet.DEPAssignProfileResponseSuccess)
}
require.NoError(t, encoder.Encode(resp))
case "/devices":
// Apple's "Get Device Details" endpoint — called by our new endpoint.
deviceDetailsCalled.Add(1)
require.NoError(t, encoder.Encode(map[string]any{
"devices": map[string]any{
depSerial: map[string]any{
"serial_number": depSerial,
"model": "MacBook Pro",
"profile_status": "assigned",
"profile_uuid": fakeProfileUUID,
"profile_assign_time": fakeProfileAssignTime.Format(time.RFC3339),
"profile_push_time": fakeProfilePushTime.Format(time.RFC3339),
"device_family": "Mac",
},
},
}))
default:
_, _ = w.Write([]byte(`{}`))
}
}))
// Run the DEP sync so Fleet ingests the device and creates a host +
// host_dep_assignments row.
s.runDEPSchedule()
// Find the host that was just created by the DEP sync.
var listResp listHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp)
var depHost *fleet.Host
for _, h := range listResp.Hosts {
if h.HardwareSerial == depSerial {
depHost = h.Host
break
}
}
require.NotNil(t, depHost, "expected to find DEP host after sync")
// ------------------------------------------------------------------
// 2. Happy path: DEP host returns both fleet record + Apple details
// ------------------------------------------------------------------
deviceDetailsCalled.Store(0)
var depResp getHostDEPAssignmentResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/dep_assignment", depHost.ID), nil, http.StatusOK, &depResp)
// host_dep_assignment must be present and reference the right host
require.NotNil(t, depResp.HostDEPAssignment, "host_dep_assignment should not be nil for a DEP host")
require.Equal(t, depHost.ID, depResp.ID)
require.False(t, depResp.HostDEPAssignment.AddedAt.IsZero(), "added_at should be set")
require.Nil(t, depResp.HostDEPAssignment.DeletedAt, "deleted_at should be nil for an active DEP host")
// ABMTokenID is the FK linking this record to its ABM token and should be present in the response
require.NotNil(t, depResp.HostDEPAssignment.ABMTokenID, "abm_token_id should be present in the response")
// the DEP sync calls /profile/devices which writes profile_uuid, assign_profile_response, and response_updated_at
require.NotNil(t, depResp.HostDEPAssignment.ProfileUUID, "profile_uuid should be set after DEP sync")
require.Equal(t, fakeProfileUUID, *depResp.HostDEPAssignment.ProfileUUID)
require.NotNil(t, depResp.HostDEPAssignment.AssignProfileResponse, "assign_profile_response should be set after DEP sync")
require.Equal(t, fleet.DEPAssignProfileResponseSuccess, *depResp.HostDEPAssignment.AssignProfileResponse)
require.NotNil(t, depResp.HostDEPAssignment.ResponseUpdatedAt, "response_updated_at should be set after DEP sync")
// migration fields are not set during a plain DEP sync
require.Nil(t, depResp.HostDEPAssignment.MDMMigrationDeadline, "mdm_migration_deadline should be nil for a freshly synced host")
require.Nil(t, depResp.HostDEPAssignment.MDMMigrationCompleted, "mdm_migration_completed should be nil for a freshly synced host")
// Set a migration deadline and mark migration completed, then re-fetch to
// confirm both fields are surfaced in the response.
migrationDeadline := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second)
require.NoError(t, s.ds.UpsertMDMAppleHostDEPAssignments(
context.Background(),
[]fleet.Host{*depHost},
*depResp.HostDEPAssignment.ABMTokenID,
map[uint]time.Time{depHost.ID: migrationDeadline},
))
require.NoError(t, s.ds.SetHostMDMMigrationCompleted(context.Background(), depHost.ID))
deviceDetailsCalled.Store(0)
var migrationResp getHostDEPAssignmentResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/dep_assignment", depHost.ID), nil, http.StatusOK, &migrationResp)
require.NotNil(t, migrationResp.HostDEPAssignment.MDMMigrationDeadline, "mdm_migration_deadline should be set after upsert")
require.WithinDuration(t, migrationDeadline, *migrationResp.HostDEPAssignment.MDMMigrationDeadline, time.Second)
require.NotNil(t, migrationResp.HostDEPAssignment.MDMMigrationCompleted, "mdm_migration_completed should be set after SetHostMDMMigrationCompleted")
require.WithinDuration(t, migrationDeadline, *migrationResp.HostDEPAssignment.MDMMigrationCompleted, time.Second)
// dep_device must be present and contain Apple's live data
require.NotNil(t, depResp.DEPDevice, "dep_device should not be nil for a DEP host with a valid ABM token")
require.Equal(t, depSerial, depResp.DEPDevice.SerialNumber)
require.Equal(t, "MacBook Pro", depResp.DEPDevice.Model)
require.Equal(t, "assigned", depResp.DEPDevice.ProfileStatus)
require.Equal(t, fakeProfileUUID, depResp.DEPDevice.ProfileUUID)
// The mock ABM /devices endpoint should have been called exactly once.
require.Equal(t, int32(1), deviceDetailsCalled.Load(), "expected exactly one call to Apple's Get Device Details API")
// ------------------------------------------------------------------
// 3. Non-DEP host: both fields should be null
// ------------------------------------------------------------------
nonDEPHost := createOrbitEnrolledHost(t, "darwin", "non-dep", s.ds)
deviceDetailsCalled.Store(0)
var nonDEPResp getHostDEPAssignmentResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/dep_assignment", nonDEPHost.ID), nil, http.StatusOK, &nonDEPResp)
require.Nil(t, nonDEPResp.HostDEPAssignment, "host_dep_assignment should be null for a non-DEP host")
require.Nil(t, nonDEPResp.DEPDevice, "dep_device should be null for a non-DEP host")
// Apple's API should never be called for a non-DEP host.
require.Equal(t, int32(0), deviceDetailsCalled.Load(), "Apple Get Device Details should not be called for a non-DEP host")
// ------------------------------------------------------------------
// 4. Non-Apple (Windows) host: both fields should be null, Apple API never called
// ------------------------------------------------------------------
windowsHost := createOrbitEnrolledHost(t, "windows", "non-apple", s.ds)
deviceDetailsCalled.Store(0)
var windowsResp getHostDEPAssignmentResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/dep_assignment", windowsHost.ID), nil, http.StatusOK, &windowsResp)
require.Nil(t, windowsResp.HostDEPAssignment, "host_dep_assignment should be null for a non-Apple host")
require.Nil(t, windowsResp.DEPDevice, "dep_device should be null for a non-Apple host")
// Apple's DEP API must never be called for a non-Apple host.
require.Equal(t, int32(0), deviceDetailsCalled.Load(), "Apple Get Device Details should not be called for a non-Apple host")
// ------------------------------------------------------------------
// 5. ABM returns an error: host_dep_assignment is populated, dep_device is null
// ------------------------------------------------------------------
s.mockDEPResponse(orgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
case "/devices":
// Simulate a server-side ABM error
deviceDetailsCalled.Add(1)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"code":"INTERNAL_ERROR","message":"something went wrong"}`))
default:
_, _ = w.Write([]byte(`{}`))
}
}))
deviceDetailsCalled.Store(0)
var abmErrResp getHostDEPAssignmentResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/dep_assignment", depHost.ID), nil, http.StatusOK, &abmErrResp)
// Fleet's record should still come back
require.NotNil(t, abmErrResp.HostDEPAssignment, "host_dep_assignment should still be returned when ABM errors")
require.Equal(t, depHost.ID, abmErrResp.ID)
// But Apple's data should be nil
require.Nil(t, abmErrResp.DEPDevice, "dep_device should be null when ABM returns an error")
// The mock endpoint was still called once (the attempt was made)
require.Equal(t, int32(1), deviceDetailsCalled.Load(), "Apple Get Device Details should still be attempted even when it errors")
// ------------------------------------------------------------------
// 6. Unauthenticated request: should be rejected
// ------------------------------------------------------------------
savedToken := s.token
s.token = "bad-token"
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/dep_assignment", depHost.ID), nil, http.StatusUnauthorized, &getHostDEPAssignmentResponse{})
s.token = savedToken
// ------------------------------------------------------------------
// 7. Non-existent host: should return 404
// ------------------------------------------------------------------
s.DoJSON("GET", "/api/latest/fleet/hosts/999999/dep_assignment", nil, http.StatusNotFound, &getHostDEPAssignmentResponse{})
}