<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #49777 ## Description Adds `host_id` and `host_serial` to the Apple `mdm_enrolled` activity so IT admins can build automations on top of it, and surfaces the activity on the individual host's activity timeline. - **`server/fleet/activities.go`** — added `HostID` to `ActivityTypeMDMEnrolled` and a `HostIDs()` method (mirrors the existing `ActivityTypeMDMUnenrolled` pattern), so the activity is linked to the host and appears on its timeline. - **`server/mdm/lifecycle/lifecycle.go`** — populate `host_id` for macOS/iOS/iPadOS enrollments. Account-driven user (BYOD) enrollments have no hardware serial, so they report the enrollment ID as `host_serial` too, keeping `host_serial` populated for automations regardless of enrollment type. - **Frontend** — new `MdmEnrolledActivityItem` component, registered in the host past-activity component map (and the `IHostPastActivityType` union), renders the now-host-linked `mdm_enrolled` activity on the host details **Activity** card. There's no Figma, so the copy mirrors the sibling `mdm_unenrolled` item (e.g. "Mobile device management (MDM) was turned on for this host"). `host_id` uses `omitempty`, so Windows (`microsoft_mdm.go`) enrollments keep their existing activity payload unchanged — Windows is intentionally out of scope, handled in #47874, which also owns the audit-log documentation update for the shared field. > **For reviewer:** the ADUE `host_serial = enrollment_id` behavior comes from the issue's test plan. It means `host_serial` and `enrollment_id` carry the same value for BYOD. Flagging in case Product would rather leave `host_serial` empty for ADUE and have automations read `enrollment_id`. ## Testing - **Automated:** `TestMDMEnrolledActivityHostIDAndSerial` (`server/mdm/lifecycle`) covers device enrollment (`host_serial` = hardware serial) and ADUE (`host_serial` = enrollment ID), both asserting `host_id`/`HostIDs()`. Also verified `server/datastore/mysql` `TestMDMEnrollment`, `server/activity/internal/mysql` `TestListActivities`, and `server/service` `TestMDMTokenUpdate*` pass. - **Live (simulated) manual macOS enrollment** via `osquery-perf`: the `mdm_enrolled` activity recorded `host_id` + `host_serial`, and an `activity_host_past` row linked it to the host (confirmed it shows on the host timeline). - **Frontend:** `MdmEnrolledActivityItem.tests.tsx` covers the rendered copy for macOS/iOS/Android and the actor/no-actor variants; also visually confirmed the activity renders on a host's Activity card in the running app. `yarn jest`, `eslint`, and `tsc` pass. - Updated the MDM integration tests (`integration_mdm_test.go`, `integration_mdm_dep_test.go`, `integration_vpp_install_test.go`) whose activity-detail and host-feed assertions changed now that `mdm_enrolled` carries `host_id` and appears on the host timeline (feed assertions now filter by activity type). - **Pending on-device QA (next week):** DEP/ADE macOS and account-driven user enrollment (iOS/iPadOS) on real hardware, per the issue's test plan. - Regression: Windows `mdm_enrolled` payload is unchanged (`host_id` is omitted when zero); both platforms' `mdm_unenrolled` are unaffected. # Screenshot for the frontend change <img width="706" height="382" alt="Screenshot 2026-07-28 at 11 16 57 AM" src="https://github.com/user-attachments/assets/8f57d129-f819-4399-8754-18b397a49db8" /> # Checklist for submitter - [x] Changes file added for user-visible changes in `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 - [ ] QA'd all new/changed functionality manually <!-- manual macOS verified via simulator; DEP + real-device ADUE pending next week --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Added support for rendering “MDM enrolled” in the host activity feed with platform- and actor-aware messaging. * **Bug Fixes** * Updated Apple “MDM enrolled” activity details to include the correct host identifier and serial/enrollment identifiers. * Ensured host-scoped activity behavior applies only when the host is known (host id present). * **Tests** * Expanded regression and integration coverage for “MDM enrolled” activity details and feed contents, including VPP-related assertion stability. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Magnus Jensen <magnus@fleetdm.com>
294 lines
12 KiB
Go
294 lines
12 KiB
Go
package mdmlifecycle
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"testing"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
|
"github.com/fleetdm/fleet/v4/server/mock"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func nopNewActivity(ctx context.Context, user *fleet.User, details fleet.ActivityDetails) error {
|
|
return nil
|
|
}
|
|
|
|
func TestDoUnsupportedParams(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
|
|
|
|
err := lc.Do(context.Background(), HostOptions{})
|
|
require.ErrorContains(t, err, "unsupported platform")
|
|
|
|
err = lc.Do(context.Background(), HostOptions{Platform: "linux"})
|
|
require.ErrorContains(t, err, "unsupported platform")
|
|
|
|
err = lc.Do(context.Background(), HostOptions{Platform: "darwin", Action: "invalid"})
|
|
require.ErrorContains(t, err, "unknown action")
|
|
|
|
err = lc.Do(context.Background(), HostOptions{Platform: "windows", Action: "invalid"})
|
|
require.ErrorContains(t, err, "unknown action")
|
|
}
|
|
|
|
func TestDoParamValidation(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
lf := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
|
|
ctx := context.Background()
|
|
|
|
cases := []struct {
|
|
platform string
|
|
action HostAction
|
|
wantErr bool
|
|
}{
|
|
|
|
{"darwin", HostActionTurnOn, true},
|
|
{"darwin", HostActionTurnOff, true},
|
|
{"darwin", HostActionReset, true},
|
|
{"darwin", HostActionDelete, true},
|
|
{"windows", HostActionTurnOn, true},
|
|
{"windows", HostActionTurnOff, true},
|
|
{"windows", HostActionReset, true},
|
|
{"windows", HostActionDelete, false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
err := lf.Do(ctx, HostOptions{
|
|
Action: tc.action,
|
|
Platform: tc.platform,
|
|
})
|
|
if tc.wantErr {
|
|
require.ErrorContains(t, err, "required")
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReconcileHostNameEnforcementOnEnrollment verifies that both Apple
|
|
// enrollment lifecycle branches — turn-on (TokenUpdate) and reset (Authenticate,
|
|
// covering re-enrollment) — reconcile the host's host-name template enforcement,
|
|
// so a host enrolling into a team with a template gets a queued row.
|
|
func TestReconcileHostNameEnforcementOnEnrollment(t *testing.T) {
|
|
ctx := license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierPremium})
|
|
const hostID = uint(99)
|
|
|
|
t.Run("turn-on reconciles with the enrolled host id", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, uuid string) (*fleet.NanoEnrollment, error) {
|
|
return &fleet.NanoEnrollment{Enabled: true, Type: "Device", TokenUpdateTally: 1}, nil
|
|
}
|
|
ds.GetHostMDMCheckinInfoFunc = func(ctx context.Context, uuid string) (*fleet.HostMDMCheckinInfo, error) {
|
|
return &fleet.HostMDMCheckinInfo{HostID: hostID, Platform: "darwin"}, nil
|
|
}
|
|
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil }
|
|
var gotIDs []uint
|
|
ds.ReconcileHostDeviceNamesForHostsFunc = func(ctx context.Context, hostIDs []uint) error {
|
|
gotIDs = hostIDs
|
|
return nil
|
|
}
|
|
|
|
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
|
|
require.NoError(t, lc.Do(ctx, HostOptions{Action: HostActionTurnOn, Platform: "darwin", UUID: "host-uuid"}))
|
|
require.True(t, ds.ReconcileHostDeviceNamesForHostsFuncInvoked)
|
|
require.Equal(t, []uint{hostID}, gotIDs)
|
|
})
|
|
|
|
t.Run("turn-on reconciles on the DEP branch before its early return", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, uuid string) (*fleet.NanoEnrollment, error) {
|
|
return &fleet.NanoEnrollment{Enabled: true, Type: "Device", TokenUpdateTally: 1}, nil
|
|
}
|
|
// DEPAssignedToFleet takes the DEP branch, which queues a job and returns
|
|
// early — the reconcile must run before that branch.
|
|
ds.GetHostMDMCheckinInfoFunc = func(ctx context.Context, uuid string) (*fleet.HostMDMCheckinInfo, error) {
|
|
return &fleet.HostMDMCheckinInfo{HostID: hostID, Platform: "darwin", DEPAssignedToFleet: true}, nil
|
|
}
|
|
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil }
|
|
var gotIDs []uint
|
|
ds.ReconcileHostDeviceNamesForHostsFunc = func(ctx context.Context, hostIDs []uint) error {
|
|
gotIDs = hostIDs
|
|
return nil
|
|
}
|
|
|
|
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
|
|
require.NoError(t, lc.Do(ctx, HostOptions{Action: HostActionTurnOn, Platform: "darwin", UUID: "host-uuid"}))
|
|
require.True(t, ds.ReconcileHostDeviceNamesForHostsFuncInvoked)
|
|
require.Equal(t, []uint{hostID}, gotIDs)
|
|
require.True(t, ds.NewJobFuncInvoked, "DEP branch should have been taken")
|
|
})
|
|
|
|
t.Run("turn-on skips reconcile when the enrollment is not ready", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
// TokenUpdateTally != 1 makes turnOnApple short-circuit before reconciling.
|
|
ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, uuid string) (*fleet.NanoEnrollment, error) {
|
|
return &fleet.NanoEnrollment{Enabled: true, Type: "Device", TokenUpdateTally: 2}, nil
|
|
}
|
|
ds.ReconcileHostDeviceNamesForHostsFunc = func(ctx context.Context, hostIDs []uint) error { return nil }
|
|
|
|
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
|
|
require.NoError(t, lc.Do(ctx, HostOptions{Action: HostActionTurnOn, Platform: "darwin", UUID: "host-uuid"}))
|
|
require.False(t, ds.ReconcileHostDeviceNamesForHostsFuncInvoked)
|
|
})
|
|
|
|
t.Run("reset reconciles with the upserted host id", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
ds.MDMAppleUpsertHostFunc = func(ctx context.Context, mdmHost *fleet.Host, fromPersonalEnrollment bool) error {
|
|
mdmHost.ID = hostID
|
|
return nil
|
|
}
|
|
ds.MDMResetEnrollmentFunc = func(ctx context.Context, uuid string, scepRenewalInProgress bool) error { return nil }
|
|
var gotIDs []uint
|
|
ds.ReconcileHostDeviceNamesForHostsFunc = func(ctx context.Context, hostIDs []uint) error {
|
|
gotIDs = hostIDs
|
|
return nil
|
|
}
|
|
|
|
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
|
|
require.NoError(t, lc.Do(ctx, HostOptions{
|
|
Action: HostActionReset, Platform: "darwin",
|
|
UUID: "host-uuid", HardwareSerial: "serial", HardwareModel: "MacBookPro",
|
|
}))
|
|
require.True(t, ds.ReconcileHostDeviceNamesForHostsFuncInvoked)
|
|
require.Equal(t, []uint{hostID}, gotIDs)
|
|
})
|
|
|
|
t.Run("reset skips reconcile during SCEP renewal", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
ds.MDMResetEnrollmentFunc = func(ctx context.Context, uuid string, scepRenewalInProgress bool) error { return nil }
|
|
ds.ReconcileHostDeviceNamesForHostsFunc = func(ctx context.Context, hostIDs []uint) error { return nil }
|
|
|
|
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
|
|
require.NoError(t, lc.Do(ctx, HostOptions{
|
|
Action: HostActionReset, Platform: "darwin",
|
|
UUID: "host-uuid", HardwareSerial: "serial", HardwareModel: "MacBookPro",
|
|
SCEPRenewalInProgress: true,
|
|
}))
|
|
require.False(t, ds.ReconcileHostDeviceNamesForHostsFuncInvoked)
|
|
})
|
|
}
|
|
|
|
// TestDeleteAppleDuplicateDEPHost verifies that deleting one of a set of
|
|
// duplicate DEP hosts (same serial) does not recreate a pending "ghost" host
|
|
// when another DEP-assigned host with that serial still exists, while a host
|
|
// with no duplicate is still restored as before.
|
|
// TestMDMEnrolledActivityHostIDAndSerial verifies the Apple mdm_enrolled activity
|
|
// carries host_id (so it lands on the host's activity timeline via HostIDs) and a
|
|
// populated host_serial for both device and account-driven user (BYOD)
|
|
// enrollments. Regression coverage for #49777.
|
|
func TestMDMEnrolledActivityHostIDAndSerial(t *testing.T) {
|
|
ctx := license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierPremium})
|
|
const hostID = uint(99)
|
|
|
|
// runTurnOn drives an Apple turn-on enrollment of the given nano enrollment
|
|
// type and returns the mdm_enrolled activity that was recorded.
|
|
runTurnOn := func(t *testing.T, enrollType, hardwareSerial string, opts HostOptions) *fleet.ActivityTypeMDMEnrolled {
|
|
ds := new(mock.Store)
|
|
ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, uuid string) (*fleet.NanoEnrollment, error) {
|
|
return &fleet.NanoEnrollment{Enabled: true, Type: enrollType, TokenUpdateTally: 1}, nil
|
|
}
|
|
ds.GetHostMDMCheckinInfoFunc = func(ctx context.Context, uuid string) (*fleet.HostMDMCheckinInfo, error) {
|
|
return &fleet.HostMDMCheckinInfo{HostID: hostID, Platform: opts.Platform, HardwareSerial: hardwareSerial}, nil
|
|
}
|
|
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil }
|
|
ds.ReconcileHostDeviceNamesForHostsFunc = func(ctx context.Context, hostIDs []uint) error { return nil }
|
|
|
|
var captured *fleet.ActivityTypeMDMEnrolled
|
|
newAct := func(ctx context.Context, user *fleet.User, details fleet.ActivityDetails) error {
|
|
if a, ok := details.(*fleet.ActivityTypeMDMEnrolled); ok {
|
|
captured = a
|
|
}
|
|
return nil
|
|
}
|
|
|
|
lc := New(ds, slog.New(slog.DiscardHandler), newAct)
|
|
require.NoError(t, lc.Do(ctx, opts))
|
|
require.NotNil(t, captured, "an mdm_enrolled activity should have been recorded")
|
|
return captured
|
|
}
|
|
|
|
t.Run("device enrollment: host_id set, host_serial is the hardware serial", func(t *testing.T) {
|
|
a := runTurnOn(t, mdm.EnrollType(mdm.Device).String(), "C08VQ2AXHT96",
|
|
HostOptions{Action: HostActionTurnOn, Platform: "darwin", UUID: "host-uuid"})
|
|
|
|
require.Equal(t, hostID, a.HostID)
|
|
require.Equal(t, []uint{hostID}, a.HostIDs())
|
|
require.NotNil(t, a.HostSerial)
|
|
require.Equal(t, "C08VQ2AXHT96", *a.HostSerial)
|
|
require.Nil(t, a.EnrollmentID)
|
|
})
|
|
|
|
t.Run("account-driven user enrollment: host_id set, host_serial is the enrollment id", func(t *testing.T) {
|
|
a := runTurnOn(t, mdm.EnrollType(mdm.UserEnrollmentDevice).String(), "",
|
|
HostOptions{Action: HostActionTurnOn, Platform: "ios", UUID: "ADUE-ENROLL-ID", UserEnrollmentID: "ADUE-ENROLL-ID"})
|
|
|
|
require.Equal(t, hostID, a.HostID)
|
|
require.Equal(t, []uint{hostID}, a.HostIDs())
|
|
require.NotNil(t, a.EnrollmentID)
|
|
require.Equal(t, "ADUE-ENROLL-ID", *a.EnrollmentID)
|
|
require.NotNil(t, a.HostSerial)
|
|
require.Equal(t, "ADUE-ENROLL-ID", *a.HostSerial)
|
|
})
|
|
}
|
|
|
|
func TestDeleteAppleDuplicateDEPHost(t *testing.T) {
|
|
ctx := license.NewContext(context.Background(), &fleet.LicenseInfo{Tier: fleet.TierPremium})
|
|
|
|
const serial = "ABC123XYZ"
|
|
host := &fleet.Host{ID: 1, HardwareSerial: serial, Platform: "darwin"}
|
|
|
|
newDS := func(dupExists bool) *mock.Store {
|
|
ds := new(mock.Store)
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
ac := &fleet.AppConfig{}
|
|
ac.MDM.AppleBMEnabledAndConfigured = true
|
|
return ac, nil
|
|
}
|
|
abmTokenID := uint(7)
|
|
ds.GetHostDEPAssignmentFunc = func(ctx context.Context, hostID uint) (*fleet.HostDEPAssignment, error) {
|
|
return &fleet.HostDEPAssignment{HostID: hostID, ABMTokenID: &abmTokenID}, nil
|
|
}
|
|
ds.ReconcileDuplicateDEPHostOnDeleteFunc = func(ctx context.Context, s, platform string, deletedHostID uint) (bool, error) {
|
|
require.Equal(t, serial, s)
|
|
require.Equal(t, host.Platform, platform)
|
|
require.Equal(t, host.ID, deletedHostID)
|
|
return dupExists, nil
|
|
}
|
|
ds.RestoreMDMApplePendingDEPHostFunc = func(ctx context.Context, h *fleet.Host) error {
|
|
return nil
|
|
}
|
|
return ds
|
|
}
|
|
|
|
t.Run("duplicate exists, ghost host not restored", func(t *testing.T) {
|
|
ds := newDS(true)
|
|
|
|
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
|
|
err := lc.Do(ctx, HostOptions{Action: HostActionDelete, Platform: "darwin", Host: host})
|
|
require.NoError(t, err)
|
|
|
|
require.True(t, ds.ReconcileDuplicateDEPHostOnDeleteFuncInvoked)
|
|
require.False(t, ds.RestoreMDMApplePendingDEPHostFuncInvoked)
|
|
})
|
|
|
|
t.Run("no duplicate, ghost host restored", func(t *testing.T) {
|
|
ds := newDS(false)
|
|
// "No team" default keeps getDefaultTeamForABMToken from needing TeamExists.
|
|
ds.GetABMTokenByIDFunc = func(ctx context.Context, tokenID uint) (*fleet.ABMToken, error) {
|
|
return &fleet.ABMToken{ID: tokenID}, nil
|
|
}
|
|
ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
|
|
return job, nil
|
|
}
|
|
|
|
lc := New(ds, slog.New(slog.DiscardHandler), nopNewActivity)
|
|
err := lc.Do(ctx, HostOptions{Action: HostActionDelete, Platform: "darwin", Host: host})
|
|
require.NoError(t, err)
|
|
|
|
require.True(t, ds.ReconcileDuplicateDEPHostOnDeleteFuncInvoked)
|
|
require.True(t, ds.RestoreMDMApplePendingDEPHostFuncInvoked)
|
|
})
|
|
}
|