diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index e796ab7af5..1b5cdbaac2 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -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)) diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index f72a4968af..8860db8229 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -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 { diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 33a573e208..81d3f8442b 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -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 { diff --git a/server/fleet/service.go b/server/fleet/service.go index db6cf4d77e..ae8bb1ea4a 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -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. diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 76e622a27e..b2b8ebd9fa 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -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 diff --git a/server/service/handler.go b/server/service/handler.go index fe0eac7e40..e7ff9d5a31 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -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{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index 7006c02fbd..b1520fd643 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -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 //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go index 2434aeda89..a8a4b584db 100644 --- a/server/service/integration_mdm_dep_test.go +++ b/server/service/integration_mdm_dep_test.go @@ -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{}) +}